From 97ff2af70ff9bda3aa7b8e5201b6f11603def76d Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Thu, 23 Apr 2026 11:57:50 +0100 Subject: [PATCH 1/3] feat(daemon): manual restart + auto-restart on install update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Restart daemon" control in Settings > Advanced, and makes `ensure_daemon_running` transparently stop a stale daemon (one that predates the current app install) before spawning a fresh one. - `restart_daemon` Tauri command: stop + wait-for-shutdown + spawn. Uses `ant_core::node::daemon::client::stop` directly — no subprocess. - `is_daemon_stale`: compares bundled sidecar mtime against `daemon.pid` mtime with a 5s tolerance. If the sidecar is newer, the daemon was started by a previous app install and needs to be swapped. - No interruption to running nodes. `ant-core/src/node/process/spawn.rs` sets `kill_on_drop(false)` on node child processes, so they keep running across daemon restarts; the new daemon re-discovers them via the registry + PID checks on startup. Without this, users who update the app stay on the old daemon until they manually kill the process — a silent "works locally, broken on the next release" trap. The mtime heuristic handles the common case invisibly; the Settings button covers explicit user intent (e.g. to pick up a config change). Co-Authored-By: Claude Opus 4.7 (1M context) --- pages/settings.vue | 35 +++++++++++++++ src-tauri/src/lib.rs | 101 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/pages/settings.vue b/pages/settings.vue index 5bc8e41..6c5be21 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -118,6 +118,26 @@
+ +
+
+
+

Node daemon

+

+ Restart the background service that supervises your nodes. Your + running nodes keep running — only the supervisor is swapped. +

+
+ +
+
+
@@ -477,6 +497,21 @@ const editingDaemon = ref(false) const daemonInput = ref('') const daemonInputRef = ref(null) +// Daemon control +const daemonRestarting = ref(false) + +async function restartDaemon() { + daemonRestarting.value = true + try { + await invoke('restart_daemon') + toasts.add('Daemon restarted', 'info') + } catch (e: any) { + toasts.add(`Failed to restart daemon: ${e?.message ?? e}`, 'error') + } finally { + daemonRestarting.value = false + } +} + // Direct wallet (private key) const editingDirectWallet = ref(false) const directWalletKeyInput = ref('') diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7747c62..3aa802d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -370,9 +370,89 @@ async fn disconnect_daemon_sse(state: tauri::State<'_, Arc>) -> Result Ok(()) } +/// True when the bundled sidecar binary was modified noticeably after the +/// current daemon's pid file — i.e. the user installed an app update since +/// the daemon last started, so the running daemon is from the previous +/// version. 5-second tolerance avoids false positives from filesystem +/// timestamp granularity and install-time clock skew. +fn is_daemon_stale() -> bool { + let Some(sidecar) = find_daemon_binary() else { + return false; + }; + let Ok(data_dir) = ant_core::config::data_dir() else { + return false; + }; + let sidecar_mtime = match std::fs::metadata(&sidecar).and_then(|m| m.modified()) { + Ok(t) => t, + Err(_) => return false, + }; + let pid_mtime = match std::fs::metadata(data_dir.join("daemon.pid")).and_then(|m| m.modified()) + { + Ok(t) => t, + Err(_) => return false, + }; + sidecar_mtime + .duration_since(pid_mtime) + .map(|d| d > std::time::Duration::from_secs(5)) + .unwrap_or(false) +} + +/// Stop the daemon if it's running, and wait for the port file / HTTP server +/// to go away so the caller can safely start a new daemon without a bind +/// conflict. No-op if the daemon isn't currently running. +async fn stop_daemon_internal() -> Result<(), String> { + use ant_core::node::daemon::client as daemon_client; + let config = ant_core::node::types::DaemonConfig::default(); + + if !daemon_client::info(&config).running { + return Ok(()); + } + + daemon_client::stop(&config) + .await + .map_err(|e| format!("Failed to stop daemon: {e}"))?; + + let http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_millis(500)) + .build() + .unwrap_or_default(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + match config::discover_daemon_url() { + None => return Ok(()), + Some(url) => { + if http_client + .get(format!("{url}/api/v1/status")) + .send() + .await + .is_err() + { + return Ok(()); + } + } + } + } + Err("Daemon did not shut down within 10 seconds".into()) +} + +/// Stop the running daemon (if any) and start a fresh one. Node processes +/// are intentionally decoupled from the daemon lifecycle +/// (`ant-core/src/node/process/spawn.rs` → `kill_on_drop(false)`), so they +/// continue running throughout the restart. +#[tauri::command] +async fn restart_daemon() -> Result { + stop_daemon_internal().await?; + ensure_daemon_running().await +} + /// Start the daemon if it's not already running. /// Uses a bundled sidecar binary (production) or PATH fallback (dev). /// Spawns detached so the daemon survives app close. +/// +/// If a running daemon is detected but the bundled sidecar is newer +/// (`is_daemon_stale`), the old daemon is stopped first so the app +/// transparently picks up the updated binary on relaunch. #[tauri::command] async fn ensure_daemon_running() -> Result { // Check if already running via port file @@ -387,12 +467,20 @@ async fn ensure_daemon_running() -> Result { .await .is_ok() { - return Ok(url); - } - // Port file exists but daemon is unresponsive — clean up stale files - if let Ok(data) = ant_core::config::data_dir() { - let _ = std::fs::remove_file(data.join("daemon.port")); - let _ = std::fs::remove_file(data.join("daemon.pid")); + if is_daemon_stale() { + tracing::info!( + "Running daemon predates the current app install; restarting to pick up the bundled sidecar" + ); + stop_daemon_internal().await?; + } else { + return Ok(url); + } + } else { + // Port file exists but daemon is unresponsive — clean up stale files + if let Ok(data) = ant_core::config::data_dir() { + let _ = std::fs::remove_file(data.join("daemon.port")); + let _ = std::fs::remove_file(data.join("daemon.pid")); + } } } @@ -647,6 +735,7 @@ pub fn run() { save_upload_history, discover_daemon_url, ensure_daemon_running, + restart_daemon, connect_daemon_sse, disconnect_daemon_sse, autonomi_ops::init_autonomi_client, From 12934e99361e239f807281457c08857e1858adf9 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Thu, 23 Apr 2026 12:14:35 +0100 Subject: [PATCH 2/3] fix(daemon): show "Restarting" panel instead of "Cannot connect" flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual restart used to flash the red "Cannot connect to node daemon" error on pages/index.vue during the ~1 s window between the old daemon shutting down and the new one accepting connections. The next poll recovered automatically, but the flicker looked like a failure. - `nodesStore.restarting` gates the transition. Set while the restart is in flight; polling errors during that window don't flip `daemonConnected`, so the disconnected panel never renders. - New `restartDaemon` action on the nodes store wraps `invoke` with the flag + a forced re-fetch so the UI snaps back to the post-restart state without waiting for the next poll tick. - pages/index.vue renders a dedicated "Restarting node daemon… / Your nodes keep running." panel, prioritised over both `initializing` and disconnected. - Settings button delegates to `nodesStore.restartDaemon()` rather than invoking the command directly. Top-right node count was already stable across restarts (it renders from cached state, not the live poll) — no change there. Co-Authored-By: Claude Opus 4.7 (1M context) --- pages/index.vue | 8 ++++++++ pages/settings.vue | 11 +++++------ stores/nodes.ts | 34 ++++++++++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/pages/index.vue b/pages/index.vue index 6cf2d32..c3d466a 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -49,6 +49,14 @@

