Skip to content

Restore every window, not just one: per-window sessions + a window registry - #41

Merged
rockyway merged 14 commits into
developfrom
feature/multi-window-restore
Aug 18, 2026
Merged

Restore every window, not just one: per-window sessions + a window registry#41
rockyway merged 14 commits into
developfrom
feature/multi-window-restore

Conversation

@rockyway

Copy link
Copy Markdown
Contributor

The bug

Quit TermFlow with several windows open and only one came back, carrying one window's tabs.

Root-caused to three independent layers, each sufficient on its own — which is why fixing any one of them in isolation changed nothing observable:

L1 — one key, N writers, blind overwrite. stateKey() scoped only by profile + elevation; every window of an instance shares a WebView2 origin and therefore one localStorage. saveState() read only its own store and did a bare setItem — no read, no merge, no per-writer version. Every window armed the same writers: the useEffect at App.tsx registers beforeunload + a 30s autosave + visibilitychange synchronously, while the isDetachWindow() / ?newWindow=1 early-returns live inside the async initializeApp() — so they skipped RESTORE, never SAVE. The worst case was deterministic, not racy: closing a secondary window wrote that window's tabs immediately before destroy, so closing a 1-tab window overwrote a 10-tab window's session.

L2 — one reader. restoreState had a single call site, reachable only by falling through both early returns — the boot main window.

L3 — nothing to restore into. One window declared in tauri.conf.json, no window-list persistence anywhere in src-tauri/, and detach payloads in an in-memory DashMap that starts empty every launch.

The fix

Rust owns the durable window list; the renderer owns each window's payload. That split is forced: Rust cannot read localStorage, and beforeunload cannot await an invoke.

  • window_registry.rs — profile-scoped registry of windows + geometry, written atomically, debounced against per-frame drag events. Every failure mode (missing, corrupt, future version) loads as empty: the cost of an empty registry is one window, the cost of an error path is an app that does not start.
  • setup() recreates N windows, binding slot 0 to the existing main and building the rest with the same configuration open_new_window uses. Geometry is only reapplied if still reachable — monitors get unplugged, and a window restored off-screen is one the user cannot click.
  • windowScope.ts resolves a windowId in index.tsx before the bridge or App loads, for the same reason initProfileScope must. Slot 0 keeps the bare key, so an existing single-window session loads byte-identically.
  • ?newWindow=1 no longer skips restore — a fresh window has a fresh id, finds nothing, and the existing decision table opens a default tab. Skipping restore while still saving is what let a fresh window's default tab replace a session it never read.

Collateral fixed, because each would make this look broken

  • AppHandle::exit(0) (tray Quit, quit_app, last-window confirm) fires no CloseRequested, so no renderer ever saved. Now a flush handshake with a hard 1.5s ceiling — a quit that hangs is worse than one stale window.
  • The restore-side prune built its keep-set from one window, deleting every other window's scrollback before they booted. Now a union across all sessions of this profile, and it skips entirely if any blob is unreadable — deleted scrollback is not recoverable.
  • Every closed window leaked a localStorage blob forever. Slot 0 now sweeps against the backend registry.
  • updater.rs passed empty restart_args, so an in-app update relaunched --profile work as default — different config, different registry, empty storage scope. The user reads that as the update eating their session.

Two bugs found in this PR's own first draft

  • Ids were published after builder.build(). A webview starts loading the moment it is built and resolves its id as its first act; losing that race falls back to slot 0 — silently merging the new window into main's session, i.e. the original bug, reintroduced by the fix. Ids are now reserved before build in all three creation paths.
  • The window suffix used :, so a default-profile window with id work would derive auto-terminal-state:work — the work profile's slot-0 key. Now #: unreachable by construction rather than by coincidence.

Verification

405 Rust + 2000 renderer tests green; tsc --noEmit clean; clippy clean (0 errors, no warnings in new files).

The boot-wiring tripwire was mutation-tested — reintroducing the ?newWindow=1 early return turns it red — and reads source through utils/readSource, since its assertions span whitespace and would silently stop matching on a CRLF checkout.

Manually confirmed on a nightly side-by-side instance: the registry is created, slot 0 registers as w0/main, and geometry tracks the live window.

⚠️ The end-to-end matrix (multi-window quit → relaunch) has NOT been run. The units and the wiring are pinned; the user-visible behaviour is being verified separately by @rockyway.

