Tauri Workspace Lifecycle Side-Effect Pattern
Tauri Workspace Lifecycle Side-Effect Pattern
Problem
When adding a new subsystem that must start on workspace open and stop on workspace close (e.g., presence service, MCP server, embedding pipeline, agent harness), the wiring follows a specific pattern that’s easy to get wrong. Common mistakes: blocking workspace open on network calls, forgetting teardown on close, or not handling the auth-gated case.
Symptoms:
- Service
Option<Arc<...>>inManagerBundleis alwaysNone - Commands that check for the service always return empty/no-op
- Scaffolded code across all layers (domain, application, infrastructure, framework, frontend) but no events flow
#[allow(dead_code)]annotations on framework-layer code that should be reachable
Root Cause
The subsystem was scaffolded layer-by-layer but the workspace lifecycle wiring — the glue that connects layers at runtime — was never completed. Each layer works in isolation but nothing triggers construction, startup, or teardown.
Solution
The Canonical Pattern
Every workspace-lifecycle subsystem in commands/workspace.rs follows this shape:
// In open_workspace (and initialize_workspace):fn start_my_service(state: &AppState, app: &AppHandle, workspace: &domain::Workspace) { // 1. Tear down previous instance (workspace switch) { let mut lock = state.managers.my_service.lock(); *lock = None; }
// 2. Check preconditions (auth, settings, etc.) // Return early if not met — this is the "graceful degradation" gate. let has_session = tauri::async_runtime::block_on(state.cloud.auth_repo.get_session()); if !matches!(has_session, Ok(Some(_))) { tracing::debug!("my_service_skipped: no auth session"); return; }
// 3. Construct service with dependencies from AppState let service = Arc::new(MyService::new( state.cloud.cloud_client.config().clone(), Arc::clone(&state.cloud.auth_repo), ));
// 4. Store in AppState so commands can access it { let mut lock = state.managers.my_service.lock(); *lock = Some(Arc::clone(&service)); }
// 5. Async startup — spawn, never block workspace open let workspace_id = workspace.id.to_string(); tauri::async_runtime::spawn(async move { if let Err(e) = service.start(&workspace_id).await { tracing::warn!(error = %e, "my_service_start_failed"); } });}// In close_workspace:{ let service = { let mut lock = state.managers.my_service.lock(); lock.take() // Extract and clear in one step }; if let Some(svc) = service { // Teardown with timeout — block briefly, don't hang tauri::async_runtime::block_on(async move { match tokio::time::timeout( std::time::Duration::from_secs(2), svc.stop(), ).await { Ok(Ok(())) => tracing::info!("my_service_stopped"), Ok(Err(e)) => tracing::warn!(error = %e, "my_service_stop_failed"), Err(_) => tracing::warn!("my_service_stop_timed_out"), } }); }}Key Design Decisions
| Decision | Rationale |
|---|---|
| Join is spawned, leave is blocked | Join may open a network connection (slow, user waiting). Leave must complete before state is cleared — otherwise the service may try to operate on cleared state. |
Auth check uses block_on | Session check is local (keychain/memory), sub-millisecond. Safe to block. |
| Timeout on leave (2-3s) | Prevents hanging on dead connections during workspace close. |
lock.take() extracts and clears | Prevents double-stop if close_workspace is called twice. |
| Best-effort, never block | Construction failure is logged but workspace open always succeeds. |
Both Entry Points
Remember: there are two workspace-opening functions:
initialize_workspace— first launch or explicit initopen_workspace— opening an existing workspace
Both must call the new start_my_service function. Missing one causes the “works sometimes” bug.
Frontend Wiring Checklist
When the subsystem has frontend components:
- Event listeners — Mount the
useXxxListeners()hook inAppContent(alongsideuseAgentEventListeners,usePresenceListeners, etc.) - Store — Zustand store should handle the initial empty state gracefully (no events = no data = nothing renders)
- Page-level tracking — If the subsystem needs to track which page the user is on, subscribe to
selectedPageSlugin a dedicated hook and call a Tauri command on change - UI components — Mount at appropriate places (sidebar for workspace-level, page header for page-level)
Prevention
Checklist for Adding a New Lifecycle Subsystem
- Add
Mutex<Option<Arc<MyService>>>toManagerBundleinstate.rs - Initialize to
NoneinAppState::new() - Create
start_my_service()helper incommands/workspace.rs - Call from both
initialize_workspaceandopen_workspace - Add teardown block in
close_workspace(before state clearing) - Add Tauri commands in
commands/my_service.rs - Register commands in
main.rsSpecta builder - Run
pnpm generate:typesfor TypeScript bindings - Mount frontend hooks in
AppContent - Remove any
#[allow(dead_code)]from framework-layer code
Warning Signs
#[allow(dead_code)]on framework-layer functions (not domain/application) — usually means it’s scaffolded but not wiredOption<...>inAppStatethat’s initialized toNoneand never replaced- Frontend hooks/stores that exist but are never imported
References
- INK-702: Wire presence module end-to-end
apps/desktop/src-tauri/src/commands/workspace.rs— canonical examples:start_mcp_server,wire_agent_manager,start_embedding_pipeline,start_presence_serviceapps/desktop/src-tauri/src/state.rs—ManagerBundlestruct
Was this page helpful?
Thanks for your feedback!