Plugin Runtime service specification
Plugin Runtime service specification
Responsibility
Manage plugin process lifecycle and stdin/stdout JSON-RPC communication. Translates logical method calls into JSON-RPC writes and resolves responses.
The Plugin Runtime owns process I/O. No other service reads or writes plugin stdio.
Public API
impl PluginRuntime {
/// All request methods enforce per-method timeouts internally.
/// Callers await the result directly — no oneshot management needed.
pub async fn search(&self, plugin_id: &str, query: SearchQuery)
-> Result<Vec<SearchResult>, PluginError>;
pub async fn get_track(&self, plugin_id: &str, content_id: &str)
-> Result<Track, PluginError>;
pub async fn get_album(&self, plugin_id: &str, content_id: &str)
-> Result<Album, PluginError>;
pub async fn get_artist(&self, plugin_id: &str, content_id: &str)
-> Result<Artist, PluginError>;
pub async fn stream(&self, plugin_id: &str, content_id: &str)
-> Result<StreamInfo, PluginError>;
pub async fn ping(&self, plugin_id: &str)
-> Result<(), PluginError>;
/// Kill the plugin process immediately and mark for restart on next request.
/// No-op if plugin_id is not running.
/// Use for explicit abort (user cancels search). Not needed for timeouts —
/// the Plugin Runtime handles killing on timeout internally.
pub fn cancel(&self, plugin_id: &str);
pub fn list_plugins(&self) -> Vec<PluginInfo>;
pub fn plugin_status(&self, plugin_id: &str) -> Option<PluginStatus>;
}
Timeout enforcement
Timeouts are enforced internally by the Plugin Runtime, not by callers. Every outbound call is wrapped with its method-specific timeout:
impl PluginRuntime {
async fn call_with_timeout<T>(
&self,
plugin_id: &str,
req: Request,
timeout: Duration,
) -> Result<T, PluginError>
where
T: DeserializeOwned,
{
let result = tokio::time::timeout(timeout, self.call(plugin_id, req)).await;
match result {
Ok(Ok(response)) => Ok(response),
Ok(Err(e)) => Err(e),
Err(_) => {
// Timeout — kill the plugin process and return timeout error
self.cancel_internal(plugin_id);
Err(PluginError::Timeout)
}
}
}
}
| Method | Internal timeout |
|---|---|
ping |
2s |
search |
30s |
get_track |
10s |
get_album |
10s |
get_artist |
10s |
stream |
5s |
Callers never need to know these values. The oneshot::Receiver return type is eliminated — callers simply await Result<T, PluginError>.
Ownership boundary
- Plugin Runtime kills the plugin process on its own timeout. The timeout error and process kill are atomic.
- Callers (Search Manager, Library Service, etc.) receive
Err(PluginError::Timeout)and do NOT kill the plugin. They only handle the error. cancel()exists for explicit abort only (e.g., user clicks “cancel search”). It is not called on timeout.
PluginError
pub enum PluginError {
NotFound, // plugin_id doesn't exist / not registered
Unsupported, // plugin doesn't implement this capability
Timeout, // request exceeded method timeout
Crash, // process exited during request
Disabled, // plugin exceeded crash threshold
InvalidResponse, // JSON-RPC parse error or schema mismatch
Io(std::io::Error), // stdin/stdout pipe error
}
PluginInfo
pub struct PluginInfo {
pub id: String,
pub name: String,
pub version: String,
pub capabilities: Vec<PluginCapability>,
pub status: PluginStatus,
pub last_seen: Option<Instant>, // last successful request
pub crash_count: usize, // current sliding-window count
}
PluginStatus
pub enum PluginStatus {
Discovered, // manifest parsed, not yet launched
Registered, // registered and ready for launch
Launching, // process spawned, waiting for ping response
Ready, // ping successful, accepting requests
Busy, // request in flight
Crashed, // process exited unexpectedly
Disabled, // exceeded crash threshold
Terminated, // clean shutdown after idle timeout
}
Idle timeout
Per-plugin tokio::time::sleep task, reset on each request. When a plugin’s idle timer fires:
- Send
SIGTERM(Unix) /TerminateProcess(Windows) - Wait up to 2 seconds for clean exit
- If still running, send
SIGKILL(Unix) /TerminateProcess(Windows)
Do not use a global sweep — a sweep requires a lock on all plugin state, and idle timeout per plugin is purely local.
Default idle timeout: 5 minutes (configurable).
Crash threshold
In-memory, sliding window. Per-plugin VecDeque<Instant>:
- On each crash, push
Instant::now() - Drain entries older than 5 minutes from the front
- If
len() >= 5, set status toDisabled - No persistence — crash counts reset on app restart
A plugin disabled by crash threshold is not automatically re-enabled. Requires app restart.
Plugin discovery
Plugin directory is scanned on startup only. No hot-reload in v1. Adding or removing a plugin requires app restart.
Scan path: ~/.rymflux/plugins/<plugin-id>/plugin.toml
Selection: every subdirectory containing a parseable plugin.toml.
Invalid manifests are logged and skipped (FR-PLUGIN-1).
References
- ADR-001 (JSON-RPC over stdin/stdout)
- ADR-011 (plugin.toml format)
- FR-PLUGIN-1, FR-PLUGIN-2, FR-PLUGIN-3 (functional.md)