From 968d8da4bf75d2a6b0b13a7c2f0dabf4dcb0e9f6 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:17:05 +0000 Subject: [PATCH] feat(web): F5 persist serialized xterm state for one-write reload restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On reload a pane re-parsed its cached raw scrollback tail into xterm — the per-pane parse cost F1 already bounds, but still a full re-parse. F5 persists the pane's xterm screen + a capped scrollback via @xterm/addon-serialize and restores it in a single term.write, no raw re-parse, then reattaches with the F1-bounded resume_from delta. Cache format (terminalCache.ts): an entry now holds EITHER a serialized screen (a live pane, the fast path) OR raw bytes. Raw is still written by the headless pre-warm path (terminalPrewarm has no xterm to serialize) and as a live-pane fallback when serialization is unavailable, and it still reads a pre-F5 entry — so the format change needs no DB version bump or migration (a deviation from the plan's "bump + drop v1", forced by the pre-warm path the plan did not account for; keeping raw as a first-class shape is strictly more compatible). The ArrayBuffer check is now realm-safe. Restore (Terminal.tsx): restoreCache() writes a serialized entry in one go, or re-parses a ground-state raw tail through the shared queue, and it composes with F3 — a parked tab defers the whole entry and restores on first activation. The serialized scrollback is capped at 2000 lines (SERIALIZE_MAX_SCROLLBACK), well under the 5000-line buffer, to bound the cache entry and the restore write. Tests: - serializeRestore.test.ts: over the real corpus (H2), serialize→restore→ reserialize is a fixed point (screen + scrollback preserved) through the same core VT parser (@xterm/headless); the cap holds. This also lands H2's deferred serialize()-compare fidelity. - terminalCacheRoundtrip.test.ts (fake-indexeddb): serialized and raw entries round-trip, raw is ground-state trimmed, a malformed entry reads as cold. - terminalPrewarmAttach updated for the { data } payload shape. Verification: pnpm typecheck clean; pnpm vitest 938 passed (10 new); pnpm build bundles addon-serialize. WI-129. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UtEFLJAhLiq4NfZCN2vuBb --- web/package.json | 3 + web/pnpm-lock.yaml | 25 ++++ web/src/Terminal.tsx | 116 +++++++++++++----- web/src/__tests__/serializeRestore.test.ts | 71 +++++++++++ .../__tests__/terminalCacheRoundtrip.test.ts | 76 ++++++++++++ .../__tests__/terminalPrewarmAttach.test.ts | 5 +- web/src/terminalCache.ts | 70 +++++++---- web/src/terminalPrewarm.ts | 4 +- 8 files changed, 318 insertions(+), 52 deletions(-) create mode 100644 web/src/__tests__/serializeRestore.test.ts create mode 100644 web/src/__tests__/terminalCacheRoundtrip.test.ts diff --git a/web/package.json b/web/package.json index c50329c1..6cdc30fc 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,7 @@ "@solidjs/router": "^0.16.1", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", + "@xterm/addon-serialize": "^0.14.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "monaco-editor": "^0.55.1", @@ -32,6 +33,8 @@ "@solidjs/testing-library": "^0.8.10", "@testing-library/jest-dom": "^7.0.1", "@testing-library/user-event": "^14.6.4", + "@xterm/headless": "^6.0.0", + "fake-indexeddb": "^6.2.5", "jsdom": "^30.0.1", "typescript": "^6.0.3", "vite": "^8.2.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 08d3ad28..ff9e1c55 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -34,6 +34,9 @@ importers: '@xterm/addon-search': specifier: ^0.16.0 version: 0.16.0 + '@xterm/addon-serialize': + specifier: ^0.14.0 + version: 0.14.0 '@xterm/addon-web-links': specifier: ^0.12.0 version: 0.12.0 @@ -59,6 +62,12 @@ importers: '@testing-library/user-event': specifier: ^14.6.4 version: 14.6.4(@testing-library/dom@10.4.1) + '@xterm/headless': + specifier: ^6.0.0 + version: 6.0.0 + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 jsdom: specifier: ^30.0.1 version: 30.0.1 @@ -507,9 +516,15 @@ packages: '@xterm/addon-search@0.16.0': resolution: {integrity: sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==} + '@xterm/addon-serialize@0.14.0': + resolution: {integrity: sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==} + '@xterm/addon-web-links@0.12.0': resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} + '@xterm/headless@6.0.0': + resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} + '@xterm/xterm@6.0.0': resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} @@ -637,6 +652,10 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1538,8 +1557,12 @@ snapshots: '@xterm/addon-search@0.16.0': {} + '@xterm/addon-serialize@0.14.0': {} + '@xterm/addon-web-links@0.12.0': {} + '@xterm/headless@6.0.0': {} + '@xterm/xterm@6.0.0': {} ansi-regex@5.0.1: {} @@ -1640,6 +1663,8 @@ snapshots: expect-type@1.4.0: {} + fake-indexeddb@6.2.5: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 diff --git a/web/src/Terminal.tsx b/web/src/Terminal.tsx index fa1baa19..5a997c18 100644 --- a/web/src/Terminal.tsx +++ b/web/src/Terminal.tsx @@ -4,6 +4,7 @@ import { Terminal as XTerm } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { SearchAddon, type ISearchResultChangeEvent } from "@xterm/addon-search"; +import { SerializeAddon } from "@xterm/addon-serialize"; import "@xterm/xterm/css/xterm.css"; import { openAttach } from "./api"; import type { RuntimeSocket } from "./runtimeTransport"; @@ -20,6 +21,7 @@ import { loadTerminalCache, MAX_TERMINAL_CACHE_BYTES, saveTerminalCache, + type TerminalCacheEntry, } from "./terminalCache"; import { createReplayQueue, @@ -30,6 +32,12 @@ import { type ReplayHandle, type ReplayTail, } from "./terminalReplay"; + +// Cap on the scrollback the serialized cache persists. A 5000-line scrollback +// full of wide chars and colour serializes large; 2000 lines keeps the cache +// entry and the one-write restore bounded while still filling the viewport and +// a deep scroll on reload (WI-129). +const SERIALIZE_MAX_SCROLLBACK = 2000; import { beginForegroundReplay } from "./terminalPrewarm"; import { clampTerminalFontSize, @@ -138,16 +146,17 @@ const TerminalView: Component = (props) => { let ws: RuntimeSocket | null = null; let fit: FitAddon | null = null; let search: SearchAddon | null = null; + let serializeAddon: SerializeAddon | null = null; let resizeObserver: ResizeObserver | null = null; let inSnapshot = true; let outputPosition: number | undefined; let snapshotEndPosition: number | undefined; let cacheChunks: Uint8Array[] = []; let cacheBytes = 0; - // A parked pane's cached tail, prepared but not yet replayed. Kept in memory - // on reload and replayed lazily on first activation (F3), so retained tabs do - // not time-slice the shared replay parser with the active pane. - let deferredCacheReplay: ReplayTail | null = null; + // A parked pane's cache entry, loaded but not yet restored. Kept in memory on + // reload and restored lazily on first activation (F3), so retained tabs do not + // time-slice the shared replay parser with the active pane. + let deferredCache: TerminalCacheEntry | null = null; let cacheTimer: ReturnType | null = null; // The outputPosition at the last successful persist, so an unchanged ring is // not re-copied and re-written to IndexedDB. @@ -322,6 +331,19 @@ const TerminalView: Component = (props) => { return combined; }; + // Serialize the live terminal's screen + a capped scrollback, so a reload + // restores it in one write instead of re-parsing raw bytes (F5). Returns null + // when serialization is unavailable or throws, so persistCache can fall back + // to the raw ring. + const trySerialize = (): string | null => { + if (!serializeAddon) return null; + try { + return serializeAddon.serialize({ scrollback: SERIALIZE_MAX_SCROLLBACK }); + } catch { + return null; + } + }; + const persistCache = () => { if (outputPosition === undefined) return; // Skip the write when nothing new has arrived since the last persist @@ -329,7 +351,12 @@ const TerminalView: Component = (props) => { // value means the ring is identical and copying+writing it is wasted work. if (outputPosition === lastPersistedPosition) return; lastPersistedPosition = outputPosition; - void saveTerminalCache(props.sessionId, outputPosition, cachedBytes()); + const serialized = trySerialize(); + void saveTerminalCache( + props.sessionId, + outputPosition, + serialized !== null ? { serialized } : { data: cachedBytes() }, + ); }; const scheduleCachePersist = () => { @@ -547,6 +574,8 @@ const TerminalView: Component = (props) => { fit = new FitAddon(); term.loadAddon(fit); term.loadAddon(new WebLinksAddon()); + serializeAddon = new SerializeAddon(); + term.loadAddon(serializeAddon); search = new SearchAddon(); term.loadAddon(search); search.onDidChangeResults((info) => props.onSearchResults?.(info)); @@ -814,31 +843,23 @@ const TerminalView: Component = (props) => { if (destroyed) return; // A live pane's initial load holds the pre-warm gate until snapshot-done. enterForegroundReplay(); - if (!cached || cached.data.byteLength === 0) { + if (!cached) { setReadyToConnect(true); if (!isParked()) connect(); return; } - const bytes = new Uint8Array(cached.data); - const prepared = prepareReplayTail(bytes, cached.outputPosition); - cacheChunks = [bytes]; - cacheBytes = bytes.byteLength; // Adopt the cache's position now so a warm reattach resumes from it even - // if the visible replay is deferred (or later cancelled by a re-park). - outputPosition = prepared.outputPosition; + // if the visible restore is deferred (or later cancelled by a re-park). + outputPosition = cached.outputPosition; if (shouldDeferCacheReplay(isParked(), true)) { - // Parked on reload: keep the tail in memory and replay it on the first + // Parked on reload: keep the entry in memory and restore it on the first // activation, not into the shared FIFO with the active pane (F3). - deferredCacheReplay = prepared; + deferredCache = cached; setReadyToConnect(true); return; } - setStatusText("Restoring terminal..."); - replay = replayCacheTail(prepared); - void replay.done.then(() => { + restoreCache(cached, () => { if (destroyed) return; - outputPosition = prepared.outputPosition; - term?.scrollToBottom(); setReadyToConnect(true); if (!isParked()) connect(); }); @@ -917,6 +938,49 @@ const TerminalView: Component = (props) => { ); } + /** + * Restore a cached terminal, then run `onDone`. A serialized entry (F5) is a + * single `term.write` of the screen + capped scrollback — no raw re-parse. A + * raw entry (pre-warm, fallback, or a pre-F5 cache) re-parses a ground-state + * tail through the shared replay queue, exactly as before F5. + */ + function restoreCache(cached: TerminalCacheEntry, onDone: () => void): void { + setStatusText("Restoring terminal..."); + if (typeof cached.serialized === "string") { + term?.reset(); + cacheChunks = []; + cacheBytes = 0; + outputPosition = cached.outputPosition; + const restored = cached.serialized; + if (!term) { + onDone(); + return; + } + term.write(restored, () => { + if (destroyed) return; + term?.scrollToBottom(); + onDone(); + }); + return; + } + if (cached.data) { + const bytes = new Uint8Array(cached.data); + const prepared = prepareReplayTail(bytes, cached.outputPosition); + cacheChunks = [bytes]; + cacheBytes = bytes.byteLength; + outputPosition = prepared.outputPosition; + replay = replayCacheTail(prepared); + void replay.done.then(() => { + if (destroyed) return; + outputPosition = prepared.outputPosition; + term?.scrollToBottom(); + onDone(); + }); + return; + } + onDone(); + } + function connect() { if (isParked()) return; if (isSessionGone()) { markSessionGone(); return; } @@ -1111,18 +1175,14 @@ const TerminalView: Component = (props) => { if (destroyed || !readyToConnect() || isParked()) return; // Resuming to the foreground: hold the pre-warm gate through this attach. enterForegroundReplay(); - // A tab parked on reload deferred its cache replay (F3); run it now, before - // attaching, so the restored scrollback is on screen when the delta arrives. - const pending = deferredCacheReplay; + // A tab parked on reload deferred its cache restore (F3); run it now, before + // attaching, so the restored screen is up when the delta arrives. + const pending = deferredCache; if (pending) { - deferredCacheReplay = null; - setStatusText("Restoring terminal..."); + deferredCache = null; replay?.cancel(); - replay = replayCacheTail(pending); - void replay.done.then(() => { + restoreCache(pending, () => { if (destroyed || isParked()) return; - outputPosition = pending.outputPosition; - term?.scrollToBottom(); connect(); }); return; diff --git a/web/src/__tests__/serializeRestore.test.ts b/web/src/__tests__/serializeRestore.test.ts new file mode 100644 index 00000000..628e9487 --- /dev/null +++ b/web/src/__tests__/serializeRestore.test.ts @@ -0,0 +1,71 @@ +import { Terminal } from "@xterm/headless"; +import { SerializeAddon } from "@xterm/addon-serialize"; +import { describe, expect, it } from "vitest"; + +import claudeCodeTui from "../../tests/fixtures/transcripts/claude-code-tui.bin.gz?gzbytes"; +import shellPlain from "../../tests/fixtures/transcripts/shell-plain.bin.gz?gzbytes"; +import cargoBuild from "../../tests/fixtures/transcripts/cargo-build.bin.gz?gzbytes"; + +// F5 (WI-129) persists a live pane's xterm state via @xterm/addon-serialize and +// restores it in a single `term.write` on reload. The fidelity contract is: a +// serialized screen, written into a fresh terminal, reproduces the same screen +// and scrollback — so restore-then-reserialize is a fixed point. Proven here +// against the real transcript corpus (H2), through the same core VT parser the +// browser build uses (@xterm/headless shares it). + +const SERIALIZE_MAX_SCROLLBACK = 2000; + +const CORPORA = [ + { name: "claude-code-tui", bytes: claudeCodeTui }, + { name: "shell-plain", bytes: shellPlain }, + { name: "cargo-build", bytes: cargoBuild }, +] as const; + +function makeTerminal() { + const term = new Terminal({ + cols: 120, + rows: 40, + scrollback: 5000, + allowProposedApi: true, + }); + const serialize = new SerializeAddon(); + term.loadAddon(serialize); + return { term, serialize }; +} + +function write(term: Terminal, data: Uint8Array | string): Promise { + return new Promise((r) => term.write(data, r)); +} + +describe.each(CORPORA)("serialized restore fidelity: $name", ({ bytes }) => { + it("restore-then-reserialize is a fixed point (screen + scrollback preserved)", async () => { + const a = makeTerminal(); + await write(a.term, bytes); + const serialized = a.serialize.serialize({ scrollback: SERIALIZE_MAX_SCROLLBACK }); + // Note: an alt-screen program that has since exited leaves an empty normal + // buffer, so `serialized` can legitimately be "" — the fixed-point below + // still holds, and that is the property F5 relies on. + + // Restore into a fresh terminal in ONE write, then re-serialize. + const b = makeTerminal(); + await write(b.term, serialized); + const reserialized = b.serialize.serialize({ scrollback: SERIALIZE_MAX_SCROLLBACK }); + + expect(reserialized).toBe(serialized); + // The live screen (the viewport) matches too, independent of scrollback. + expect(b.serialize.serialize({ scrollback: 0 })).toBe( + a.serialize.serialize({ scrollback: 0 }), + ); + }); + + it("caps the serialized scrollback to the documented bound", async () => { + const { term, serialize } = makeTerminal(); + await write(term, bytes); + const capped = serialize.serialize({ scrollback: SERIALIZE_MAX_SCROLLBACK }); + // Never more scrollback lines than the cap (rows of viewport aside): the + // serialized string's newline count stays bounded regardless of a + // 5000-line buffer. + const lines = capped.split("\n").length; + expect(lines).toBeLessThanOrEqual(SERIALIZE_MAX_SCROLLBACK + term.rows + 2); + }); +}); diff --git a/web/src/__tests__/terminalCacheRoundtrip.test.ts b/web/src/__tests__/terminalCacheRoundtrip.test.ts new file mode 100644 index 00000000..e29954e9 --- /dev/null +++ b/web/src/__tests__/terminalCacheRoundtrip.test.ts @@ -0,0 +1,76 @@ +import "fake-indexeddb/auto"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { loadTerminalCache, saveTerminalCache } from "../terminalCache"; + +// F5 (WI-129) makes the cache hold either a serialized xterm screen (a live +// pane, the fast restore path) or raw bytes (the headless pre-warm path, or a +// pre-F5 entry). loadTerminalCache must return whichever shape was stored, and +// keep reading raw entries so the format change needs no DB version bump. + +async function clearDb(): Promise { + await new Promise((resolve) => { + const req = indexedDB.deleteDatabase("vogt-terminal-cache"); + req.onsuccess = req.onerror = req.onblocked = () => resolve(); + }); +} + +describe("terminal cache round-trip", () => { + beforeEach(clearDb); + + it("stores and returns a serialized entry (the F5 fast path)", async () => { + await saveTerminalCache("s-serialized", 4242, { serialized: "\x1b[32mrestored\x1b[0m" }); + const entry = await loadTerminalCache("s-serialized"); + expect(entry).not.toBeNull(); + expect(entry?.serialized).toBe("\x1b[32mrestored\x1b[0m"); + expect(entry?.data).toBeUndefined(); + expect(entry?.outputPosition).toBe(4242); + }); + + it("stores and returns a raw entry (pre-warm / fallback / pre-F5)", async () => { + const bytes = new TextEncoder().encode("line-a\nline-b\n"); + await saveTerminalCache("s-raw", bytes.byteLength, { data: bytes }); + const entry = await loadTerminalCache("s-raw"); + expect(entry).not.toBeNull(); + expect(entry?.serialized).toBeUndefined(); + expect(Array.from(new Uint8Array(entry!.data!))).toEqual(Array.from(bytes)); + }); + + it("ground-state-trims a raw entry whose cursor is past its length", async () => { + // outputPosition beyond the byte length marks a ring that dropped its head: + // the load path advances past the first newline so replay starts in ground + // state. + const bytes = new TextEncoder().encode("partial-escape\nclean-line\n"); + await saveTerminalCache("s-trim", 100_000, { data: bytes }); + const entry = await loadTerminalCache("s-trim"); + expect(Array.from(new Uint8Array(entry!.data!))).toEqual( + Array.from(new TextEncoder().encode("clean-line\n")), + ); + }); + + it("ignores an entry with neither serialized nor data", async () => { + // A malformed / foreign entry never crashes the restore; it reads as a cold + // start. + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open("vogt-terminal-cache", 1); + req.onupgradeneeded = () => { + req.result.createObjectStore("sessions", { keyPath: "sessionId" }) + .createIndex("updatedAt", "updatedAt"); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + await new Promise((resolve, reject) => { + const tx = db.transaction("sessions", "readwrite"); + tx.objectStore("sessions").put({ + sessionId: "s-bad", + outputPosition: 10, + updatedAt: Date.now(), + }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + expect(await loadTerminalCache("s-bad")).toBeNull(); + }); +}); diff --git a/web/src/__tests__/terminalPrewarmAttach.test.ts b/web/src/__tests__/terminalPrewarmAttach.test.ts index e4ad4c20..7fe6878a 100644 --- a/web/src/__tests__/terminalPrewarmAttach.test.ts +++ b/web/src/__tests__/terminalPrewarmAttach.test.ts @@ -98,12 +98,13 @@ describe("warmAttachOnce", () => { await expect(promise).resolves.toBe(true); expect(saveTerminalCache).toHaveBeenCalledTimes(1); - const [id, outputPosition, data] = saveTerminalCache.mock.calls[0]!; + const [id, outputPosition, cachePayload] = saveTerminalCache.mock.calls[0]!; expect(id).toBe("11111111-1111-1111-1111-111111111111"); // outputPosition is the absolute stream end, so a later open sends it as // resume_from and gets a delta instead of a cold snapshot. expect(outputPosition).toBe(1000); - expect(data.byteLength).toBe(payload.byteLength); + // Pre-warm has no xterm to serialize, so it caches raw bytes (F5). + expect(cachePayload.data.byteLength).toBe(payload.byteLength); expect(socket.closed).toBe(true); }); diff --git a/web/src/terminalCache.ts b/web/src/terminalCache.ts index 680aa9ea..0cbbe526 100644 --- a/web/src/terminalCache.ts +++ b/web/src/terminalCache.ts @@ -5,13 +5,34 @@ const MAX_CACHED_SESSIONS = 8; export const MAX_TERMINAL_CACHE_BYTES = 4 * 1024 * 1024; +/** + * A cached terminal, in one of two shapes (F5, WI-129): + * + * - `serialized`: a live pane persists its xterm screen + capped scrollback via + * `@xterm/addon-serialize`. On reload it restores in a single `term.write`, + * with no raw-byte re-parse — the fast path. + * - `data`: raw scrollback bytes. Written by the headless pre-warm path + * (`terminalPrewarm`), which has no xterm to serialize, and by a live pane as + * a fallback when serialization is unavailable. Restored by re-parsing a + * ground-state-aligned tail (the pre-F5 path), which also reads any entry a + * pre-F5 client left behind — so the format change needs no DB version bump + * or migration. + * + * Exactly one of `serialized` / `data` is present. + */ export interface TerminalCacheEntry { sessionId: string; outputPosition: number; - data: ArrayBuffer; updatedAt: number; + serialized?: string; + data?: ArrayBuffer; } +/** What a caller hands `saveTerminalCache`: a serialized screen or raw bytes. */ +export type TerminalCachePayload = + | { serialized: string } + | { data: Uint8Array }; + /** * The client cache is a byte-oriented ring (see `appendToCache` in * Terminal.tsx): once it overflows, its oldest bytes are dropped at an @@ -73,22 +94,30 @@ export async function loadTerminalCache( >, ); db.close(); + if (!result || !Number.isSafeInteger(result.outputPosition)) return null; + + // Serialized fast path: a live pane's screen, restored in one write. + if (typeof result.serialized === "string") { + return result.serialized.length > 0 ? result : null; + } + // Raw path (pre-warm entries, live-pane fallback, pre-F5 entries). Detect + // the ArrayBuffer realm-safely (`instanceof` misses a cross-realm buffer, as + // a structured clone can produce). if ( - !result || - !(result.data instanceof ArrayBuffer) || - !Number.isSafeInteger(result.outputPosition) || - result.outputPosition < result.data.byteLength + result.data != null && + Object.prototype.toString.call(result.data) === "[object ArrayBuffer]" && + result.outputPosition >= result.data.byteLength ) { - return null; - } - // Drop any partial leading escape sequence / UTF-8 char left by the ring - // trim so the tail replays from a terminal ground state. - const bytes = new Uint8Array(result.data); - const start = groundStateReplayStart(bytes, result.outputPosition); - if (start > 0) { - result.data = bytes.slice(start).buffer; + // Drop any partial leading escape sequence / UTF-8 char left by the ring + // trim so the tail replays from a terminal ground state. + const bytes = new Uint8Array(result.data); + const start = groundStateReplayStart(bytes, result.outputPosition); + if (start > 0) { + result.data = bytes.slice(start).buffer; + } + return result; } - return result; + return null; } catch { return null; } @@ -97,7 +126,7 @@ export async function loadTerminalCache( export async function saveTerminalCache( sessionId: string, outputPosition: number, - data: Uint8Array, + payload: TerminalCachePayload, ): Promise { if (typeof indexedDB === "undefined" || !Number.isSafeInteger(outputPosition)) { return; @@ -106,12 +135,11 @@ export async function saveTerminalCache( const db = await openCache(); const tx = db.transaction(STORE_NAME, "readwrite"); const store = tx.objectStore(STORE_NAME); - store.put({ - sessionId, - outputPosition, - data: data.slice().buffer, - updatedAt: Date.now(), - } satisfies TerminalCacheEntry); + const stored: TerminalCacheEntry = + "serialized" in payload + ? { sessionId, outputPosition, serialized: payload.serialized, updatedAt: Date.now() } + : { sessionId, outputPosition, data: payload.data.slice().buffer, updatedAt: Date.now() }; + store.put(stored); const entries = await requestResult( store.index("updatedAt").getAllKeys() as IDBRequest, diff --git a/web/src/terminalPrewarm.ts b/web/src/terminalPrewarm.ts index b72c72e4..049a4dc6 100644 --- a/web/src/terminalPrewarm.ts +++ b/web/src/terminalPrewarm.ts @@ -212,7 +212,9 @@ export function warmAttachOnce( return; } const data = concatChunks(chunks, bytes); - void saveTerminalCache(sessionId, endPosition, data).finally(() => + // Pre-warm has no xterm to serialize; it caches raw bytes, restored + // via the ground-state-aligned raw path (F5). + void saveTerminalCache(sessionId, endPosition, { data }).finally(() => finish(true), ); }