Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
62a4e1f
Move a transferring Workspace between snapshots on disk at the invoke
nedtwigg Sep 10, 2026
4c41d2c
Keep a transferring Workspace in its own arrivals file, not a snapshot
nedtwigg Sep 11, 2026
0e50404
Merge workspaces-registry into workspaces-durability
nedtwigg Sep 11, 2026
fe738ee
Merge branch 'workspaces-registry' into workspaces-durability
nedtwigg Sep 11, 2026
589d1d1
Merge branch 'workspaces-registry' into workspaces-durability
nedtwigg Sep 11, 2026
3cacfbb
Merge branch 'workspaces-registry' into workspaces-durability
nedtwigg Sep 11, 2026
e91f878
Merge branch 'workspaces-registry' into workspaces-durability
nedtwigg Sep 11, 2026
afdff76
Merge branch 'workspaces-registry' into workspaces-durability
nedtwigg Sep 11, 2026
280fadf
Merge branch 'workspaces-registry' into workspaces-durability
nedtwigg Sep 11, 2026
4363add
Retain transfer journal until both Window snapshots reflect adoption
nedtwigg Sep 11, 2026
269bd1c
Preserve newer adopted snapshots and retire journals when sources close
nedtwigg Sep 11, 2026
1f4cbe9
Merge branch 'workspaces-registry' into workspaces-durability
nedtwigg Sep 11, 2026
fd81afd
Keep transfer outcomes durable through refusal and target closure
nedtwigg Sep 11, 2026
1e22d85
Normalize Windows line endings in journal dispatch regression
nedtwigg Sep 11, 2026
87ed539
Merge branch 'workspaces-registry' into workspaces-durability
nedtwigg Sep 11, 2026
8d09f10
Hold approved exit until destroyed-window handbacks finish
nedtwigg Sep 11, 2026
3b2ebaa
Merge branch 'workspaces-registry' into workspaces-durability
nedtwigg Sep 11, 2026
6b4e11c
Bound handback cleanup waits and gate AppKit termination
nedtwigg Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/specs/security-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ behind is `docs/specs/security-remote.md` -> "Credentials at rest".
persists one window's structure per file as `<state root>/sessions/<label>.json`
— panes, cwds, titles, doors, layout, TODO flags, never terminal text
(`docs/specs/standalone.md` -> "Persistence"), plus a
`<label>.geometry.json` sibling holding that window's box and nothing else.
`<label>.geometry.json` sibling holding that window's box and nothing else, and
`arrivals.json`, the Workspaces mid-transfer between windows, written the same way
(`docs/specs/standalone.md` -> "Arrival queue").
`restrict_to_owner` locks the directory and, *first*, the temp file renamed into
it, applying a protected single-ACE DACL on Windows where a unix mode is a
silent no-op. The same helper
Expand Down
67 changes: 52 additions & 15 deletions docs/specs/standalone.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,16 +450,22 @@ by `the_geometry_flush_slot_is_released_with_the_drain`.

### What a window's `Destroyed` settles

**Everything keyed by a label is settled in the `Destroyed` arm, and only
there**: Tauri takes the label out of `webview_windows()` at that moment and not
before, so a `burrow:windows` push sent ahead of it names a window that can never
answer — and every ask then waits out its whole budget. The arm forgets the
window's PTY ownership and reaps what it still owned, hands back every arrival it
will never take (§Arrival queue), drops the save refusal (nothing can save under
a dead label), forgets its geometry, tells the quit machine (§Quit flow) and
pushes the live labels to the sidecar. **An arriving Workspace's shells are taken
out of the reap first**: they belong to its source again, which is still showing
those terminals.
**Must clear label-keyed ownership, registry, geometry, and close state in the
`Destroyed` arm**, when Tauri has removed the window from `webview_windows()`.
**Must remove incoming arrivals from the reap before killing orphaned PTYs**;
the source still holds those Sessions. Hand-backs run in a blocking worker that
reports completion on the main thread. **Must defer every approved exit until all
such workers have completed**, including exit requested through the quit walk
or Tauri/AppKit (`every_approved_exit_path_checks_cleanup`;
`quit_waits_for_every_destroyed_window_handback` pins the counter). **Must force an
approved exit after `QUIT_PHASE_TIMEOUT_MS` waiting for cleanup**, logging the
timeout; a stalled disk operation or completion callback cannot trap the app
(`stalled_cleanup_cannot_block_an_approved_exit_forever`). The arm updates the
quit machine and sends the remaining live labels to the sidecar; the Burrow’s
ask collector must never wait for a window that cannot answer.

