Skip to content

Commit 4da9e86

Browse files
authored
Fix standalone localStorage WAL bloat: Rust per-window session store (#225)
2 parents ca59db0 + 5a0e876 commit 4da9e86

7 files changed

Lines changed: 389 additions & 16 deletions

File tree

docs/specs/standalone.md

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ stderr, which Rust appends to the log file). Webview → Rust is the Tauri
6464
`pty_get_scrollback` / `get_available_shells`, `dor_control_response`,
6565
`iframe_create_proxy_url`, the `agent_browser_*` family, the `clipboard`
6666
readers, `read_update_log`, and `kill_sidecar_now` — each a thin forwarder to
67-
the corresponding sidecar message, with two carve-outs: on Windows the
67+
the corresponding sidecar message. `load_session` / `save_session` are the
68+
exception that is *not* forwarded: they read/write the per-window session file
69+
directly in Rust (§Persistence). Two further carve-outs: on Windows the
6870
clipboard readers skip the sidecar and read the Win32 clipboard natively
6971
(`clipboard_win.rs`; behavior in `docs/specs/mouse-and-clipboard.md` §8.6),
7072
and `agent_browser_screenshot` receives a temp-file *path* from the sidecar
@@ -169,13 +171,59 @@ The workspace strip lands here when the workspaces rollout reaches stage 3 —
169171

170172
## Persistence
171173

172-
`TauriAdapter.saveState` / `getState` store the session blob in webview
173-
`localStorage` under `TauriAdapter.STATE_KEY`, routed through
174+
`TauriAdapter.saveState` / `getState` route the session blob through
174175
`lib/src/lib/window-persistence.ts` (`loadSessionState` / `saveSessionState`)
175176
— the standalone adapter boundary where the `PersistedWindow` wrapping lives,
176177
identity-passthrough while the workspaces flag is off
177-
(`docs/specs/transport.md`, Workspace/Window containers). Theme selection
178-
persists separately through the theme store (`docs/specs/theme.md`).
178+
(`docs/specs/transport.md`, Workspace/Window containers). The backing store is
179+
**not** WebKit `localStorage`: `window-persistence.ts` reads/writes through the
180+
`SessionKeyValueStore` seam, and the standalone adapter supplies a Rust-backed
181+
implementation (`standalone/src/tauri-session-store.ts`). Theme selection still
182+
persists through the theme store on `localStorage` (`docs/specs/theme.md`); it
183+
is tiny and rarely written, so it does not stress the WebKit store.
184+
185+
**Why not `localStorage`.** WKWebView stores `localStorage` as SQLite in WAL
186+
mode. Dormouse rewrites the multi-MB scrollback-bearing session blob on every
187+
save, and WebKit pins its own WAL with a long-lived reader that never advances
188+
during a running session — so the WAL is never checkpointed and grows unbounded
189+
(observed ~1 GB after a few hours; an external checkpoint is blocked by the same
190+
reader). A days-long session made this pathological.
191+
192+
**Rust file store.** `save_session(window, state)` / `load_session(window)`
193+
(`lib.rs`) persist the blob as one atomic file per Tauri window —
194+
`<app_data_dir>/sessions/<label>.json`, written temp-then-rename so a crash
195+
cannot truncate the previous snapshot. There is no WAL to grow, and overwriting
196+
in place bounds the on-disk size to one blob. **Window identity is implicit**:
197+
each command keys by the invoking `tauri::Window`'s `label()`, so the frontend
198+
stays window-agnostic and a second window (`win-2`, …) persists to its own file
199+
without ever rewriting the first window's blob — the store is multi-window even
200+
though the app ships a single window today.
201+
202+
**Boot + the synchronous-read constraint.** `getState()` is synchronous because
203+
cold-start restore reads it before React mounts, but a Tauri `invoke` is async.
204+
`TauriSessionStore` resolves this with an in-memory write-through cache: `init()`
205+
(awaited by `bootstrap()` before `resumeOrRestore`) hydrates the cache from
206+
`load_session`, `getItem` returns the cache synchronously, and `setItem` updates
207+
the cache and forwards to `save_session` asynchronously, coalescing bursts to at
208+
most one in-flight write (latest value wins). This mirrors how the VS Code
209+
adapter reads a host-injected seed (`docs/specs/vscode.md`).
210+
211+
**Migration.** On the first boot after this change `load_session` returns null;
212+
if a legacy blob is still in `localStorage` under `TauriAdapter.STATE_KEY`, the
213+
adapter adopts it, persists it to the Rust store (through the store's normal
214+
write path, so it shares the coalescing), and removes the key — so WebKit stops
215+
rewriting it and its bloated WAL collapses on the next quit.
216+
217+
**Durability on quit (current limitation).** `saveState` returns after updating
218+
the cache and *firing* `save_session`; nothing awaits the Rust write on
219+
shutdown — the `onRequestSessionFlush` handshake is a no-op here (VS Code-only),
220+
and the normal quit path does not intercept the window close (`updater.ts`
221+
`onCloseRequested` only prevents default for a pending update). So a clean quit
222+
can drop the save fired at `pagehide`, losing state changed in the final
223+
debounce/heartbeat window — a regression from the old `localStorage` path, which
224+
WebKit flushed on teardown. Accepted for now because restore is best-effort and
225+
saves are frequent (≤500 ms for layout changes); a drain-on-quit is planned
226+
(`## Future`).
179227

