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

ADR-007: Search manager — Rust backend with Tauri events

ADR-007: Search manager — Rust backend with Tauri events

Status

Accepted

Context

The search manager (FR-CORE-4) dispatches search queries to all registered plugins with the search capability, collects results with a configurable per-plugin timeout, merges them, and returns a unified result set.

Two viable homes exist for this component:

  • Rust backend: Lives alongside the plugin runtime and playback engine. Dispatches plugin process requests directly. Emits results to the frontend via Tauri events.
  • Svelte frontend: The frontend calls Tauri commands per-plugin (e.g., invoke('plugin_search', { plugin: 'spotify', query: '...' })), manages timeouts and merging in JavaScript.

The tradeoff is between concurrency control and iteration speed.

Decision

The search manager lives in the Rust backend. It receives search requests via a Tauri command (start_search), manages concurrent plugin dispatch with tokio::select!, and emits results progressively via Tauri events (search_result, search_done, search_error).

Architecture

Frontend (Svelte)

    │ invoke("start_search", { query: "..." })

Rust: Search Manager

    │ holds Arc<PluginRuntime>
    │ calls runtime.search() for each active plugin

Plugin Runtime

    │ translates method call → JSON-RPC write to stdin
    │ for each plugin process

    ├── Plugin A responds (stdout read) → oneshot::Receiver<Vec<Result>>
    ├── Plugin B times out → Plugin Runtime kills and returns timeout
    └── All responses collected


Search Manager

    │ tokio::select! across all oneshot::Receivers with shared deadline

    ├── Plugin A result → emit("search_result", { plugin: "A", results: [...] })
    ├── Plugin B timeout → emit("search_error", { plugin: "B", error: "timeout" })
    └── All done         → emit("search_done", { total: 42 })


Frontend listens to "search_result", "search_error", "search_done"

    ├── "search_result" → append to displayed results, deduplicate
    ├── "search_error"  → show per-plugin notification
    └── "search_done"   → show "N results from X plugins"

Forward path: Search Manager → Plugin Runtime

The Search Manager does not touch plugin stdio directly. It holds an Arc<PluginRuntime> and calls into it via a direct method call:

impl PluginRuntime {
    /// Dispatch a search request to one plugin.
    /// Returns a oneshot receiver that resolves with results or an error.
    pub async fn search(
        &self,
        plugin_id: &str,
        query: &SearchQuery,
    ) -> Result<oneshot::Receiver<Vec<SearchResult>>, PluginError>
}

The Search Manager calls runtime.search() for each active plugin in a loop, collects N oneshot::Receiver handles, then waits across all of them with tokio::select! and a single shared deadline. The Plugin Runtime owns process I/O: it translates the logical search call into a JSON-RPC write to the plugin’s stdin, reads the response from stdout, and resolves the oneshot.

This means:

  • Forward path: direct method call on Arc<PluginRuntime> — no channel, no queue.
  • Return path: tokio::sync::oneshot per plugin, then broadcast via Tauri event (per ADR-005).
  • These are different flows and use different mechanisms. This is intentional: the forward path is a synchronous (async) call that benefits from Rust’s type system; the return path is a one-shot message that benefits from channel semantics.

Ownership boundary:

  • Plugin Runtime owns: process lifecycle, stdin/stdout I/O, JSON-RPC serialization/deserialization.
  • Search Manager owns: dispatch logic (which plugins to call), timing (per-plugin timeouts), result merging and ranking.
  • The Search Manager does not read or write plugin stdio. It does not manage plugin processes. It does not parse JSON-RPC responses.

Ranking weights and deduplication heuristics are config-driven: Tauri command set_search_config updates a SearchConfig struct in the backend. This allows iteration on ranking without moving the manager to the frontend.

Tauri command surface:

#[tauri::command]
async fn start_search(app: AppHandle, query: String) -> Result<(), String>

#[tauri::command]
async fn cancel_search(app: AppHandle) -> Result<(), String>

#[tauri::command]
async fn set_search_config(app: AppHandle, config: SearchConfig) -> Result<(), String>

Alternatives considered

  • Frontend-managed search (N invokes + Promise.all): Each plugin requires a separate invoke() call. Tauri’s invoke is async but not designed for racing N plugin processes with a shared timeout. The frontend would need to track in-flight requests, handle plugin crash mid-invoke, and reimplement process supervision that Rust already has. Concretely: if a plugin crashes during search, the Rust-side handler for that invoke gets a broken-pipe error, but the frontend’s promise doesn’t resolve — it hangs until a browser-level timeout. This is fragile.
  • Single invoke returning all results: Simpler but blocks the frontend until all plugins respond or time out. Loses progressive UX. For a slow plugin (default 30s timeout per FR-PLUGIN-3), the user waits 30s before seeing any results.
  • Frontend manages timeouts but Rust manages processes: Worst of both worlds. Rust launches processes and handles IPC. Frontend tracks which plugins are in-flight via invoke response. No benefit over pure Rust + events.

Consequences

Positive:

  • Rust’s async primitives (tokio::select!, tokio::time::timeout) compose naturally with plugin process supervision (spawn process → send JSON-RPC → wait for response → handle crash/timeout → restart on next request).
  • Tauri events push results to the frontend progressively without the frontend managing in-flight state.
  • Configuration-driven ranking allows iteration on search quality without backend recompilation.

Negative:

  • Search heuristics (dedup matching, ranking weights) require backend changes to add new fields or algorithms. Mitigation: SearchConfig can expose feature flags for experimental heuristics without recompile.
  • The Tauri event surface adds a protocol contract between backend and frontend that must be kept in sync. Mitigation: shared TypeScript types for event payloads.

Postscript (Phase 3 refinement)

The oneshot::Receiver return type and per-plugin timeout ownership were later moved into PluginRuntime (see plugin-runtime.md and search-manager.md). This simplifies the Search Manager’s dispatch loop: it now awaits Result<Vec<SearchResult>> directly, and PluginRuntime enforces timeouts internally. The core decision — Rust backend + Tauri events — is unaffected.

References

  • FR-CORE-4 (functional.md)
  • ADR-005 (Tauri events for inter-component communication)

Was this page helpful?