Skip to content

Fix standalone localStorage WAL bloat: Rust per-window session store#225

Merged
nedtwigg merged 5 commits into
mainfrom
fix-standalone-bloat
Jul 7, 2026
Merged

Fix standalone localStorage WAL bloat: Rust per-window session store#225
nedtwigg merged 5 commits into
mainfrom
fix-standalone-bloat

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Jul 7, 2026

Copy link
Copy Markdown
Member

Problem

The standalone (Tauri/WKWebView) app persisted its session blob (dormouse.session, multi-MB scrollback) into WebKit localStorage, rewritten on every save (a 500 ms-debounced save plus an unconditional 30 s heartbeat). WebKit stores localStorage as SQLite in WAL mode and pins its own WAL with a long-lived reader that never advances during a running session, so the WAL is never checkpointed and grows unbounded — observed ~1 GB after a few hours (~8 GB/day), and an external checkpoint is blocked by the same reader. Over a multi-day run this is pathological.

Diagnosis detail: PRAGMA wal_checkpoint(PASSIVE) against the live DB moved 0 of ~249k frames (a reader pins frame 0); it only truncates once WebKit closes the store (app quit). So the fix cannot be an external checkpoint — the blob has to leave WebKit localStorage.

Fix — a common seam, two host-native backings

Persistence already flows through the PlatformAdapter.saveState/getState seam. This keeps that seam and re-backs standalone below it:

  • VS Code already persists host-natively (extension host workspaceState / per-panel vscode.setState()) — the host owns which window is which. Unchanged; it's the reference.
  • Standalone now persists via Rust: save_session / load_session write one atomic file per window (<app_data_dir>/sessions/<label>.json, temp-then-rename). Window identity is implicit — Rust keys by the invoking tauri::Window label, so the frontend stays window-agnostic and the store is multi-window-ready even though one window ships today. No WAL, no checkpoint, no unbounded growth.

New SessionKeyValueStore seam in window-persistence.ts: the browser-dev sidecar keeps localStorage (plain Chromium, no WAL issue); the real app supplies TauriSessionStore, an in-memory write-through cache seeded once at boot so getState() stays synchronous (cold-start restore reads it before React mounts), with writes forwarded async and coalesced to one in-flight save_session.

Migration: first boot after this change adopts any legacy localStorage blob into the Rust store and clears the key, so WebKit stops rewriting it and the bloated WAL collapses on the next quit.

Scope note

This is part 1 of 2. It removes the unbounded-growth pathology. Reducing write throughput (skip unchanged saves, drop the unconditional 30 s heartbeat, cap persisted scrollback) is a follow-up — now a pure IO-efficiency task, no longer a bloat fix.

Tests

  • Rust: file round-trip, per-window isolation, atomic overwrite, path-escape sanitization.
  • TS: sync cache read, burst coalescing (first + latest wins), recovery after a rejected write.
  • tsc (lib + standalone), pnpm lint:specs, full standalone + lib persistence suites green.

Specs updated: standalone.md §Persistence (rewrite), transport.md (seam), command list + Files table.

Not yet done: an end-to-end run in a real rebuilt app (file creation, restore across quit, WAL no longer growing) — the reviewer/author can dogfood via dev:standalone.

🤖 Generated with Claude Code

nedtwigg and others added 4 commits July 6, 2026 21:33
Add `save_session` / `load_session` Tauri commands that persist the
webview's PersistedWindow blob as one atomic file per window
(`<app_data>/sessions/<label>.json`), keyed implicitly by the invoking
`tauri::Window` label. Writes are temp-then-rename so a crash can't
truncate the previous snapshot.

This is the standalone-native backing that replaces WebKit localStorage,
whose WKWebView SQLite WAL grew unbounded (WebKit pins its own WAL with a
long-lived reader and never truncates during a days-long session). A file
we overwrite atomically has no WAL and cannot grow; per-window files mean
a second window never rewrites the first's blob.

Pure helpers (read_session_from / write_session_to / session_file_name)
are unit-tested for round-trip, window isolation, atomic overwrite, and
label sanitization (no path escape).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce `SessionKeyValueStore` in window-persistence.ts as the seam
below the shared save/restore code — a single synchronous key/value slot
the host persists natively. `localStorage` (browser-dev sidecar) still
satisfies it structurally; the standalone `TauriAdapter` now backs it with
`TauriSessionStore`, an in-memory write-through cache hydrated once at boot
from the Rust `load_session` file and forwarding writes to `save_session`.

getState() stays synchronous (cold-start restore reads it before React
mounts) because the cache is seeded in init(), which bootstrap() awaits
before restore — mirroring the VS Code adapter's host-injected seed. Writes
coalesce to at most one in-flight save_session, latest-value-wins.

First boot after this change migrates any legacy blob out of WebKit
localStorage into the Rust store and clears the key, so WebKit stops
rewriting it and its bloated WAL collapses on the next quit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite standalone.md §Persistence: the session blob no longer lives in
WebKit localStorage (whose WKWebView SQLite WAL grew unbounded — WebKit
pins its own WAL with a long-lived reader and never checkpoints during a
running session). It now persists via a Rust per-window atomic file store
(save_session / load_session), keyed implicitly by the tauri::Window label,
multi-window-ready. Document the synchronous-getState boot cache, write
coalescing, and the one-time localStorage→Rust migration. Add the two
commands to the Rust bridge list and the store to the Files table.

