Skip to content
Documentation GitHub
Architecture

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<...>> in ManagerBundle is always None
  • 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

DecisionRationale
Join is spawned, leave is blockedJoin 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_onSession 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 clearsPrevents double-stop if close_workspace is called twice.
Best-effort, never blockConstruction failure is logged but workspace open always succeeds.

Both Entry Points

Remember: there are two workspace-opening functions:

  • initialize_workspace — first launch or explicit init
  • open_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:

  1. Event listeners — Mount the useXxxListeners() hook in AppContent (alongside useAgentEventListeners, usePresenceListeners, etc.)
  2. Store — Zustand store should handle the initial empty state gracefully (no events = no data = nothing renders)
  3. Page-level tracking — If the subsystem needs to track which page the user is on, subscribe to selectedPageSlug in a dedicated hook and call a Tauri command on change
  4. 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>>> to ManagerBundle in state.rs
  • Initialize to None in AppState::new()
  • Create start_my_service() helper in commands/workspace.rs
  • Call from both initialize_workspace and open_workspace
  • Add teardown block in close_workspace (before state clearing)
  • Add Tauri commands in commands/my_service.rs
  • Register commands in main.rs Specta builder
  • Run pnpm generate:types for 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 wired
  • Option<...> in AppState that’s initialized to None and 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_service
  • apps/desktop/src-tauri/src/state.rsManagerBundle struct

Was this page helpful?