fix(app): bridge auto-reconnect so daemon restart no longer bricks the GUI

The Tauri bridge connected to the daemon once at startup and held a single
stream with no recovery: when the daemon exited (Restart/Stop, crash, or an
update), the reader emitted spacesh:disconnected and died, and every later
request went through the dead writer forever — the GUI was permanently stuck
(settings frozen, offline). Since the bridge is Rust-side state that survives
a webview reload, even Cmd+R didn't recover it.

- bridge.rs: requests now reconnect-and-retry on failure with a single-flight
  guard (generation counter) so concurrent failures collapse into one
  reconnect and never open duplicate connections; a 5s reply timeout catches
  silently-dropped connections. ensure_daemon respawns the daemon if it
  exited. On success the bridge emits spacesh:reconnected.
- App.tsx: on spacesh:reconnected, bump a connection epoch that keys
  LayoutEngine, remounting terminals so they re-attach (snapshot + live stream)
  to the restarted daemon; also reload health/config/status.
- Settings: drop the Stop button — with lazy daemon spawn any GUI request
  resurrects the daemon, so an in-GUI "stop" is contradictory. Restart now
  works end to end (shutdown → reconnect respawns → panels re-attach).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 10:22:24 +07:00
parent 99f5708cbf
commit 9ca0164d0b
3 changed files with 105 additions and 23 deletions
+7 -8
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { COLORS, FONT, ACCENTS } from "./theme";
import { setConfig, shutdownDaemon, restartDaemon } from "./socketBridge";
import { setConfig, restartDaemon } from "./socketBridge";
import type { ConfigView, DaemonHealth } from "./socketBridge";
const FONTS = ["JetBrains Mono", "Menlo", "Monaco", "SF Mono", "Fira Code", "Cascadia Code"];
@@ -71,7 +71,7 @@ function fmtUptime(ms: number): string {
}
function DaemonSection({ health, onReload }: { health: DaemonHealth | null; onReload: () => void }) {
const [confirm, setConfirm] = useState<null | "stop" | "restart">(null);
const [confirm, setConfirm] = useState(false);
// Tick so uptime counts up live while the modal is open.
const [, setTick] = useState(0);
useEffect(() => {
@@ -88,19 +88,18 @@ function DaemonSection({ health, onReload }: { health: DaemonHealth | null; onRe
</>) : <div>offline</div>}
</div>
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
<button onClick={() => setConfirm("restart")} style={{ padding: "7px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 13 }}>Restart</button>
<button onClick={() => setConfirm("stop")} style={{ padding: "7px 14px", background: "transparent", color: COLORS.stError, border: `1px solid ${COLORS.stError}`, borderRadius: 7, fontSize: 13 }}>Stop</button>
<button onClick={() => setConfirm(true)} style={{ padding: "7px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 13 }}>Restart</button>
</div>
{confirm && (
<div style={{ marginTop: 10, padding: 10, borderRadius: 8, background: COLORS.bgPanel, border: `1px solid ${COLORS.borderStrong}` }}>
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 8 }}>
{confirm === "stop" ? "Stop the daemon? All sessions end." : "Restart the daemon? Sessions end and respawn."}
Restart the daemon? Running sessions end and respawn; panels re-attach automatically.
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={() => setConfirm(null)} style={{ padding: "5px 12px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 6, fontSize: 12 }}>Cancel</button>
<button onClick={() => { const c = confirm; setConfirm(null); void (c === "stop" ? shutdownDaemon() : restartDaemon()).then(onReload); }}
<button onClick={() => setConfirm(false)} style={{ padding: "5px 12px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 6, fontSize: 12 }}>Cancel</button>
<button onClick={() => { setConfirm(false); void restartDaemon().then(onReload); }}
style={{ padding: "5px 12px", background: COLORS.stError, color: "#fff", border: "none", borderRadius: 6, fontSize: 12, fontWeight: 600 }}>
{confirm === "stop" ? "Stop" : "Restart"}
Restart
</button>
</div>
</div>