Skip to content
Rymflux Documentation
Esc
navigateopen⌘Jpreview
On this page

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:

  1. Send SIGTERM (Unix) / TerminateProcess (Windows)
  2. Wait up to 2 seconds for clean exit
  3. 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>:

  1. On each crash, push Instant::now()
  2. Drain entries older than 5 minutes from the front
  3. If len() >= 5, set status to Disabled
  4. 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)

Was this page helpful?