Plan: termflow-fabric/docs/plan/018-multi-window-session-restore-implementation.md

Plan 018 Task 1. Rust must know how many windows to create before any
webview exists, and localStorage is unreachable from Rust — so the window
LIST and geometry live here, while each window's tab/pane payload stays in
the renderer keyed by the windowId recorded here.

Every failure mode (missing, corrupt, future version) loads as empty: the
cost of an empty registry is one window, the cost of an error path is an
app that does not start.
Plan 018 Task 2. WindowTracker owns the registry, the live label -> windowId
map and the geometry write debounce as one unit, so 'the map and the registry
agree' has a single owner.

Register on create (persisted immediately — a new window must survive a crash),
forget on Destroyed, and update geometry/focus on Moved/Resized/Focused. Moved
and Resized fire per frame during a drag, so the disk write is debounced to
500ms while memory tracks every event.

id_for_label deliberately returns None rather than falling back to slot 0:
windows silently sharing one storage key is the defect this exists to fix.
Plan 018 Task 3. setup() binds slot 0 to the already-created 'main' window and
builds slots 1..N with the same configuration open_new_window uses (GPU browser
args included — a divergence there is the gpu_preference footgun).

Labels are NOT reused from the registry. A saved detach-* label would send the
restored window down the detach boot path looking for a payload no process
still holds (detach_payloads is in-memory, empty every launch). The stable
windowId carries the session, so the label is free to be normalised.

Geometry is only reapplied if it is still reachable: monitors get unplugged
between sessions, and a window restored onto coordinates that no longer exist
is one the user cannot click, drag or close.

get_window_session_id errors on an unknown label rather than defaulting to
slot 0 — a silent fallback would put two windows on one storage key.
Mirrors the `:pro:alt` script pair: CARGO_TARGET_DIR=target-pro-nightly
with TERMFLOW_PROFILE=nightly baked in, so the build defaults to the
nightly profile without --profile on every launch.

Nothing in Rust special-cases a profile name -- everything mutable is
derived from ProfileIdentity -- so the name alone buys config.nightly.json,
history.nightly.db, layout.nightly.json, recordings.nightly, its own
instance lock, pty-host pipe and host record, API/MCP port claim and
TermFlow-nightly.log, plus a window/tray title marked (nightly). It is
not the primary instance, so it never claims the machine-wide fabric
keypair or peer listener.

Both .gitignore files gain /target-pro-nightly/: cargo resolves
CARGO_TARGET_DIR relative to src-tauri, which is where the tree actually
lands, while the root entry covers a run from the repo root.

The guard test that reads the baked profile names back out of
package.json was swept into b55493e by a concurrent bulk stage.
Plan 018 Task 4. StateManager's STATE_KEY now carries a window dimension
resolved from the backend registry, so each window persists its own tabs
instead of blindly overwriting one shared key.

Resolved in index.tsx immediately after initProfileScope and before the
bridge or App, for the same reason that one must be: App registers unload,
visibility and interval saves the moment it mounts, so a late id lands the
first saves on slot 0's key.

Only the auto-saved SESSION is per-window. Saved layouts and the API token
stay per-instance — a named layout is a user library, and fragmenting it per
window would hide layouts from the window that did not save them.

Slot 0 keeps the original bare key, so an existing single-window session
loads byte-identically. The window dimension uses '#' rather than ':' so a
window id can never derive another profile's slot-0 key.
Plan 018 Task 5. ?newWindow=1 used to return before StateManager.restoreState.
With per-window keys that is both unnecessary and harmful: a fresh window has a
fresh id, so restoreState finds nothing and the existing decision table opens a
default tab on its own — while the early return left a window that SAVED (the
mount effect registers the save hooks for every window regardless) but never
restored, so its default tab could replace a session it never read.

The ?path= feature is preserved by feeding it into pendingOpenPath.

