Skip to content

Commit a422838

Browse files
authored
Standalone quit flow: graceful teardown, durable final save, session-persistence hardening (#230)
2 parents f94d37a + b7f9297 commit a422838

42 files changed

Lines changed: 2121 additions & 217 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/specs/auto-update.md

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,25 @@ app launch
3030
3131
user quits
3232
33-
├─ no approved, downloaded update → exit normally
34-
└─ approved, downloaded update → write success marker → install() → exit
35-
36-
└─ install fails → overwrite with failure marker → exit normally
33+
└─ quit orchestrator runs graceful teardown + durable final save
34+
│ (docs/specs/standalone.md §Quit flow)
35+
├─ no approved, downloaded update → quit_proceed → exit
36+
└─ approved, downloaded update → write success marker → install() → exit
37+
38+
└─ install fails → overwrite with failure marker → quit_proceed → exit
3739
```
3840

39-
The `Update` object returned by `check()` is held in memory as an available update. Clicking the approval action calls `download()` and promotes it to a pending update only after the download succeeds. The close handler intercepts the window close event only when there is an approved, downloaded update, writes a success marker to `localStorage` *before* calling `install()` (because on Windows, NSIS force-kills the process), then — on Windows only — kills the sidecar and waits for it to fully exit (see *Sidecar teardown on Windows* below) before calling `install()`. In Vite dev mode (`pnpm dev:standalone`), the close handler skips `install()` without preventing the close. Dev mode is useful for testing check/download/banner behavior, but install must be tested from a packaged app because the updater resolves its replacement target from the current executable path.
41+
The `Update` object returned by `check()` is held in memory as an available update. Clicking the approval action calls `download()` and promotes it to a pending update only after the download succeeds.
42+
43+
Quit-time install is driven by the quit orchestrator (`docs/specs/standalone.md` §Quit flow): after the graceful terminal teardown and the durable final session save land, and only when an approved, downloaded update is pending, the orchestrator calls the updater's `installPendingUpdate()` (paired with `hasPendingUpdate()`). `installPendingUpdate()` writes the success marker *before* calling `install()` (§Post-install markers), and on Windows first kills the sidecar and waits for it to fully exit (§Sidecar teardown on Windows). It never closes the window itself: exiting the process is the orchestrator's `quit_proceed` job, which runs after this returns. In Vite dev mode (`pnpm dev:standalone`), `installPendingUpdate()` skips `install()` (the orchestrator still proceeds to exit); install must be tested from a packaged app because the updater resolves its replacement target from the current executable path.
4044

4145
## Sidecar teardown on Windows
4246

43-
The NSIS installer overwrites files inside the bundled sidecar — including node-pty's native `conpty.node`. Windows refuses to overwrite a native module that a live process still has loaded, so if the Node sidecar is running when NSIS reaches `node_modules`, the install fails with *"Error opening file for writing: …\_up_\sidecar\node_modules\node-pty\prebuilds\win32-x64\conpty.node"*. The Rust `RunEvent::Exit` kill is too late and asynchronous — NSIS starts copying files immediately after `install()` force-kills the app, racing the sidecar's shutdown.
47+
The NSIS installer overwrites files inside the bundled sidecar — including node-pty's native `conpty.node`. Windows refuses to overwrite a native module that a live process still has loaded, so if the Node sidecar is running when NSIS reaches `node_modules`, the install fails with *"Error opening file for writing: …\_up_\sidecar\node_modules\node-pty\prebuilds\win32-x64\conpty.node"*. The Rust `RunEvent::Exit` sidecar kill is too late and asynchronous — NSIS starts copying files immediately after `install()` force-kills the app, racing the sidecar's shutdown. (By quit time the orchestrator's graceful teardown has already killed the sidecar's *PTYs*, but the sidecar process itself is still alive holding those native modules.)
4448

4549
Because `pty-core` spawns with `useConptyDll: true` on Windows (see [terminal-escapes.md](terminal-escapes.md#osc-color-queries-on-windows-require-the-bundled-conpty)), the same hazard now covers two more bundled files: the sidecar additionally `LoadLibrary`s node-pty's `conpty/conpty.dll`, and each pseudoconsole runs an `OpenConsole.exe` child process. `conpty.dll` is released when the sidecar exits (same as `conpty.node`); the `OpenConsole.exe` children run inside the sidecar's job object (`process_wrap`'s `JobObject`), so terminating the sidecar tears them down too.
4650

47-
So on Windows the close handler `invoke`s `kill_sidecar_now` and awaits it before `install()`. That command is synchronous on the Rust side: it sends the kill, then polls `try_wait` (capped at ~5s) until the process has actually exited and released its file handles. `try_wait` is used instead of the job-object `wait()` because `wait()` consumes a completion-port message the reaper thread relies on and could block forever if the sidecar had already exited. macOS and Linux can replace open files in place, so they skip this and rely on the existing `RunEvent::Exit` cleanup.
51+
So on Windows `installPendingUpdate()` `invoke`s `kill_sidecar_now` and awaits it before `install()`. That command is synchronous on the Rust side: it sends the kill, then polls `try_wait` (capped at ~5s) until the process has actually exited and released its file handles. `try_wait` is used instead of the job-object `wait()` because `wait()` consumes a completion-port message the reaper thread relies on and could block forever if the sidecar had already exited. macOS and Linux can replace open files in place, so they skip this and rely on the existing `RunEvent::Exit` cleanup.
4852

4953
## Update notice in the Baseboard
5054

@@ -71,12 +75,18 @@ The Baseboard is in `lib/` but the updater is standalone-only. The notice is thr
7175

7276
## Platform behavior at quit
7377

78+
On every platform the quit orchestrator calls `quit_proceed` after the teardown +
79+
install step returns; `quit_proceed` sets the approved flag and calls
80+
`app.exit(0)`, so the app exit is uniform. The per-platform difference is only in
81+
what `install()` itself does:
82+
7483
| Platform | What `install()` does | App exit |
7584
|----------|----------------------|----------|
76-
| Windows | Kills the sidecar and waits for it to exit (so NSIS can overwrite its loaded native modules), then launches NSIS installer in passive mode (progress bar, no user interaction). Force-kills the app. | Automatic (NSIS) |
77-
| macOS | Replaces the `.app` bundle in place | `getCurrentWindow().close()` after `install()` returns |
78-
| Linux | Replaces the AppImage in place | `getCurrentWindow().close()` after `install()` returns |
79-
| Vite dev mode | Skips `install()` to avoid replacing the dev executable directory | Native close proceeds normally |
85+
| Windows | Kills the sidecar and waits for it to exit (so NSIS can overwrite its loaded native modules), then launches NSIS installer in passive mode (progress bar, no user interaction). Force-kills the app. | NSIS force-kills before `quit_proceed` is reached |
86+
| macOS | Replaces the `.app` bundle in place | `quit_proceed``app.exit(0)` |
87+
| Linux | Replaces the AppImage in place | `quit_proceed``app.exit(0)` |
88+
| No pending update | — (`installPendingUpdate` not called) | `quit_proceed``app.exit(0)` |
89+
| Vite dev mode | Skips `install()` to avoid replacing the dev executable directory | `quit_proceed``app.exit(0)` |
8090

8191
Windows uses `"installMode": "passive"` (configured in `tauri.conf.json` under `plugins.updater.windows`).
8292

@@ -95,7 +105,8 @@ The success marker is written *before* `install()` because Windows NSIS force-ki
95105

96106
| File | Role |
97107
|------|------|
98-
| [`standalone/src/updater.ts`](../../standalone/src/updater.ts) | State machine, update check, user-approved download, close handler, post-install markers |
108+
| [`standalone/src/updater.ts`](../../standalone/src/updater.ts) | State machine, update check, user-approved download, quit-time install (`hasPendingUpdate` / `installPendingUpdate`, called by the quit orchestrator), post-install markers |
109+
| [`standalone/src/quit.ts`](../../standalone/src/quit.ts) | Quit orchestrator (owned by `docs/specs/standalone.md` §Quit flow); calls `installPendingUpdate()` as the last teardown step |
99110
| [`standalone/src/UpdateBanner.tsx`](../../standalone/src/UpdateBanner.tsx) | Pure presentational component — renders inline notice content for the Baseboard |
100111
| [`standalone/src/main.tsx`](../../standalone/src/main.tsx) | Passes `<ConnectedUpdateBanner />` as the `baseboardNotice` prop to `<App />`, calls `startUpdateCheck()` after platform init |
101112

@@ -115,12 +126,12 @@ In `standalone/src-tauri/tauri.conf.json`:
115126
}
116127
```
117128

118-
The Rust side registers the plugin with `tauri_plugin_updater::Builder::new().build()` in `lib.rs`. No custom Rust commands or `on_before_exit` hooks — the JS close handler handles everything. Capabilities must include `core:window:allow-destroy` as well as `core:window:allow-close`: Tauri's `onCloseRequested` API calls `destroy()` after the handler returns when the close was not prevented.
129+
The Rust side registers the plugin with `tauri_plugin_updater::Builder::new().build()` in `lib.rs`. The updater adds no Rust commands of its own; the install step runs entirely in JS (`installPendingUpdate`) and the process exit is the quit orchestrator's `quit_proceed` (`docs/specs/standalone.md` §Quit flow). Capabilities include `core:window:allow-close` and `core:window:allow-destroy` (used by the AppBar window controls); the quit flow itself needs no added capability (`core:event:allow-listen` already exists, and the quit commands are custom, which require none).
119130

120131
## Dependencies
121132

122133
- `@tauri-apps/plugin-updater` — update check, download, install
123-
- `@tauri-apps/api/window``getCurrentWindow()`, `onCloseRequested`
134+
- `@tauri-apps/api/core``invoke('kill_sidecar_now')` before install on Windows
124135
- `@tauri-apps/api/app``getVersion()` for the "from" version in markers
125136
- `@tauri-apps/plugin-shell``open()` for the changelog link
126137
- `tauri-plugin-updater` Rust crate — registered in `Cargo.toml` and `lib.rs`
@@ -135,6 +146,6 @@ The Rust side registers the plugin with `tauri_plugin_updater::Builder::new().bu
135146

136147
**Why write the success marker before `install()`?** On Windows, the NSIS installer force-kills the process — code after `install()` may never run. Writing optimistically and overwriting on failure handles both platforms correctly.
137148

138-
**Why no `on_before_exit` Rust hook?** The JS close handler (`onCloseRequested`) runs before `install()` and handles marker writes and (on Windows) the synchronous sidecar kill. On Windows, NSIS handles process termination after `install()`. On macOS/Linux the sidecar is orphaned and exits when its stdin closes — harmless there because open files can be replaced in place.
149+
**Why install as the last step of the quit orchestrator, not a standalone hook?** The install must run *after* the graceful terminal teardown and the durable final session save (`docs/specs/standalone.md` §Quit flow) — otherwise a Windows NSIS force-kill mid-teardown would lose the freshest scrollback. Folding install into the orchestrator makes that ordering explicit and gives it the same bounded-exit backstops. The updater therefore owns no quit interception of its own.
139150

140151
**Why `localStorage` instead of Tauri's store plugin?** `localStorage` persists across launches in Tauri's webview, requires no additional dependencies, and is automatically scoped to the app. If the user resets app data, markers are cleaned up naturally.

docs/specs/layout.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ For a terminal Surface the pane ID is its session ID. `TerminalPane` calls `getO
295295

296296
### Session persistence
297297

298-
Layout, scrollback, cwd, minimized items, user-pinned titles, untouched state, and alert state are saved to persistent storage via a debounced save (500ms). The layout persists as the native Lath format (`lathLayout`; `docs/specs/tiling-engine.md` → "Persistence and migration"); the legacy dockview `layout` key is no longer written. Derived command/app labels shown on minimized doors are display-only and are not persisted as user-pinned titles. Saves are triggered by every Lath store commit (add/remove/resize/swap/meta) and a 30s periodic interval. Saves are flushed immediately on PTY exit, `pagehide`, and extension shutdown requests.
298+
Layout, scrollback, cwd, minimized items, user-pinned titles, untouched state, and alert state are saved to persistent storage via a debounced save (500ms). The layout persists as the native Lath format (`lathLayout`; `docs/specs/tiling-engine.md` → "Persistence and migration"); the legacy dockview `layout` key is no longer written. Derived command/app labels shown on minimized doors are display-only and are not persisted as user-pinned titles. Every Lath store commit (add/remove/resize/swap/meta, including the active-pane the layout records) *schedules* the debounced save; content changes (terminal output, activity/TODO, pane title/command state, minimized-door changes) only *mark the session dirty*; a 30s heartbeat persists only when the session is dirty, so an idle app stops writing. Saves are flushed immediately and unconditionally on PTY exit, `pagehide`, and extension shutdown requests — the correctness net for any dirty-trigger gap. The dirty-gating mechanism and the store-level identical-value backstop are specified in `docs/specs/standalone.md` §Persistence.
299299

300300
In standalone, each Workspace's snapshot is wrapped in a Window snapshot that records every Workspace (name + layout) and which one is active, so all Workspaces — not just the mounted one — survive a restart. VS Code persists one Workspace per webview exactly as today (one snapshot per `WebviewView` / `WebviewPanel`). The persisted container types (`PersistedWorkspace`, `PersistedWindow`) and their migration live in `docs/specs/transport.md`.
301301

docs/specs/mouse-and-clipboard.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ The temporary override ends on the **next mouse-up event inside the terminal con
5050

5151
**Sticky override.** Clicking **Make sticky** in the banner converts the temporary override into a sticky one. The banner is dismissed; the No-Mouse icon remains visible with its "click to restore" hover text; mouse and wheel events continue to be handled by the terminal rather than the inside program. The sticky override persists until the user clicks the No-Mouse icon, or until the inside program stops requesting mouse reporting.
5252

53-
**Auto-clear on reporting off.** If the inside program stops requesting mouse reporting (e.g. exits or sends DECRST `?1000l`/`?1002l`/`?1003l`) while either override is active, the override is cleared. The icon and banner are removed because there is no longer anything to override.
53+
**Auto-clear on reporting off.** If the inside program stops requesting mouse reporting (e.g. exits or sends DECRST `?1000l`/`?1002l`/`?1003l`) while either override is active, the override is cleared. The icon and banner are removed because there is no longer anything to override. Restoring a **dead** session likewise resets terminal-owned mouse modes: replaying its saved scrollback ends with a Dormouse-emitted reset tail (`REPLAY_MODE_RESET`; see the replay-time mode-reset tail in `docs/specs/terminal-escapes.md`) that DECRSTs mouse tracking so a stale mode latched by a dead TUI does not block selection in the restored pane. The mouse-mode observer syncs the store back to `none` from those DECRSTs like any other.
5454

5555
**No designed keyboard path.** The Mouse icon, No-Mouse icon, and banner buttons are mouse-first: no keybinding or focus management targets them. They are plain buttons, so focus-based activation is not actively prevented — making them properly keyboard-activatable is listed in §9.1.
5656

0 commit comments

Comments
 (0)