Source of truth: `CleanupGate` and `WindowEvent::Destroyed` in
`standalone/src-tauri/src/lib.rs`.

### Per-window close

Expand Down Expand Up @@ -624,6 +630,31 @@ 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` records the arrival in `sessions/arrivals.json`** — a JSON
array of `{ workspaceId, from, to, workspace }`, never an entry in either
window's snapshot (rationale). **Must retain an adopted record until target
and source snapshots both reflect the move**, marking it settled at
`adopt_done` and checking after each `save_session` or source-window close
(`adoption_keeps_the_journal_until_both_snapshots_are_durable`). **Must reverse
the durable destination on hand-back and retain the record until both
snapshots reflect the return** (`a_hand_back_is_recovered_in_the_source_before_its_next_flush`).
**Must tombstone settled arrivals into a deliberately closed Window until
both snapshots omit them**, including during boot recovery
(`closing_an_adopted_target_never_resurrects_either_copy`).
A record left at boot is merged into its recorded destination before `restore_windows` — a tear-out target gets a file
holding just it, active; a source snapshot still naming the id loses it, an
emptied one is removed — so the Workspace restores once, with fresh shells,
and successful records are deleted; **must retain failed records for retry
and roll back the target if trimming the source fails**. **Must preserve a
settled arrival’s newer target record during boot recovery**
(`an_arrival_record_round_trips_until_it_is_forgotten`,
`a_leftover_arrival_boots_into_an_existing_target_snapshot`,
`a_leftover_arrival_boots_into_a_tear_out_targets_new_snapshot`,
`a_leftover_arrival_leaves_a_source_snapshot_that_still_names_it`,
`the_arrivals_file_is_gone_after_the_boot_merge`).
- **Must run journal I/O and its lock waits off the main thread**, including
transfer/settlement/close commands and destroyed-window cleanup
(`journal_commands_run_off_the_main_thread`).
- **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
Expand All @@ -632,10 +663,12 @@ 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` / `record_arrival_on_disk` /
`forget_arrival_on_disk` / `restore_arrivals` 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
Pinned by `standalone/src/workspace-move.test.ts`, the disk tests in
`standalone/src-tauri/src/lib.rs`, and the arrival tests in
`standalone/src-tauri/src/routing.rs`.

