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::oneshotper 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:
SearchConfigcan 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)