transport.md: the standalone boundary round-trips through the new
SessionKeyValueStore seam — localStorage for the browser-dev sidecar, the
Rust file store for the real standalone app, never WebKit localStorage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…plify)

No behavior change:
- Collapse the write coalescer from 3 fields to 2 — drop `hasPending`,
  use `pending: string | null` as the "nothing queued" sentinel (a queued
  value is always a JSON string, never null), removing the unreachable
  `?? ""` fallback it forced.
- Rust: build the temp path from the computed filename (`{name}.tmp`)
  instead of an into_os_string()/push round-trip.
- Unexport the file-local SessionSaveFn type.
- Document the store's window-label keying (why the KV key is ignored) and
  the one-time migration's single-window assumption + a SUNSET marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 7, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 5a0e876
Status: ✅  Deploy successful!
Preview URL: https://2d7bc54d.mouseterm.pages.dev
Branch Preview URL: https://fix-standalone-bloat.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The atomic Rust file store is a clean fix for the WAL bloat, and the boot-seeded write-through cache correctly preserves the synchronous getState() contract. The coalescing state machine and its tests read well. One design consideration worth an explicit decision before this lands:

Standalone loses the last save on quit — a durability property localStorage used to provide. The old TauriAdapter.saveState went through localStorage.setItem, which is synchronous and durable on return (that durable-per-write behavior is exactly what grew the WAL). The new saveState updates the in-memory cache and fires save_session asynchronously, returning before the Rust write lands. Nothing on the shutdown path waits for that write:

  • onRequestSessionFlush / notifySessionFlushComplete are no-ops in tauri-adapter.ts (that flush-and-wait handshake is only wired for VS Code panel disposal).
  • TauriSessionStore has no drain/await; TauriAdapter.shutdown() doesn't call one.
  • RunEvent::Exit in lib.rs only shuts down the sidecar.

So the pagehide handler and the unmount-time persistSessionNow() in use-session-persistence.ts resolve as soon as the synchronous saveState returns — the fired save_session invoke can be dropped when the webview tears down. Net effect: on a clean quit, the delta since the last completed async write (up to one debounce/heartbeat cycle) is lost, where before it was always durable.

This may well be an acceptable trade for part 1 — scrollback restore is best-effort and saves are frequent — but it's a real behavioral change from the localStorage baseline with no drain-on-quit to replace it, so it seems worth a conscious call rather than a silent side effect. (I haven't run a rebuilt app to confirm the invoke is actually dropped at quit — the PR notes e2e testing is still pending — but the synchronous-durability guarantee is gone at the code level regardless.)

Same shape, smaller: the migration at tauri-adapter.ts:190-193 clears the legacy localStorage key immediately after firing save_session without awaiting it. This is mostly self-healing because the cache is seeded with the same blob (hydrate(seed)), so the next normal save re-persists it — the only true loss is migration-invoke-dropped and quit before any subsequent save. Routing the migration write through the store (or draining the store on quit) would cover both cases at once.

Not blocking — flagging for your judgment on whether the last-save-on-quit window is acceptable for this stage.

…+ doc

Per review on #225 (last-save-on-quit is no longer durable now that
save_session is async, vs the old synchronous-durable localStorage):

- Route the one-time localStorage→Rust migration write through the store's
  normal setItem path instead of a raw fire-and-forget invoke, so it shares
  the same coalescing and the planned drain-on-quit.
- Document the durability trade in standalone.md §Persistence as a current
  limitation (accepted: restore is best-effort, saves are frequent).
- Add ## Future "Drain-on-quit for the session store": store.drain() +
  onCloseRequested preventDefault → flush → await drain → close, to land
  with the part-2 save-path rework.

The full native drain is deferred to part 2 (it must intercept the
updater-owned close path and bridge the React save hook + store drain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nedtwigg

nedtwigg commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

Good catch, and the diagnosis is accurate — the synchronous-durability guarantee is gone at the code level. saveState fires save_session and returns; the flush handshake is a no-op on Tauri, shutdown() has no caller, and onCloseRequested only prevents default for a pending update, so a clean quit can drop the pagehide save.

Conscious call: accept the bounded window for this PR, land the proper drain in part 2. The lost delta is state changed in the final debounce/heartbeat window (≤500 ms for anything that triggers a layout save; up to the heartbeat for pure-scrollback drift), affecting only cold restore on next launch — best-effort by design. The reliable fix can't live in pagehide (browsers won't await async there); it has to intercept onCloseRequestedpreventDefault → final flushSessionSave()await store.drain()close, bridging the React save hook, the store, and the updater-owned close path with its Windows ordering. That's the same path part 2's save-path rework (write-on-change + heartbeat removal) reshapes, so it belongs there.

Done now in 5a0e876:

  • Routed the one-time migration write through the store's setItem (one write path; it'll participate in the future drain) — covers your smaller point.
  • Documented the trade in standalone.md §Persistence as a current limitation, and added ## Future "Drain-on-quit for the session store" with the exact plan above.

Thanks for the review.

@nedtwigg nedtwigg merged commit 4da9e86 into main Jul 7, 2026
9 checks passed
@nedtwigg nedtwigg deleted the fix-standalone-bloat branch July 7, 2026 06:42
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