### Dragging a Workspace between windows
Expand Down Expand Up @@ -749,7 +782,8 @@ written.
- **The writer removes its own temp file on every error path**, so only a crash
can leave one behind.
- **A per-window close removes the blob, its temp sibling and its geometry**
(§Per-window close); nothing else deletes a snapshot.
(§Per-window close); nothing else deletes a snapshot but the boot merge
(§Arrival queue).
- **`sweep_orphan_session_temps` runs once in `setup()`** and deletes every
`<label>.json.tmp` — the legacy and hard-crash migration, given the rule above.
`SESSION_TEMP_SUFFIX` is pinned against the writer by
Expand Down Expand Up @@ -881,9 +915,9 @@ Every trigger funnels into `request_quit(app)`:
| Arm | Fired by | Guard |
|---|---|---|
| `WindowEvent::CloseRequested` | the window close button | `api.prevent_close()` unless the quit is approved. Refused outright while the walk is running: a window taken out from under its own teardown leaves the walk emitting to a dead label. Only the **last** window's close is a quit; every other one is a per-window close (§Windows) |
| `RunEvent::ExitRequested` | a window-level exit request | `api.prevent_exit()` unless approved. The event's `code` is ignored: the `approved` gate alone is what lets the flow's own terminating `app.exit(0)` through without re-catching it |
| `RunEvent::ExitRequested` | a window-level exit request | `api.prevent_exit()` unless approved and cleared by the bounded cleanup gate (§What a window's `Destroyed` settles). The event's `code` is ignored |
| the app menu's Quit item | the menu, and its `Cmd+Q` accelerator | a **custom** `MenuItem`, never `PredefinedMenuItem::quit`, whose event calls `request_quit`; muda wires the predefined one straight to AppKit's `terminate:` (macOS; rationale) |
| `applicationShouldTerminate:` | the Dock's Quit, `osascript`, logout, restart | spliced onto tao's live delegate class at `Ready`, answering `NSTerminateCancel` and starting the flow, then `NSTerminateNow` once the flow's own `app.exit(0)` comes back through it (macOS; rationale) |
| `applicationShouldTerminate:` | the Dock's Quit, `osascript`, logout, restart | spliced onto tao's live delegate class at `Ready`, answering `NSTerminateCancel` and starting the flow, then `NSTerminateNow` once approved and cleared by the bounded cleanup gate (§What a window's `Destroyed` settles; macOS; rationale) |

Source of truth: `standalone/src-tauri/src/macos_terminate.rs`.

Expand Down Expand Up @@ -925,6 +959,9 @@ wedged webview, in three phases:
| 2 — voting | acked, no window walking yet | **none** — a window may be parked on its confirmation dialog waiting on a human, who must never be force-quit out from under it. Only `quit_proceed` (`approved`) or `quit_cancel`/repeat-trigger (`seq` bump) ends the wait |
| 3 — walking | one window tearing down | **per phase**, ~14 s, refreshed by its `quit_progress` bumps *and* by the walk advancing to the next window, so each phase and each window gets its own budget; no progress for the budget ⇒ log and exit |

Approved exits from these watchdogs also pass the bounded cleanup gate
(§What a window’s `Destroyed` settles).

Phase 3's budget comfortably exceeds the webview's own teardown ceiling. Each
watchdog captures the `seq` it was spawned for, so a **repeated quit trigger** —
which bumps `seq`, spawns a fresh watchdog and re-emits — leaves the stale one to
Expand Down
14 changes: 14 additions & 0 deletions docs/specs/standalone.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,20 @@ left to fall back on. With the record settling at `adopt_done` the drain is
idempotent, and a reload mid-arrival finds its Workspace again instead of losing
it. The webview's `adopting` set is what makes repeated drains safe.

The pending-arrival record is its own file rather than an entry staged into a
snapshot. The first durability attempt wrote the arriving Workspace into the
target's `sessions/<label>.json` at the invoke and it failed two ways. A torn-out
window then had a snapshot before it opened, and `bootFromTearOut` reads "this
window has a snapshot" as "this is an ordinary restore": the new window
cold-restored the staged copy over fresh shells, drained the arrival, and threw
`Duplicate Workspace id` adopting the real one — the tear-out was handed back and
both windows persisted the id. And for a transfer into a live window the staged
entry did not survive to adoption: `getWindowSnapshot` iterates the target's
store, which does not hold the Workspace yet, so the target's next debounced
flush (500 ms after any change, inside the 3 s arrival timeout) rewrote its file
without it. A file neither webview writes has neither problem, and merging it at
boot is the only moment no flush can race it.

## Dragging a Workspace between windows

Spiked before the drag was built, because it was the one unverified platform
Expand Down
4 changes: 3 additions & 1 deletion lib/src/lib/window-session-aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 keeps its own record of the arrival on disk for the
* gap (`docs/specs/standalone.md` → "Arrival queue"), so a crash restores it
* in the target. Cleared by `clearWorkspaceTransferring` (the target refused
* it) or by `forgetWorkspaceSession` (it landed).
*/
export function markWorkspaceTransferring(workspaceId: WorkspaceId): void {
Expand Down
2 changes: 1 addition & 1 deletion scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": 9350,
"docs/specs/standalone.md": 9600,
"docs/specs/terminal-context.md": 900,
"docs/specs/terminal-escapes.md": 3750,
"docs/specs/terminal-state.md": 2350,
Expand Down
Loading