180228
## File drop
181229

@@ -224,13 +272,14 @@ root `package.json` for the `dev:standalone*` orchestration.
224272

225273
| File | Role |
226274
|------|------|
227-
| `standalone/src-tauri/src/lib.rs` | Rust backend: sidecar spawn/supervision, invoke commands, event forwarding, file drop, logging, dock icon, exit teardown |
275+
| `standalone/src-tauri/src/lib.rs` | Rust backend: sidecar spawn/supervision, invoke commands, event forwarding, per-window session file store (`save_session` / `load_session`), file drop, logging, dock icon, exit teardown |
228276
| `standalone/src-tauri/src/clipboard_win.rs` | Native Win32 clipboard reads on Windows (owned by `docs/specs/mouse-and-clipboard.md`) |
229277
| `standalone/scripts/tauri.mjs`, `csp.mjs` | Tauri CLI wrapper assembling the webview CSP (`DORMOUSE_REMOTE_CONNECT_SRC`) |
230278
| `standalone/src-tauri/tauri.conf.json` | Window config, dev/build commands, sidecar resources glob, updater config |
231279
| `standalone/src/main.tsx` | Webview bootstrap (boot sequence above) |
232280
| `standalone/src/AppBar.tsx` | Titlebar: shell dropdown, theme picker, window controls |
233-
| `standalone/src/tauri-adapter.ts` | `TauriAdapter`: PlatformAdapter over Tauri invoke/events, localStorage persistence, control-request dispatch |
281+
| `standalone/src/tauri-adapter.ts` | `TauriAdapter`: PlatformAdapter over Tauri invoke/events, session persistence via the Rust store, control-request dispatch |
282+
| `standalone/src/tauri-session-store.ts` | `TauriSessionStore`: Rust-backed `SessionKeyValueStore` — boot-seeded write-through cache over `load_session` / `save_session` (§Persistence) |
234283
| `standalone/src/updater.ts`, `UpdateBanner.tsx`, `UpdateDebugModal.tsx` | Auto-update (owned by `docs/specs/auto-update.md`) |
235284
| `standalone/src/browser-sidecar-host.ts`, `browser-sidecar-adapter.ts` | Browser-dev harness (owned by `docs/specs/transport.md`) |
236285
| `standalone/sidecar/main.js` | Sidecar entry: stdio JSON-lines dispatch, shutdown ordering, parent-PID watchdog |
@@ -239,3 +288,14 @@ root `package.json` for the `dev:standalone*` orchestration.
239288
| `standalone/sidecar/clipboard-ops.js` | OS clipboard tiers (owned by `docs/specs/mouse-and-clipboard.md`) |
240289
| `standalone/scripts/build-sidecar-proxy.mjs` | Bundles `lib/src/host/` into the sidecar `.cjs` copies |
241290
| `standalone/scripts/dev-agent-browser.mjs` | `dev:standalone:ab` entry (owned by `docs/specs/transport.md`) |
291+
292+
## Future
293+
294+
**Drain-on-quit for the session store.** Restore the last-save durability the
295+
`localStorage` path had before the Rust store (§Persistence, "Durability on
296+
quit"). Add `TauriSessionStore.drain()` — resolves when no `save_session` is
297+
in-flight or pending — and have the close path (`updater.ts` `onCloseRequested`)
298+
`preventDefault`, run a final `flushSessionSave()`, `await` the drain (with a
299+
timeout so a hung write can't wedge quit), then close. Lands with the part-2
300+
save-path rework (write-on-change + heartbeat removal), which reshapes the same
301+
path.

docs/specs/transport.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ Source of truth: `lib/src/lib/session-types.ts` defines the persisted-session in
114114

115115
**Workspace/Window containers (implemented, dormant behind the `dormouse.flags.workspaces` flag; rollout ledger in `docs/specs/layout.md` `## Future`).** A **Workspace** persists as a `PersistedWorkspace`: a `WorkspaceId`, a user-facing `name`, and the Workspace's `PersistedSession` (its panes, doors, and dockview layout). The standalone Window persists as a `PersistedWindow`: the ordered list of `PersistedWorkspace` plus the active `WorkspaceId`. Source of truth: `PersistedWorkspace` / `PersistedWindow` / `readPersistedWindow` / `replaceActiveSession` in `session-types.ts`. VS Code does **not** use `PersistedWindow`; each webview persists exactly one `PersistedSession` — its single Workspace — through the same per-surface state API as today (`workspaceState` for the view, `vscode.setState()` per editor panel; see `docs/specs/vscode.md`).
116116

117-
The wrapping lives at the **standalone adapter boundary**, not in the shared save/restore code: `lib/src/lib/window-persistence.ts` (`activeSessionFromStored` / `storedValueForSession`) translates between the host's stored top-level blob and the bare `PersistedSession` that `reconnect.ts` / `session-save.ts` operate on, and `tauri-adapter.ts` / `browser-sidecar-adapter.ts` route `getState` / `saveState` through it. With the flag **off** (the default) these are identity passthroughs — the stored blob stays a bare `PersistedSession` and behavior is byte-identical to pre-workspace behavior. With the flag **on**, load returns the active Workspace's session and save merges it back into the active slot, preserving the other Workspaces.
117+
The wrapping lives at the **standalone adapter boundary**, not in the shared save/restore code: `lib/src/lib/window-persistence.ts` (`activeSessionFromStored` / `storedValueForSession`) translates between the host's stored top-level blob and the bare `PersistedSession` that `reconnect.ts` / `session-save.ts` operate on, and `tauri-adapter.ts` / `browser-sidecar-adapter.ts` route `getState` / `saveState` through it. The blob round-trips through a `SessionKeyValueStore` — a single synchronous key/value slot the host persists natively: the browser-dev sidecar uses `localStorage`, while the real standalone adapter uses a Rust-backed per-window file store (`docs/specs/standalone.md` §Persistence), never WebKit `localStorage`. With the flag **off** (the default) these are identity passthroughs — the stored blob stays a bare `PersistedSession` and behavior is byte-identical to pre-workspace behavior. With the flag **on**, load returns the active Workspace's session and save merges it back into the active slot, preserving the other Workspaces.
118118

119119
Versioning and migration: the standalone top-level snapshot is a `PersistedWindow` (its own `version: 1`) wrapping v3 sessions. A pre-workspace bare `PersistedSession` (any version) migrates on read to a single `PersistedWorkspace` named `Workspace 1`, marked active, inside a `PersistedWindow` (`readPersistedWindow`); unreadable inner sessions are dropped and a dangling `activeWorkspaceId` is repaired to the first Workspace. A host that hands back a bare `PersistedSession` (VS Code, or legacy/flag-off standalone storage) is read as one Workspace. Migrations stay additive — older shapes keep flowing v1→v2→v3→(window) without losing panes, doors, alert state, or surface kind.
120120

lib/src/lib/window-persistence.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,34 @@ export function storedValueForSession(existingStored: unknown, session: unknown)
4242
return existingWindow ? replaceActiveSession(existingWindow, next) : wrapSessionInWindow(next);
4343
}
4444

45+
/**
46+
* The seam below the shared save/restore code: a single synchronous key/value
47+
* slot the host persists natively. `localStorage` (browser-dev sidecar) and the
48+
* standalone `TauriSessionStore` (a Rust-backed, boot-seeded cache) both satisfy
49+
* it — the same interface, two host-native backings (`docs/specs/standalone.md`
50+
* §Persistence). `Storage` is a structural superset, so passing `localStorage`
51+
* still type-checks. VS Code does not go through here; it persists one bare
52+
* `PersistedSession` per webview through the extension host's own state APIs.
53+
*/
54+
export interface SessionKeyValueStore {
55+
getItem(key: string): string | null;
56+
setItem(key: string, value: string): void;
57+
}
58+
4559
// Storage-level round trip shared by the standalone adapters (Tauri + the
46-
// browser-dev sidecar). Owns the JSON parse/stringify and the `Storage` access
60+
// browser-dev sidecar). Owns the JSON parse/stringify and the store access
4761
// so each adapter's get/save collapses to one call instead of re-implementing
4862
// the read-merge-write dance.
4963

5064
/** Read the stored blob and return the `PersistedSession` to restore (or null). */
51-
export function loadSessionState(storage: Storage, key: string): unknown {
65+
export function loadSessionState(storage: SessionKeyValueStore, key: string): unknown {
5266
const raw = storage.getItem(key);
5367
if (raw === null) return null;
5468
return activeSessionFromStored(JSON.parse(raw));
5569
}
5670

5771
/** Persist `session` under `key`, merging into the active Workspace when the flag is on. */
58-
export function saveSessionState(storage: Storage, key: string, session: unknown): void {
72+
export function saveSessionState(storage: SessionKeyValueStore, key: string, session: unknown): void {
5973
// Flag off (the default): store the bare session without reading the existing
6074
// blob — its previous value is irrelevant, so skip parsing the (potentially
6175
// large, scrollback-bearing) stored snapshot.

standalone/src-tauri/src/lib.rs

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,78 @@ fn read_update_log() -> Result<String, String> {
572572
read_log_tail(10_000)
573573
}
574574

575+
// --- Per-window session persistence (docs/specs/standalone.md §Persistence) ---
576+
//
577+
// The webview's persisted-session blob (a `PersistedWindow`) is stored as one
578+
// atomic file per Tauri window, keyed by the window label. This replaces webview
579+
// `localStorage`, whose WKWebView SQLite WAL grew unbounded because WebKit pins
580+
// its own WAL with a long-lived reader and never truncates during a days-long
581+
// session. A plain file we overwrite atomically has no WAL and cannot grow.
582+
//
583+
// Window identity is implicit: each command keys by the invoking window's label,
584+
// so the frontend stays window-agnostic and a second window (`win-2`, …) persists
585+
// to its own file without ever rewriting the first window's blob.
586+
587+
fn sessions_dir(app: &AppHandle) -> Result<PathBuf, String> {
588+
Ok(app
589+
.path()
590+
.app_data_dir()
591+
.map_err(|e| format!("app_data_dir unavailable: {e}"))?
592+
.join("sessions"))
593+
}
594+
595+
// Window labels are app-controlled (e.g. "main"), but sanitize defensively so a
596+
// label can never escape the sessions directory or embed a path separator.
597+
fn session_file_name(label: &str) -> String {
598+
let safe: String = label
599+
.chars()
600+
.map(|c| {
601+
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
602+
c
603+
} else {
604+
'_'
605+
}
606+
})
607+
.collect();
608+
format!("{safe}.json")
609+
}
610+
611+
fn read_session_from(dir: &Path, label: &str) -> Result<Option<String>, String> {
612+
let path = dir.join(session_file_name(label));
613+
match std::fs::read_to_string(&path) {
614+
Ok(contents) => Ok(Some(contents)),
615+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
616+
Err(e) => Err(format!("read session {label}: {e}")),
617+
}
618+
}
619+
620+
fn write_session_to(dir: &Path, label: &str, state: &str) -> Result<(), String> {
621+
create_dir_all(dir).map_err(|e| format!("create sessions dir: {e}"))?;
622+
let file_name = session_file_name(label);
623+
let path = dir.join(&file_name);
624+
let tmp = dir.join(format!("{file_name}.tmp"));
625+
// Atomic replace: write a sibling temp file, fsync it, then rename over the
626+
// target so a crash mid-write can never truncate the previous good snapshot.
627+
{
628+
let mut f = File::create(&tmp).map_err(|e| format!("open temp: {e}"))?;
629+
f.write_all(state.as_bytes())
630+
.map_err(|e| format!("write temp: {e}"))?;
631+
f.sync_all().map_err(|e| format!("fsync temp: {e}"))?;
632+
}
633+
std::fs::rename(&tmp, &path).map_err(|e| format!("rename session {label}: {e}"))?;
634+
Ok(())
635+
}
636+
637+
#[tauri::command]
638+
fn load_session(window: tauri::Window) -> Result<Option<String>, String> {
639+
read_session_from(&sessions_dir(window.app_handle())?, window.label())
640+
}
641+
642+
#[tauri::command]
643+
fn save_session(window: tauri::Window, state: String) -> Result<(), String> {
644+
write_session_to(&sessions_dir(window.app_handle())?, window.label(), &state)
645+
}
646+
575647
#[tauri::command]
576648
fn kill_sidecar_now(state: tauri::State<'_, SidecarState>) {
577649
kill_sidecar_and_wait(&state.child);
@@ -1103,6 +1175,8 @@ pub fn run() {
11031175
read_clipboard_image_as_file_path,
11041176
read_clipboard_text,
11051177
read_update_log,
1178+
load_session,
1179+
save_session,
11061180
agent_browser_command,
11071181
agent_browser_edit,
11081182
agent_browser_screenshot,
@@ -1129,8 +1203,8 @@ pub fn run() {
11291203
#[cfg(test)]
11301204
mod tests {
11311205
use super::{
1132-
find_node_binary, resolve_dor_cli_paths, resolve_sidecar_path,
1133-
strip_windows_verbatim_prefix,
1206+
find_node_binary, read_session_from, resolve_dor_cli_paths, resolve_sidecar_path,
1207+
session_file_name, strip_windows_verbatim_prefix, write_session_to,
11341208
};
11351209
use std::fs;
11361210
use std::path::{Path, PathBuf};
@@ -1343,4 +1417,47 @@ mod tests {
13431417
assert_eq!(resolved, sidecar_path);
13441418
assert!(!resolved.to_string_lossy().contains(r"\\?\"));
13451419
}
1420+
1421+
#[test]
1422+
fn session_missing_reads_none() {
1423+
let dir = TempDir::new("sessions-missing");
1424+
// No file yet — a fresh install / new window reads as None, not an error.
1425+
assert_eq!(read_session_from(dir.path(), "main").unwrap(), None);
1426+
}
1427+
1428+
#[test]
1429+
fn session_round_trips_and_isolates_windows() {
1430+
let dir = TempDir::new("sessions-roundtrip");
1431+
write_session_to(dir.path(), "main", r#"{"v":1,"who":"main"}"#).unwrap();
1432+
assert_eq!(
1433+
read_session_from(dir.path(), "main").unwrap().as_deref(),
1434+
Some(r#"{"v":1,"who":"main"}"#),
1435+
);
1436+
1437+
// A second window persists to its own file and never touches the first's.
1438+
write_session_to(dir.path(), "win-2", r#"{"v":1,"who":"win-2"}"#).unwrap();
1439+
assert_eq!(
1440+
read_session_from(dir.path(), "main").unwrap().as_deref(),
1441+
Some(r#"{"v":1,"who":"main"}"#),
1442+
);
1443+
assert_eq!(
1444+
read_session_from(dir.path(), "win-2").unwrap().as_deref(),
1445+
Some(r#"{"v":1,"who":"win-2"}"#),
1446+
);
1447+
1448+
// Overwrite is atomic-replace, not append: the latest blob fully wins.
1449+
write_session_to(dir.path(), "main", r#"{"v":2}"#).unwrap();
1450+
assert_eq!(
1451+
read_session_from(dir.path(), "main").unwrap().as_deref(),
1452+
Some(r#"{"v":2}"#),
1453+
);
1454+
}
1455+
1456+
#[test]
1457+
fn session_label_cannot_escape_directory() {
1458+
// A hostile label is flattened to a plain filename inside the dir.
1459+
assert_eq!(session_file_name("../../evil"), "______evil.json");
1460+
assert_eq!(session_file_name("main"), "main.json");
1461+
assert_eq!(session_file_name("a/b"), "a_b.json");
1462+
}
13461463
}

0 commit comments

Comments
 (0)