Adds a source tripwire over the boot wiring: both hops it guards fail silently
(every window quietly back on slot 0's key), and neither App nor bootstrapApp
can be mounted under the root Jest config. Verified by mutation — reintroducing
the early return turns it red.
Plan 018 Task 6. Detached windows already get a registry id (Task 2) and the
boot loop normalises labels away from detach-* (Task 3), so a torn-off window
comes back as an ordinary restorable window and reconstructDetachedWindow's
no-payload path falls through to the normal restore.

Closes a real race found while wiring this: the id was published AFTER
builder.build(), but a webview starts loading the moment it is built and
resolves its session id as its first action. The losing side of that race falls
back to slot 0 and silently merges the new window into the main window's
session — the exact defect this feature removes. Ids are now reserved before
build, in all three creation paths (new window, detach, boot restore).
Plan 018 Task 7. The restore sweep prunes history.db down to the terminals in
the layout it just restored. That was correct while one window restored; with
per-window sessions N windows boot concurrently, so whichever restored first
deleted every OTHER window's scrollback before those windows read their own
session. StateManager's own comment already required this union.

The keep-set now unions every session key belonging to THIS profile — sibling
profiles are excluded, since sweeping against another instance's terminals
would delete live shells' history.

Fails safe: a session blob that will not parse is a window whose terminals
cannot be named, so the sweep is skipped entirely rather than run on a partial
union. Deleted scrollback is not recoverable.
…ws' sessions

Plan 018 Tasks 8 and 9.

Task 8 — AppHandle::exit tears the process down without firing CloseRequested
for any window, so no renderer gets its beforeunload. Survivable while one
shared key held everything; with per-window sessions each window owns data only
IT can write, so an unflushed window loses its tabs outright. Quit now asks
every window to persist and waits for acks, with a hard 1.5s ceiling — a quit
that hangs is worse than one stale window. A second Quit while a flush is in
flight exits immediately. The WAIT is split into wait_for_acks so the timeout
branch is testable; a happy-path test would never reach it.

Task 9 — every closed window used to leave a localStorage blob forever, the
same unbounded growth pruneCwds exists to prevent, one level up. Slot 0 now
sweeps session keys against the backend registry before the history prune, so
a dead window's terminals fall out of the union and its scrollback is actually
reclaimed. An empty live-list is treated as 'could not ask' and sweeps nothing,
since the alternative deletes every session there is.

NOTE: the Rust half is cargo-check clean as of the previous commit, but the new
flush_tests module is UNVERIFIED — D: has 1.09GB free and the build fails with
os error 112. Renderer half is fully green (883 tests).
Canvas Mode is unmerged on develop and touches 8 of the same files, so the
per-window session work is developed on top of it rather than colliding at PR
time.

Two conflicts, both adjacent-line rather than semantic:

  lib.rs WindowEvent::Destroyed — both branches add cleanup to the same block.
  Kept both: canvas node geometry is a per-window renderer projection that must
  be dropped, while the window registry entry is precisely the thing that must
  survive to recreate this window's peers.

  StateManager imports — canvas still imported the profile-only stateKey.
  Dropped it and kept sessionStateKey: reinstating the profile-only key would
  put every window back on one blob, which is the defect this branch removes.
  Canvas's own persistence (sanitizeCanvasState) is untouched and now rides the
  per-window key, which is right — Canvas Mode is a tab, and tabs are per-window.

Also switched the boot tripwire onto canvas's utils/readSource: its assertions
span whitespace and would silently stop matching on a CRLF checkout, which the
e2e job hits or misses depending on which runner is free.

Verified: 130 suites / 2000 tests green, renderer typecheck clean, and all four
window-creation sites accounted for (canvas added none). The Rust half is
UNVERIFIED — D: has 1.09GB free and cargo fails with os error 112.
Plan 018 Task 10. wait_exit_then_apply_updates was handed Vec::new() as
restart_args, so an in-app update relaunched --profile work as the DEFAULT
identity: a different config file, a different window registry, and an empty
renderer storage scope. The user reads that as the update having eaten their
session.

Derived from the resolved identity rather than echoed from std::env::args,
which would also replay one-shot flags (--path, --headless) that must not
survive a restart.

Elevation is deliberately not expressed: it is a property of the process token
and is re-derived on launch, and the AUTO-selected 'elevated' name comes with
it. Passing --profile elevated to a process that came back at medium integrity
would mint a third identity (rel.elevated) owning nobody's data — so that one
case is left to be re-derived, while an explicitly named profile always
carries.
The comment still described the pre-018 behaviour. ?newWindow=1 now means the
window has no session saved under its id, not that restore is skipped — and it
does save under that id, so it is restored like any other window.
Canvas Mode landed on develop via PR #40, so this branch takes it from there
rather than carrying its own copy.
@rockyway
rockyway merged commit dafd040 into develop Aug 18, 2026
5 checks passed
@rockyway
rockyway deleted the feature/multi-window-restore branch August 18, 2026 23:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants