From 62a4e1fffe039f434c5e4a09a781f34b3f094608 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 14:12:49 -0700 Subject: [PATCH 1/8] Move a transferring Workspace between snapshots on disk at the invoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source omits a transferring Workspace from its next debounced save and the target writes only after adoption, so a crash in the gap restored the Workspace nowhere. begin_arrival now takes it out of the source's snapshot and puts it into the target's — creating the target's file for a tear-out — so the gap restores it once, in the target, with fresh shells. Every hand-back path takes it out of the target's file again, since the source persists it as soon as it clears the transferring mark. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u --- docs/specs/standalone.md | 10 +- lib/src/lib/window-session-aggregator.ts | 4 +- scripts/spec-word-budgets.json | 2 +- standalone/src-tauri/src/lib.rs | 121 ++++++++++++++++++++++- 4 files changed, 132 insertions(+), 5 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index e264b8a70..71795b29c 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -593,6 +593,14 @@ below reads that record rather than inferring itself from the suppression map. - **A boot's `pty_request_init` excludes every id an arrival claims.** Ownership moves at the invoke, so those shells would otherwise be listed as top-level panes beside the Workspace about to mount them. +- **`begin_arrival` moves the Workspace between the two snapshots on disk** — + out of the source's file, then into the target's (a tear-out target gets a + file holding just it) — so a crash before the target's first flush restores + it once, in the target, with fresh shells. The webviews' own debounced saves + would otherwise leave a gap in which no file held it. Every hand-back path + takes it out of the target's file again, since the source persists it as soon + as it clears the transferring mark; a file emptied that way is removed + (`a_staged_arrival_is_in_the_target_snapshot_until_it_is_handed_back`). - **An arrival unadopted after `ARRIVAL_MAX` is handed back** by a watchdog armed at `begin_arrival`, retiring only the record it was armed for (`queued_at`): a target alive but wedged never reaches `adopt_failed` or `Destroyed`, and the @@ -601,7 +609,7 @@ below reads that record rather than inferring itself from the suppression map. Source of truth: `Arrival` / `sweep_awaiting` / `expire_arrival` / `boot_list_ids` in `standalone/src-tauri/src/routing.rs`; `begin_arrival` / `adopt_ready` / -`adopt_done` / `adopt_failed` / `hand_back_arrival` in +`adopt_done` / `adopt_failed` / `hand_back_arrival` / `stage_arrival_on_disk` in `standalone/src-tauri/src/lib.rs`; `standalone/src/workspace-move.ts`; `markWorkspaceTransferring` in `lib/src/lib/window-session-aggregator.ts`. Pinned by `standalone/src/workspace-move.test.ts` and the arrival tests in diff --git a/lib/src/lib/window-session-aggregator.ts b/lib/src/lib/window-session-aggregator.ts index b19514bf5..dde0b9fd3 100644 --- a/lib/src/lib/window-session-aggregator.ts +++ b/lib/src/lib/window-session-aggregator.ts @@ -71,7 +71,9 @@ export function forgetWorkspaceSession(workspaceId: WorkspaceId): void { * mounted here, and its Sessions are still attached, because the target may * refuse it — but its shells already belong to the target, so a quit or a crash * in the gap must not leave the same Workspace persisted by two Windows and - * restored twice. Cleared by `clearWorkspaceTransferring` (the target refused + * restored twice. The host already moved it into the target's snapshot on disk + * at the invoke (`docs/specs/standalone.md` → "Arrival queue"), so the gap + * restores it there. Cleared by `clearWorkspaceTransferring` (the target refused * it) or by `forgetWorkspaceSession` (it landed). */ export function markWorkspaceTransferring(workspaceId: WorkspaceId): void { diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index a91e55258..d08887830 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -24,7 +24,7 @@ "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1050, - "docs/specs/standalone.md": 8850, + "docs/specs/standalone.md": 8950, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index b9ddc8944..d47a2d1d3 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -2403,6 +2403,55 @@ fn payload_terminal_ids(payload: &JsonValue) -> Vec { .unwrap_or_default() } +/// Put an arriving Workspace into the target's snapshot on disk **before** the +/// target adopts it. The source omits a transferring Workspace from its own +/// snapshot the moment the invoke returns (so a crash restores it once, not +/// twice), and the target writes only after adoption — so until this landed, a +/// crash in the gap restored it nowhere. Idempotent on the id; a target with no +/// snapshot yet (a tear-out) gets one holding just this Workspace. Never +/// fatal: a failed write is logged and the transfer proceeds. +fn stage_arrival_on_disk(dir: &Path, to: &str, workspace: &JsonValue) -> Result<(), String> { + let Some(id) = workspace.get("id").and_then(JsonValue::as_str) else { + return Err("arrival payload names no workspace.id".to_string()); + }; + let mut snapshot = match read_session_from(dir, to)? { + Some(contents) => serde_json::from_str::(&contents) + .map_err(|e| format!("unreadable snapshot for {to}: {e}"))?, + None => serde_json::json!({ "version": 1, "workspaces": [], "activeWorkspaceId": id }), + }; + let workspaces = snapshot + .get_mut("workspaces") + .and_then(JsonValue::as_array_mut) + .ok_or_else(|| format!("snapshot for {to} has no workspaces list"))?; + workspaces.retain(|entry| entry.get("id").and_then(JsonValue::as_str) != Some(id)); + workspaces.push(workspace.clone()); + write_session_to(dir, to, &snapshot.to_string()) +} + +/// The inverse, for an arrival that will never be adopted: the source persists +/// the Workspace again the moment it clears its transferring mark, so leaving +/// it in the target's file would restore it twice. A snapshot holding nothing +/// else is removed outright — it is a torn-out window that never opened. +fn unstage_arrival_on_disk(dir: &Path, to: &str, workspace_id: &str) -> Result<(), String> { + let Some(contents) = read_session_from(dir, to)? else { + return Ok(()); + }; + let mut snapshot = serde_json::from_str::(&contents) + .map_err(|e| format!("unreadable snapshot for {to}: {e}"))?; + let Some(workspaces) = snapshot.get_mut("workspaces").and_then(JsonValue::as_array_mut) else { + return Ok(()); + }; + let before = workspaces.len(); + workspaces.retain(|entry| entry.get("id").and_then(JsonValue::as_str) != Some(workspace_id)); + if workspaces.len() == before { + return Ok(()); + } + if workspaces.is_empty() { + return remove_session_from(dir, to); + } + write_session_to(dir, to, &snapshot.to_string()) +} + /// The record one drop becomes. fn arrival_from(from: &str, to: &str, payload: JsonValue) -> Result { let workspace_id = payload @@ -2443,6 +2492,23 @@ fn begin_arrival( windows.reassign(&arrival.terminal_ids, &arrival.to, true); routing::queue_arrival(&mut arrivals, arrival.clone()); } + // Moved on disk here, not left to the two webviews' debounced saves: out of + // the source's snapshot first, then into the target's, so a crash in the + // gap restores the Workspace once — in the target, with fresh shells — and + // never twice (§Arrival queue). Never fatal. + if let Some(workspace) = arrival.payload.get("workspace") { + match sessions_dir(app) { + Ok(dir) => { + if let Err(e) = unstage_arrival_on_disk(&dir, &arrival.from, &arrival.workspace_id) { + append_log(format!("[window] could not unstage {} from disk: {e}", arrival.workspace_id)); + } + if let Err(e) = stage_arrival_on_disk(&dir, &arrival.to, workspace) { + append_log(format!("[window] could not stage {} on disk: {e}", arrival.workspace_id)); + } + } + Err(e) => append_log(format!("[window] {e}")), + } + } spawn_arrival_watchdog(app.clone(), &arrival); Ok(()) } @@ -2488,6 +2554,13 @@ fn hand_back_arrival( "[window] {} never arrived in {} ({reason}); handing it back to {}", arrival.workspace_id, arrival.to, arrival.from )); + // Out of the target's file first: the source persists it again as soon as + // it clears the transferring mark below. + if let Ok(dir) = sessions_dir(app) { + if let Err(e) = unstage_arrival_on_disk(&dir, &arrival.to, &arrival.workspace_id) { + append_log(format!("[window] could not unstage {} on disk: {e}", arrival.workspace_id)); + } + } if app.get_webview_window(&arrival.from).is_some() { windows.reassign(&arrival.terminal_ids, &arrival.from, false); let _ = app.emit_to( @@ -2556,6 +2629,9 @@ fn open_workspace_window( routing::take_arrival(&mut guard(&windows.arrivals), &workspace_id, &label) { windows.reassign(&arrival.terminal_ids, &arrival.from, false); + if let Ok(dir) = sessions_dir(&app) { + let _ = unstage_arrival_on_disk(&dir, &label, &arrival.workspace_id); + } } return Err(err); } @@ -3761,8 +3837,8 @@ mod tests { find_node_binary, notepad_archive_lock_path, read_notepad_archive_from, read_session_from, reset_notepad_archive_at, resolve_dor_cli_paths, resolve_sidecar_path, session_file_name, state_root_from, strip_windows_verbatim_prefix, sweep_orphan_session_temps, - temp_write_path, write_notepad_archive_to, write_session_to, SESSION_TEMP_SUFFIX, - NOTEPAD_ARCHIVE_FILE, + stage_arrival_on_disk, temp_write_path, unstage_arrival_on_disk, write_notepad_archive_to, + write_session_to, JsonValue, SESSION_TEMP_SUFFIX, NOTEPAD_ARCHIVE_FILE, }; use super::guard; use std::collections::HashSet; @@ -3794,6 +3870,47 @@ mod tests { } } + #[test] + fn a_staged_arrival_is_in_the_target_snapshot_until_it_is_handed_back() { + let dir = TempDir::new("arrival-stage"); + let workspace = serde_json::json!({ "id": "workspace-7", "name": "Docs", "session": { "version": 3 } }); + + // A tear-out target has no snapshot yet: it gets one holding the arrival. + stage_arrival_on_disk(dir.path(), "ws-2", &workspace).unwrap(); + let read = || -> JsonValue { + serde_json::from_str(&read_session_from(dir.path(), "ws-2").unwrap().unwrap()).unwrap() + }; + assert_eq!(read()["activeWorkspaceId"], "workspace-7"); + assert_eq!(read()["workspaces"].as_array().unwrap().len(), 1); + + // Staging the same id again replaces rather than duplicates. + stage_arrival_on_disk(dir.path(), "ws-2", &workspace).unwrap(); + assert_eq!(read()["workspaces"].as_array().unwrap().len(), 1); + + // An existing target keeps what it had and its own active Workspace. + write_session_to( + dir.path(), + "main", + &serde_json::json!({ "version": 1, "workspaces": [{ "id": "workspace-2", "name": "A" }], "activeWorkspaceId": "workspace-2" }).to_string(), + ) + .unwrap(); + stage_arrival_on_disk(dir.path(), "main", &workspace).unwrap(); + let main: JsonValue = + serde_json::from_str(&read_session_from(dir.path(), "main").unwrap().unwrap()).unwrap(); + assert_eq!(main["activeWorkspaceId"], "workspace-2"); + assert_eq!(main["workspaces"].as_array().unwrap().len(), 2); + + // Handing back removes it, and an emptied snapshot goes entirely. + unstage_arrival_on_disk(dir.path(), "main", "workspace-7").unwrap(); + let main: JsonValue = + serde_json::from_str(&read_session_from(dir.path(), "main").unwrap().unwrap()).unwrap(); + assert_eq!(main["workspaces"].as_array().unwrap().len(), 1); + unstage_arrival_on_disk(dir.path(), "ws-2", "workspace-7").unwrap(); + assert!(read_session_from(dir.path(), "ws-2").unwrap().is_none()); + // Unstaging what was never staged is a no-op, not an error. + unstage_arrival_on_disk(dir.path(), "ws-9", "workspace-7").unwrap(); + } + /// The Windows half of `restrict_to_owner`: after it runs, the DACL must be /// protected from inheritance and grant exactly one principal — this user. /// From 4c41d2c132bbbf1d3e80b88b2912eb40565a5796 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 17:36:52 -0700 Subject: [PATCH 2/8] Keep a transferring Workspace in its own arrivals file, not a snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staging the arriving Workspace into the target's sessions/