From 251dceab95bdcbc82e14cdc801b247ffe5285480 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 6 Jun 2026 21:29:08 +0000 Subject: [PATCH] Fix mesh sidecar "state not managed" error on fresh installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `mesh_daemon_*` Tauri commands take `State>`, but that state was only registered with `.manage()` inside the success arm of `ensure_daemon_running()`, itself running in a spawned async task. So when daemon bring-up was slow or failed — a cold Windows boot with the sidecar still being virus-scanned, or a sidecar that wouldn't start at all — the state was never managed and every command (including the status poll the mesh UI drives) rejected with Tauri's cryptic internal error: state not managed for field 'state' on command 'mesh_daemon_status'. You must call '.manage()' before using this command The Err arm even had a comment claiming it registered a placeholder, but it did nothing. The success path was racy too: the window could invoke a command before the up-to-8s bring-up reached `.manage()`. Fix the lifecycle so the state is always present: - `MeshDaemon` now holds its connection (client, client_id, owned child) behind a `parking_lot::RwLock>`, so it can be created disconnected and filled in later. - Register the state synchronously in `setup`, before any window can invoke a command — no more race, no more failure-path gap. - A background reconnect loop attaches to (or spawns) the daemon, installs the live connection, pumps its event stream, and retries with backoff if bring-up fails or a daemon we were using later dies. A `shutting_down` flag stops the loop at app exit so it never orphans a daemon. Subscribe failure keeps a command-capable connection (no respawn churn), matching the prior behavior. - Commands return a clean "mesh daemon not connected" error until the connection is installed; the frontend already treats that as "daemon down" and keeps polling. - Widen the frontend status-poll budget (~6s -> ~20s) so a healthy-but-slow cold start isn't reported as a hard failure before the daemon binds. https://claude.ai/code/session_01WPJeZPUEnvPpLN8oFQCLTZ --- src-tauri/Cargo.toml | 9 +- src-tauri/src/main.rs | 122 ++++++++++++++++++------- src-tauri/src/mesh/daemon.rs | 123 ++++++++++++++++++++++---- src-tauri/src/mesh/daemon_commands.rs | 75 +++++++++++----- src/mesh-daemon.svelte.ts | 12 ++- 5 files changed, 270 insertions(+), 71 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bafce71..fe10b81 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,10 +34,11 @@ anyhow = "1" # line-delimited JSON socket the MyOwnMesh GUI uses. Unix-domain socket # on Unix, namespaced pipe on Windows. interprocess = { version = "2", features = ["tokio"] } -# Sync primitives for the Tauri daemon-state slot. parking_lot's Mutex -# is what the rest of the workspace uses (myownmesh-core ships it as a -# workspace dep); matching it here keeps the lockfile from picking up a -# second sync-primitives crate. +# Sync primitives for the Tauri daemon-state slot (the swappable mesh +# connection lives behind a parking_lot RwLock). parking_lot is what the +# rest of the workspace uses (myownmesh-core ships it as a workspace dep); +# matching it here keeps the lockfile from picking up a second +# sync-primitives crate. parking_lot = "0.12" axum = { version = "0.7", features = ["macros", "ws"] } tower = "0.5" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ee12be1..a950a72 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1868,42 +1868,99 @@ fn main() { // rather than a re-derivation of the protocol. { use std::sync::Arc; + use std::time::Duration; use tauri::{Emitter, Manager}; + + // Register the mesh-daemon state *synchronously*, before any + // window can invoke a command, so `State>` + // always resolves. It starts disconnected; the background + // task below attaches to (or spawns) the daemon and installs + // the live connection once it's up. Managing here — rather + // than inside the spawn, only on the bring-up success path as + // before — is what fixes the fresh-install failure: a daemon + // slow to bind its control socket (a cold Windows boot with + // the sidecar still being virus-scanned), or one that failed + // to start at all, previously meant `.manage()` never ran and + // every mesh command rejected with Tauri's cryptic "state not + // managed for field 'state' … You must call '.manage()'…". + // See `mesh::daemon::MeshDaemon`. + let daemon = Arc::new(mesh::daemon::MeshDaemon::disconnected()); + app.manage(daemon.clone()); + let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { - match mesh::daemon::ensure_daemon_running().await { - Ok((client, child)) => { - // Subscribe events first — gives us the - // ipc client_id needed for handler claims. - let (tx, mut rx) = tokio::sync::mpsc::channel::(256); - let client_id = match client.subscribe_events(tx).await { - Ok(id) => id, - Err(e) => { - eprintln!("daemon: event subscribe failed: {e}"); - // Daemon's up but events failed — keep going - // with a placeholder id so RPC/channel ops - // produce useful errors instead of panicking. - String::from("c-unbound") + // Reconnect loop: attach to (or spawn) the daemon, install + // the live connection, then pump its event stream to the + // frontend. If bring-up fails, or a daemon we were using + // later dies, back off and try again rather than wedging + // the mesh UI until the app restarts. Commands return a + // clean "daemon not connected" error meanwhile (see + // `mesh::daemon_commands`). + let mut backoff = Duration::from_secs(1); + while !daemon.is_shutting_down() { + match mesh::daemon::ensure_daemon_running().await { + Ok((client, child)) => { + // Raced into app exit while bringing up? Drop + // the freshly spawned child (its `Drop` kills + // it) instead of orphaning it past our exit. + if daemon.is_shutting_down() { + drop(child); + break; } - }; - let state = - Arc::new(mesh::daemon::MeshDaemon::new(client, client_id, child)); - app_handle.manage(state); - // Pump events into the frontend. - let emit_handle = app_handle.clone(); - tauri::async_runtime::spawn(async move { - while let Some(frame) = rx.recv().await { - let _ = emit_handle.emit("mesh://event", frame); + // Subscribe to the event stream first — its + // ack carries the ipc client_id RPC/channel + // claims need. + let (tx, mut rx) = + tokio::sync::mpsc::channel::(256); + match client.subscribe_events(tx).await { + Ok(client_id) => { + backoff = Duration::from_secs(1); + daemon.set_connected(client, client_id, child); + // Pump events into the frontend until + // the stream ends (daemon gone), then + // drop the connection and reconnect. + while let Some(frame) = rx.recv().await { + let _ = app_handle.emit("mesh://event", frame); + } + eprintln!("daemon: event stream ended — reconnecting"); + daemon.set_disconnected(); + } + Err(e) => { + // Daemon's reachable but the event + // subscribe failed. Keep a command- + // capable connection (placeholder id, + // no live events) and stop reconnecting: + // with no event stream there's nothing + // to drive a reconnect on, and retrying + // would risk repeatedly killing and + // respawning a daemon we own. Matches + // the original pre-reconnect behavior. + eprintln!( + "daemon: event subscribe failed: {e} — \ + continuing without live events" + ); + daemon.set_connected( + client, + String::from("c-unbound"), + child, + ); + break; + } } - }); + } + Err(e) => { + eprintln!( + "daemon: ensure_daemon_running failed: {e} — \ + retrying in {}s", + backoff.as_secs() + ); + } } - Err(e) => { - eprintln!("daemon: ensure_daemon_running failed: {e}"); - // Still register an empty placeholder so commands - // that take `State>` panic with a - // clear message ("state not found") rather than - // silently dispatching against a half-built one. + if daemon.is_shutting_down() { + break; } + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(30)); } }); } @@ -1935,7 +1992,12 @@ fn main() { use std::sync::Arc; use tauri::Manager; if let Some(state) = app.try_state::>() { - let _ = state.child.lock().take(); + // Stop the reconnect loop, then take the spawned child so + // its `Drop` (SIGKILL / TerminateProcess + wait) runs here + // — before teardown — rather than the loop respawning a + // daemon we'd orphan, or the OS racing to reclaim it. + state.begin_shutdown(); + let _ = state.take_child(); } } }); diff --git a/src-tauri/src/mesh/daemon.rs b/src-tauri/src/mesh/daemon.rs index 10142a5..fb9f3a4 100644 --- a/src-tauri/src/mesh/daemon.rs +++ b/src-tauri/src/mesh/daemon.rs @@ -1023,28 +1023,119 @@ pub async fn ensure_daemon_running() -> Result<(ControlClient, Option>` extractor every +/// `mesh_daemon_*` command depends on always resolves — even before the +/// background bring-up task has attached to a daemon, and even if that +/// bring-up fails outright. Commands invoked before a live connection is +/// installed get a clean "daemon not connected" error (see +/// [`super::daemon_commands`]) instead of Tauri's internal "state not +/// managed for field 'state' … You must call '.manage()'…". +/// +/// Managing up front — rather than inside the spawn, only on success — +/// is what fixes the fresh-install failure mode: previously a daemon +/// that was slow to bind its control socket (a cold Windows boot with +/// the sidecar still being virus-scanned) or that failed to start at all +/// meant `.manage()` never ran, so *every* mesh command rejected with +/// that cryptic internal error. +/// +/// The connected half lives behind a lock so the background task can +/// install it after the state is already managed, and swap it out / back +/// in as the daemon restarts. pub struct MeshDaemon { - pub client: ControlClient, - pub client_id: String, - /// Held purely for lifetime — drop kills the daemon child. - /// `Mutex>` so we can take ownership at app exit - /// to wait the child cleanly rather than racing the OS to - /// reclaim it. - pub child: parking_lot::Mutex>, + conn: parking_lot::RwLock>, + /// Set once at app exit so the background reconnect loop stops + /// instead of racing process teardown to spawn a fresh daemon it + /// would immediately orphan. See `main.rs`'s `RunEvent::Exit` arm. + shutting_down: std::sync::atomic::AtomicBool, +} + +/// Everything that only exists once we've attached to (or spawned) a +/// live daemon: the `client` every command sends requests on, the +/// `client_id` the event-subscribe handshake issued (passed back on +/// RPC/channel ops so the daemon routes inbound frames to our event +/// socket), and — when we spawned the daemon ourselves — the owned +/// `child` whose `Drop` kills it. +struct DaemonConn { + client: ControlClient, + client_id: String, + child: Option, } impl MeshDaemon { - pub fn new(client: ControlClient, client_id: String, child: Option) -> Self { + /// A managed-but-not-yet-connected daemon. Installed synchronously + /// during setup; [`Self::set_connected`] fills in the live + /// connection once bring-up succeeds. + pub fn disconnected() -> Self { Self { + conn: parking_lot::RwLock::new(None), + shutting_down: std::sync::atomic::AtomicBool::new(false), + } + } + + /// Signal the background reconnect loop to stop (called at app exit, + /// before the owned child is taken for an orderly wait). + pub fn begin_shutdown(&self) { + self.shutting_down + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + /// Whether app exit has begun — the reconnect loop checks this before + /// spawning so it never leaves a daemon orphaned past our own exit. + pub fn is_shutting_down(&self) -> bool { + self.shutting_down.load(std::sync::atomic::Ordering::SeqCst) + } + + /// Install (or replace) the live connection. Replacing drops the + /// previous [`DaemonConn`] — including any owned child, whose `Drop` + /// kills it — which is what we want when reconnecting after the old + /// daemon went away. + pub fn set_connected( + &self, + client: ControlClient, + client_id: String, + child: Option, + ) { + *self.conn.write() = Some(DaemonConn { client, client_id, - child: parking_lot::Mutex::new(child), - } + child, + }); + } + + /// Drop the current connection (e.g. after the daemon's event stream + /// ended). Kills an owned child via [`DaemonConn`]'s drop. + pub fn set_disconnected(&self) { + *self.conn.write() = None; + } + + /// The control client, if connected. Cheap to clone — it's just the + /// socket address + mode; the actual socket connect happens lazily + /// per request. + pub fn client(&self) -> Option { + self.conn.read().as_ref().map(|c| c.client.clone()) + } + + /// Our daemon-issued IPC client_id, if connected. + pub fn client_id(&self) -> Option { + self.conn.read().as_ref().map(|c| c.client_id.clone()) + } + + /// Snapshot the client + client_id together so `mesh_daemon_status` + /// reads a consistent pair under one lock acquisition. + pub fn client_and_id(&self) -> Option<(ControlClient, String)> { + self.conn + .read() + .as_ref() + .map(|c| (c.client.clone(), c.client_id.clone())) + } + + /// Take the spawned child out for an orderly wait at app exit. The + /// connection otherwise stays intact (the client is unaffected); + /// only the lifetime guard moves to the caller. + pub fn take_child(&self) -> Option { + self.conn.write().as_mut().and_then(|c| c.child.take()) } } diff --git a/src-tauri/src/mesh/daemon_commands.rs b/src-tauri/src/mesh/daemon_commands.rs index 089a880..13002c5 100644 --- a/src-tauri/src/mesh/daemon_commands.rs +++ b/src-tauri/src/mesh/daemon_commands.rs @@ -20,38 +20,56 @@ use super::daemon::{MeshDaemon, Request}; /// the daemon's error message verbatim. type CmdResult = Result; +/// Returned to the frontend when a `mesh_daemon_*` command is invoked +/// before the background bring-up has attached to a daemon — or after a +/// daemon we were using went away and we're mid-reconnect. The frontend +/// already treats daemon errors as "daemon down" and keeps polling +/// `mesh_daemon_status` (which the reconnect loop in `main.rs` eventually +/// satisfies), so this reads far better than Tauri's internal "state not +/// managed for field 'state' … You must call '.manage()' before using +/// this command", which is what surfaced here before the state was +/// managed synchronously at setup. +const DAEMON_NOT_CONNECTED: &str = + "mesh daemon not connected — it's still starting up or currently unavailable"; + /// Convenience: unwrap a daemon response, returning the `data` -/// field on `ok` or the error string otherwise. +/// field on `ok` or the error string otherwise. Errors with +/// [`DAEMON_NOT_CONNECTED`] when no live connection is installed yet. async fn request_data(state: &Arc, req: &Request) -> CmdResult { - state - .client - .request_ok(req) - .await - .map_err(|e| e.to_string()) + let client = state + .client() + .ok_or_else(|| DAEMON_NOT_CONNECTED.to_string())?; + client.request_ok(req).await.map_err(|e| e.to_string()) } // ---- daemon meta ----------------------------------------------------- #[tauri::command] pub async fn mesh_daemon_status(state: State<'_, Arc>) -> CmdResult { - let daemon = state.inner().clone(); - let mut data = request_data(&daemon, &Request::Status).await?; + // Snapshot the live connection up front. When the background + // bring-up hasn't attached yet (or is mid-reconnect) there's nothing + // to query — return the clean "not connected" error the frontend's + // status poll already knows how to wait out. + let Some((client, client_id)) = state.client_and_id() else { + return Err(DAEMON_NOT_CONNECTED.to_string()); + }; + let mut data = client + .request_ok(&Request::Status) + .await + .map_err(|e| e.to_string())?; // Surface the IPC client_id our event-subscription owns so the // frontend can pass it back on RpcRegister / ChannelSubscribe / // RpcCallStream ops. The daemon's `Status` payload doesn't // include this — it's local to our connection state. if let Some(obj) = data.as_object_mut() { - obj.insert( - "ipc_client_id".to_string(), - Value::String(daemon.client_id.clone()), - ); + obj.insert("ipc_client_id".to_string(), Value::String(client_id)); obj.insert( "daemon_socket".to_string(), - Value::String(daemon.client.socket_display()), + Value::String(client.socket_display()), ); obj.insert( "daemon_mode".to_string(), - Value::String(daemon.client.mode_str().to_string()), + Value::String(client.mode_str().to_string()), ); // Daemon-version gate. Surface the rev this build was pinned to // and whether the live daemon meets it, so the frontend can show @@ -82,7 +100,14 @@ pub async fn mesh_daemon_status(state: State<'_, Arc>) -> CmdResult< #[tauri::command] pub async fn mesh_daemon_update_to_pin(state: State<'_, Arc>) -> CmdResult { let daemon = state.inner().clone(); - let own = daemon.client.mode_str() == "own_llm"; + // Default to own-LLM semantics when not yet connected: the staged + // binary applies to the daemon we'd spawn ourselves. (In practice + // the version-gate UI that triggers this only renders after a + // successful status, so we're connected here.) + let own = daemon + .client() + .map(|c| c.mode_str() == "own_llm") + .unwrap_or(true); match tauri::async_runtime::spawn_blocking(move || super::daemon::drive_daemon_update(own)) .await { @@ -360,7 +385,9 @@ pub async fn mesh_daemon_rpc_register( streaming: bool, ) -> CmdResult { let daemon = state.inner().clone(); - let client_id = daemon.client_id.clone(); + let client_id = daemon + .client_id() + .ok_or_else(|| DAEMON_NOT_CONNECTED.to_string())?; request_data( &daemon, &Request::RpcRegister { @@ -380,7 +407,9 @@ pub async fn mesh_daemon_rpc_unregister( method: String, ) -> CmdResult { let daemon = state.inner().clone(); - let client_id = daemon.client_id.clone(); + let client_id = daemon + .client_id() + .ok_or_else(|| DAEMON_NOT_CONNECTED.to_string())?; request_data( &daemon, &Request::RpcUnregister { @@ -468,7 +497,9 @@ pub async fn mesh_daemon_rpc_call_stream( payload: Value, ) -> CmdResult { let daemon = state.inner().clone(); - let client_id = daemon.client_id.clone(); + let client_id = daemon + .client_id() + .ok_or_else(|| DAEMON_NOT_CONNECTED.to_string())?; request_data( &daemon, &Request::RpcCallStream { @@ -491,7 +522,9 @@ pub async fn mesh_daemon_channel_subscribe( channel: String, ) -> CmdResult { let daemon = state.inner().clone(); - let client_id = daemon.client_id.clone(); + let client_id = daemon + .client_id() + .ok_or_else(|| DAEMON_NOT_CONNECTED.to_string())?; request_data( &daemon, &Request::ChannelSubscribe { @@ -510,7 +543,9 @@ pub async fn mesh_daemon_channel_unsubscribe( channel: String, ) -> CmdResult { let daemon = state.inner().clone(); - let client_id = daemon.client_id.clone(); + let client_id = daemon + .client_id() + .ok_or_else(|| DAEMON_NOT_CONNECTED.to_string())?; request_data( &daemon, &Request::ChannelUnsubscribe { diff --git a/src/mesh-daemon.svelte.ts b/src/mesh-daemon.svelte.ts index 1d6b274..a833c99 100644 --- a/src/mesh-daemon.svelte.ts +++ b/src/mesh-daemon.svelte.ts @@ -971,8 +971,18 @@ class MeshDaemonClient { } private async fetchDaemonStatusWithRetry(): Promise { + // The backend registers the mesh-daemon state synchronously at + // startup and keeps a reconnect loop running, so `mesh_daemon_status` + // no longer fails with Tauri's "state not managed" error — it returns + // a clean "daemon not connected" until the daemon is actually up. + // Wait that out: attaching to (shared probe + own probe + spawn + + // control-socket bind) can take ~10s on a cold boot, and longer when + // the OS is still virus-scanning a freshly-installed sidecar, so the + // budget here has to comfortably exceed that or a healthy-but-slow + // first launch gets reported as a failure. ~20s; the reconnect loop + // covers a daemon that only comes up after we give up. let lastErr: unknown = null; - for (let attempt = 0; attempt < 30; attempt++) { + for (let attempt = 0; attempt < 100; attempt++) { try { return (await invoke("mesh_daemon_status")) as DaemonStatus; } catch (e) {