feat(app): version handshake — GUI restarts a stale running daemon

The daemon outlives the GUI, so after an update an OLD daemon can keep serving
the socket and the new GUI just connects to it (stale code — e.g. the missing
TERM fix). Both binaries are now stamped with the git build id (build.rs):
the daemon reports it in `health.build`, and on connect the bridge compares it
to the GUI's own SPACESH_BUILD; on mismatch it shuts the daemon down and lets
ensure_daemon respawn the bundled (matching) one. No-op for unstamped dev
builds or daemons too old to report a build. Build id is shown in Settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:39:46 +07:00
parent 8f431eaa40
commit cf7410b46a
6 changed files with 84 additions and 4 deletions
+29 -2
View File
@@ -120,7 +120,7 @@ impl Bridge {
let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>> = Arc::default();
let out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>> = Arc::default();
let (tx, reader) = spawn_connection(&sock, &app, pending.clone(), out_channels.clone()).await?;
Ok(Self {
let bridge = Self {
next_id: AtomicU64::new(1),
app,
sock,
@@ -130,7 +130,34 @@ impl Bridge {
reader: Mutex::new(reader),
pending,
out_channels,
})
};
bridge.ensure_matching_daemon().await;
Ok(bridge)
}
/// If a previously-running daemon is a different build than the one bundled
/// with this GUI (the daemon outlives the GUI, so an old one can still be
/// serving the socket), restart it so our matching daemon takes over. No-op
/// for unstamped dev builds or when the daemon is too old to report a build.
async fn ensure_matching_daemon(&self) {
let want = option_env!("SPACESH_BUILD").unwrap_or("dev");
if want == "dev" {
return;
}
let got = match self.request(Cmd::Health).await {
Ok(env) => data_of(env)
.ok()
.and_then(|v| v.get("build").and_then(|b| b.as_str()).map(str::to_string))
.unwrap_or_default(),
Err(_) => return,
};
if got.is_empty() || got == want {
return;
}
// Stale daemon — stop it and respawn our bundled one (matching code).
let seen = self.gen.load(Ordering::Acquire);
let _ = self.request(Cmd::Shutdown).await; // daemon exits; reply may not arrive
let _ = self.reconnect(seen).await; // ensure_daemon respawns the bundled daemon
}
/// Re-establish the daemon connection. Single-flight: callers pass the `gen`