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:
@@ -1,3 +1,28 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Stamp the GUI with the same git build id as the bundled daemon so the bridge
|
||||||
|
// can detect and restart a stale running daemon. Matches crates/spaceshd/build.rs.
|
||||||
fn main() {
|
fn main() {
|
||||||
|
println!("cargo:rustc-env=SPACESH_BUILD={}", git_build());
|
||||||
|
println!("cargo:rerun-if-changed=../../.git/HEAD");
|
||||||
|
println!("cargo:rerun-if-changed=../../.git/index");
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn git_build() -> String {
|
||||||
|
let sha = Command::new("git")
|
||||||
|
.args(["rev-parse", "--short=12", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|o| o.status.success())
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let Some(sha) = sha else { return "dev".into() };
|
||||||
|
let dirty = Command::new("git")
|
||||||
|
.args(["status", "--porcelain"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.map(|o| !o.stdout.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if dirty { format!("{sha}-dirty") } else { sha }
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ impl Bridge {
|
|||||||
let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>> = Arc::default();
|
let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>> = Arc::default();
|
||||||
let out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>> = 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?;
|
let (tx, reader) = spawn_connection(&sock, &app, pending.clone(), out_channels.clone()).await?;
|
||||||
Ok(Self {
|
let bridge = Self {
|
||||||
next_id: AtomicU64::new(1),
|
next_id: AtomicU64::new(1),
|
||||||
app,
|
app,
|
||||||
sock,
|
sock,
|
||||||
@@ -130,7 +130,34 @@ impl Bridge {
|
|||||||
reader: Mutex::new(reader),
|
reader: Mutex::new(reader),
|
||||||
pending,
|
pending,
|
||||||
out_channels,
|
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`
|
/// Re-establish the daemon connection. Single-flight: callers pass the `gen`
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function DaemonSection({ health, onReload }: { health: DaemonHealth | null; onRe
|
|||||||
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 8 }}>Daemon</div>
|
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 8 }}>Daemon</div>
|
||||||
<div style={{ fontFamily: FONT.mono, fontSize: 12, color: COLORS.textSecondary, lineHeight: 1.7 }}>
|
<div style={{ fontFamily: FONT.mono, fontSize: 12, color: COLORS.textSecondary, lineHeight: 1.7 }}>
|
||||||
{health ? (<>
|
{health ? (<>
|
||||||
<div>version {health.version} · pid {health.pid}</div>
|
<div>version {health.version}{health.build ? ` · ${health.build}` : ""} · pid {health.pid}</div>
|
||||||
<div>uptime {fmtUptime(health.started_at_ms)}</div>
|
<div>uptime {fmtUptime(health.started_at_ms)}</div>
|
||||||
</>) : <div>offline</div>}
|
</>) : <div>offline</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export async function closeSurfaceCmd(surfaceId: string): Promise<void> {
|
|||||||
await invoke("close_surface", { surfaceId });
|
await invoke("close_surface", { surfaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DaemonHealth { version: string; pid: number; started_at_ms: number }
|
export interface DaemonHealth { version: string; build?: string; pid: number; started_at_ms: number }
|
||||||
|
|
||||||
export async function getHealth(): Promise<DaemonHealth> {
|
export async function getHealth(): Promise<DaemonHealth> {
|
||||||
return await invoke<DaemonHealth>("health");
|
return await invoke<DaemonHealth>("health");
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Stamp the binary with the current git build id so the GUI can detect a stale
|
||||||
|
// running daemon (different code) and restart it. Matches app/src-tauri/build.rs.
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rustc-env=SPACESH_BUILD={}", git_build());
|
||||||
|
println!("cargo:rerun-if-changed=../../.git/HEAD");
|
||||||
|
println!("cargo:rerun-if-changed=../../.git/index");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_build() -> String {
|
||||||
|
let sha = Command::new("git")
|
||||||
|
.args(["rev-parse", "--short=12", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|o| o.status.success())
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let Some(sha) = sha else { return "dev".into() };
|
||||||
|
let dirty = Command::new("git")
|
||||||
|
.args(["status", "--porcelain"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.map(|o| !o.stdout.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if dirty { format!("{sha}-dirty") } else { sha }
|
||||||
|
}
|
||||||
@@ -622,6 +622,7 @@ async fn handle_request(
|
|||||||
Cmd::Health => {
|
Cmd::Health => {
|
||||||
let _ = out.send(ok(id, serde_json::json!({
|
let _ = out.send(ok(id, serde_json::json!({
|
||||||
"version": env!("CARGO_PKG_VERSION"),
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
"build": option_env!("SPACESH_BUILD").unwrap_or("dev"),
|
||||||
"pid": std::process::id(),
|
"pid": std::process::id(),
|
||||||
"started_at_ms": started_at_ms,
|
"started_at_ms": started_at_ms,
|
||||||
}))).await;
|
}))).await;
|
||||||
|
|||||||
Reference in New Issue
Block a user