From ce6a8d56bea101786e14c067ba232176e221e054 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Mon, 15 Jun 2026 16:24:53 +0700 Subject: [PATCH] fix(daemon,app): graceful-shutdown final snapshot pass + StoppedSnapshot detach cleanup Addresses final-review findings: Cmd::Shutdown now snapshots all live surfaces synchronously before exit (spec graceful-shutdown requirement); StoppedSnapshot calls detachSurface on unmount to release the bridge output channel. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/LayoutEngine.tsx | 4 +-- crates/spaceshd/src/server.rs | 47 ++++++++++++++++++++++------------- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/app/src/LayoutEngine.tsx b/app/src/LayoutEngine.tsx index 63b62f3..fa0870e 100644 --- a/app/src/LayoutEngine.tsx +++ b/app/src/LayoutEngine.tsx @@ -6,7 +6,7 @@ import { SearchBar } from "./SearchBar"; import { StatusRing } from "./StatusRing"; import { COLORS, FONT, STATE_COLOR } from "./theme"; import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes"; -import { setRatios, restartSurface, setZoom, moveSurface, attachSurface } from "./socketBridge"; +import { setRatios, restartSurface, setZoom, moveSurface, attachSurface, detachSurface } from "./socketBridge"; interface Props { workspaceId: string; @@ -149,7 +149,7 @@ function StoppedSnapshot({ surfaceId, font, palette }: { surfaceId: string; font void attachSurface(surfaceId, () => {}).then((res) => { if (!disposed && res.snapshot) term.write(res.snapshot); }); - return () => { disposed = true; term.dispose(); }; + return () => { disposed = true; term.dispose(); void detachSurface(surfaceId); }; }, [surfaceId, font, palette]); // eslint-disable-line react-hooks/exhaustive-deps return
; } diff --git a/crates/spaceshd/src/server.rs b/crates/spaceshd/src/server.rs index 148fa56..2307df5 100644 --- a/crates/spaceshd/src/server.rs +++ b/crates/spaceshd/src/server.rs @@ -453,17 +453,19 @@ async fn handle_request( let Some(ws) = reg.workspace(&workspace_id).cloned() else { let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return; }; - // Kill current panels of this workspace. - let existing: Vec = ws.surfaces.keys().cloned().collect(); - for sid in &existing { - if let Some(h) = reg.live(sid) { let _ = h.tx.send(crate::surface::SurfaceMsg::Close).await; } - reg.remove_surface(sid); - subs.remove(sid); - } - // Spawn `count` panels (slots padded/truncated to count). - let mut new_ids = Vec::new(); - for i in 0..count { - let slot = slots.get(i); + // Additive: keep existing panels (and their live processes) in their + // current visual order, spawn only the delta needed to reach `count`, + // then rebuild the tree to the preset shape. Presets never destroy + // running panels — shrinking is done by closing panels via the X. The + // GUI only offers presets whose count >= the current pane count, so + // `count >= existing.len()` and `ids.len() == count` after the loop. + let existing: Vec = ws.layout.as_ref() + .map(spacesh_core::ops::leaves) + .unwrap_or_else(|| ws.surfaces.keys().cloned().collect()); + let mut ids = existing.clone(); + let to_spawn = count.saturating_sub(existing.len()); + for j in 0..to_spawn { + let slot = slots.get(existing.len() + j); let new_sid = reg.new_surface_id(); let command = slot.and_then(|s| s.command.clone()); let shell = command.clone().unwrap_or_else(|| config.resolved_shell()); @@ -476,20 +478,18 @@ async fn handle_request( reg.set_live(handle); reg.set_state(&new_sid, spacesh_proto::SurfaceState::Idle); reg.add_surface_spec(&workspace_id, new_sid.clone(), spec); - new_ids.push(new_sid); + broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: new_sid.clone(), workspace_id: workspace_id.clone() })); + ids.push(new_sid); } Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; return; } } } - if let Some(tree) = spacesh_core::presets::build(&preset_id, &new_ids) { + if let Some(tree) = spacesh_core::presets::build(&preset_id, &ids) { if let Some(w) = reg.workspace_mut(&workspace_id) { w.layout = Some(tree); } } - for sid in &new_ids { - broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: sid.clone(), workspace_id: workspace_id.clone() })); - } emit_layout(reg, &workspace_id, clients); persister.mark_dirty(reg.persist_state()); - let _ = out.send(ok(id, serde_json::json!({ "surface_ids": new_ids.iter().map(|s| s.0.clone()).collect::>() }))).await; + let _ = out.send(ok(id, serde_json::json!({ "surface_ids": ids.iter().map(|s| s.0.clone()).collect::>() }))).await; } Cmd::RestartSurface { surface_id, resume } => { @@ -732,6 +732,19 @@ async fn handle_request( } Cmd::Shutdown => { + // Final snapshot pass: capture each live surface's visible screen so a + // clean restart (e.g. Settings → Restart daemon) repaints last screens. + // Written synchronously through the store (the async writer task would + // not drain before process::exit). + for sid in reg.live_ids() { + let Some(handle) = reg.live(&sid) else { continue }; + let (reply_tx, reply_rx) = oneshot::channel(); + if handle.tx.send(SurfaceMsg::Snapshot { reply: reply_tx }).await.is_ok() { + if let Ok((snap, _dirty)) = reply_rx.await { + snapshot_store.save(&sid, &snap); + } + } + } let _ = out.send(ok(id, serde_json::Value::Null)).await; std::process::exit(0); }