Starting node daemon...

+ +
+
+

Restarting node daemon...

+

Your nodes keep running.

+
+
diff --git a/pages/settings.vue b/pages/settings.vue index 6c5be21..57becec 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -497,18 +497,17 @@ const editingDaemon = ref(false) const daemonInput = ref('') const daemonInputRef = ref(null) -// Daemon control -const daemonRestarting = ref(false) +// Daemon control — delegate to the nodes store so the nodes page can show a +// "Restarting" panel instead of the default "Cannot connect" flicker during +// the brief window when the old daemon is down. +const daemonRestarting = computed(() => nodesStore.restarting) async function restartDaemon() { - daemonRestarting.value = true try { - await invoke('restart_daemon') + await nodesStore.restartDaemon() toasts.add('Daemon restarted', 'info') } catch (e: any) { toasts.add(`Failed to restart daemon: ${e?.message ?? e}`, 'error') - } finally { - daemonRestarting.value = false } } diff --git a/stores/nodes.ts b/stores/nodes.ts index 11c5305..e284aef 100644 --- a/stores/nodes.ts +++ b/stores/nodes.ts @@ -46,6 +46,11 @@ export const useNodesStore = defineStore('nodes', { nodes: [] as NodeInfo[], loading: false, initializing: true, + /** True while an explicit daemon restart is in flight. Suppresses the + * "Cannot connect" flash that would otherwise happen during the ~1 s + * gap between the old daemon shutting down and the new one accepting + * connections. */ + restarting: false, daemonConnected: false, daemonStatus: null as DaemonStatus | null, _pollTimer: null as ReturnType | null, @@ -120,8 +125,10 @@ export const useNodesStore = defineStore('nodes', { }) this.daemonConnected = true } catch { - if (this.daemonConnected) { - // Was connected, now lost — schedule reconnect + // Don't demote to "disconnected" during a known restart — the new + // daemon is seconds away and the UI renders a `restarting` panel in + // the meantime. + if (this.daemonConnected && !this.restarting) { this.daemonConnected = false this.scheduleReconnect() } @@ -130,6 +137,29 @@ export const useNodesStore = defineStore('nodes', { } }, + /** Trigger a daemon restart from the UI. Covers the connect-flicker that + * the manual `restart_daemon` command would otherwise produce: polling + * errors while the old daemon is dead don't flip `daemonConnected`, and + * `pages/index.vue` renders a dedicated "Restarting" panel for the + * duration. Node processes are untouched by the restart (spawn.rs sets + * kill_on_drop(false)), so the node list and counts stay stable. */ + async restartDaemon() { + this.restarting = true + try { + const url = await invoke('restart_daemon') + const settings = useSettingsStore() + if (url && url !== settings.daemonUrl) { + settings.daemonUrl = url + } + // Force a fresh fetch so the UI reflects the post-restart state + // immediately instead of waiting for the next poll tick. + await this.fetchDaemonStatus() + await this.fetchNodes() + } finally { + this.restarting = false + } + }, + /** Enrich nodes with storage usage from the LMDB payload file. */ async enrichNodeDetails() { for (const node of this.nodes) { From 9e99d362aeb9213e970e94d4dacad206b87e12f1 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Thu, 23 Apr 2026 12:34:01 +0100 Subject: [PATCH 3/3] feat(nodes): surface auto-upgrade transitions in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ant-client PR #53 ("fix(node): handle ant-node auto-upgrade transparently") added a new `NodeStatus::UpgradeScheduled` variant, a `pending_version` field on `NodeStatusSummary`, and two new lifecycle events (`UpgradeScheduled` / `NodeUpgraded`). Without consuming them on the GUI side, nodes mid-upgrade rendered as an unknown-status grey tile with a stale version string, and the upgrade itself only became visible on the next poll cycle after completion. - `utils/daemon-api.ts`: extend `NodeStatus` union with `upgrade_scheduled`, add `pending_version` to `NodeStatusSummary`, add `pending_version`/`old_version`/`new_version` to `NodeEvent`. - `stores/nodes.ts`: carry `pending_version` through `summaryToNodeInfo`; handle `upgrade_scheduled` (set status + pending_version) and `node_upgraded` (promote `new_version` to `version`, clear pending, bounce status back to `running` if still `upgrade_scheduled`) in the SSE event switch. - `components/StatusBadge.vue`: new "Upgrading" label with blue half-dot. - `components/nodes/NodeTile.vue`: blue dot + ping animation for the `upgrade_scheduled` state; version row renders "v0.10.1 → v0.10.2" while pending. - `components/nodes/NodeDetailPanel.vue` + `NodeDetail.vue`: same version-arrow treatment in the detail views. Behaviour-preserving for every other status. Existing nodes are unaffected; the new fields are all optional. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/StatusBadge.vue | 1 + components/nodes/NodeDetail.vue | 7 ++++++- components/nodes/NodeDetailPanel.vue | 7 ++++++- components/nodes/NodeTile.vue | 11 +++++++++-- stores/nodes.ts | 24 ++++++++++++++++++++++++ utils/daemon-api.ts | 20 +++++++++++++++++++- 6 files changed, 65 insertions(+), 5 deletions(-) diff --git a/components/StatusBadge.vue b/components/StatusBadge.vue index cfd1868..a212d64 100644 --- a/components/StatusBadge.vue +++ b/components/StatusBadge.vue @@ -16,6 +16,7 @@ const statusMap: Record = stopping: { dot: '◐', label: 'Stopping', color: 'text-autonomi-warning' }, adding: { dot: '◐', label: 'Adding...', color: 'text-autonomi-warning' }, errored: { dot: '●', label: 'Error', color: 'text-autonomi-error' }, + upgrade_scheduled: { dot: '◐', label: 'Upgrading', color: 'text-autonomi-blue' }, // File transfer statuses Pending: { dot: '○', label: 'Pending', color: 'text-autonomi-muted' }, Quoting: { dot: '◐', label: 'Quoting', color: 'text-autonomi-warning' }, diff --git a/components/nodes/NodeDetail.vue b/components/nodes/NodeDetail.vue index 4776cb2..2bcf17d 100644 --- a/components/nodes/NodeDetail.vue +++ b/components/nodes/NodeDetail.vue @@ -12,7 +12,12 @@
Version -

{{ node.version }}

+

+ {{ node.version }}→ {{ node.pending_version }} +

PID diff --git a/components/nodes/NodeDetailPanel.vue b/components/nodes/NodeDetailPanel.vue index 94fc0e4..d841617 100644 --- a/components/nodes/NodeDetailPanel.vue +++ b/components/nodes/NodeDetailPanel.vue @@ -19,7 +19,12 @@

Version

-

{{ node.version }}

+

+ {{ node.version }}→ {{ node.pending_version }} +

PID

diff --git a/components/nodes/NodeTile.vue b/components/nodes/NodeTile.vue index b5b48cb..83018e0 100644 --- a/components/nodes/NodeTile.vue +++ b/components/nodes/NodeTile.vue @@ -11,7 +11,7 @@