Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
122 changes: 92 additions & 30 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arc<MeshDaemon>>`
// 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::<serde_json::Value>(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::<serde_json::Value>(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<Arc<MeshDaemon>>` 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));
}
});
}
Expand Down Expand Up @@ -1935,7 +1992,12 @@ fn main() {
use std::sync::Arc;
use tauri::Manager;
if let Some(state) = app.try_state::<Arc<mesh::daemon::MeshDaemon>>() {
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();
}
}
});
Expand Down
123 changes: 107 additions & 16 deletions src-tauri/src/mesh/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1023,28 +1023,119 @@ pub async fn ensure_daemon_running() -> Result<(ControlClient, Option<DaemonChil

// ---- Tauri state ----------------------------------------------------

/// State managed by Tauri for the duration of the app. The
/// `child` slot keeps the spawned daemon alive (or `None` if we
/// attached to one we didn't spawn); the `client` is what every
/// Tauri command uses; the `client_id` is what RPC/channel ops
/// pass back to the daemon so it routes inbound events to our
/// event socket.
/// State managed by Tauri for the duration of the app.
///
/// Registered **synchronously** during `setup` (see `main.rs`) in a
/// disconnected state, so the `State<Arc<MeshDaemon>>` 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<Option<_>>` 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<Option<DaemonChild>>,
conn: parking_lot::RwLock<Option<DaemonConn>>,
/// 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<DaemonChild>,
}

impl MeshDaemon {
pub fn new(client: ControlClient, client_id: String, child: Option<DaemonChild>) -> 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<DaemonChild>,
) {
*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<ControlClient> {
self.conn.read().as_ref().map(|c| c.client.clone())
}

/// Our daemon-issued IPC client_id, if connected.
pub fn client_id(&self) -> Option<String> {
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<DaemonChild> {
self.conn.write().as_mut().and_then(|c| c.child.take())
}
}
Loading
Loading