From 402b52363fd56c177c1d433fc5ed566e8bf157a5 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:35:21 +0000 Subject: [PATCH] fix(web): F3 defer parked tabs' cache replay to first activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On reload every retained terminal tab replayed its cached tail (up to 1 MiB) immediately, through the single global replay FIFO, round-robin with the active pane. With N retained tabs the pane the user is actually looking at got 1/N of the parser — the reload-is-slow half of the terminal budget bug. A parked pane (a retained tab that is not active, or the unfocused half of a split) now keeps its prepared cache tail in memory and replays it lazily on first activation in resumeSocket(), before connect(), instead of scheduling it into the shared FIFO on load. The active pane still replays immediately, so its restore is no longer time-sliced against tabs the user cannot see. The cache position is adopted at load time so a warm reattach resumes correctly even if the deferred replay is later cancelled by a re-park. The decision is a pure helper, shouldDeferCacheReplay(parked, hasCachedBytes), unit-tested here; the six-cached-tabs end-to-end assertion lands with H3's mocked Playwright spec (WI-124). Verification: pnpm typecheck clean; pnpm vitest 909 passed. WI-127. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UtEFLJAhLiq4NfZCN2vuBb --- web/src/Terminal.tsx | 69 ++++++++++++++----- .../__tests__/shouldDeferCacheReplay.test.ts | 23 +++++++ web/src/terminalReplay.ts | 17 +++++ 3 files changed, 93 insertions(+), 16 deletions(-) create mode 100644 web/src/__tests__/shouldDeferCacheReplay.test.ts diff --git a/web/src/Terminal.tsx b/web/src/Terminal.tsx index dd6b1c14..90da21bf 100644 --- a/web/src/Terminal.tsx +++ b/web/src/Terminal.tsx @@ -25,8 +25,10 @@ import { createReplayQueue, prepareReplayTail, scheduleReplay, + shouldDeferCacheReplay, snapshotStartPosition, type ReplayHandle, + type ReplayTail, } from "./terminalReplay"; import { beginForegroundReplay } from "./terminalPrewarm"; import { @@ -142,6 +144,10 @@ const TerminalView: Component = (props) => { 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; 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. @@ -817,23 +823,18 @@ const TerminalView: Component = (props) => { 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 (shouldDeferCacheReplay(isParked(), true)) { + // Parked on reload: keep the tail in memory and replay it on the first + // activation, not into the shared FIFO with the active pane (F3). + deferredCacheReplay = prepared; + setReadyToConnect(true); + return; + } setStatusText("Restoring terminal..."); - replay = scheduleReplay( - props.sessionId, - [prepared.data], - (chunk, done) => { - if (!term) { - done(); - return; - } - term.write(chunk, done); - }, - { - kind: "cache", - droppedBytes: prepared.droppedBytes, - droppedLines: prepared.droppedLines, - }, - ); + replay = replayCacheTail(prepared); void replay.done.then(() => { if (destroyed) return; outputPosition = prepared.outputPosition; @@ -896,6 +897,26 @@ const TerminalView: Component = (props) => { connect(); } + /** Replay a prepared cache tail into xterm through the shared replay queue. */ + function replayCacheTail(prepared: ReplayTail): ReplayHandle { + return scheduleReplay( + props.sessionId, + [prepared.data], + (chunk, done) => { + if (!term) { + done(); + return; + } + term.write(chunk, done); + }, + { + kind: "cache", + droppedBytes: prepared.droppedBytes, + droppedLines: prepared.droppedLines, + }, + ); + } + function connect() { if (isParked()) return; if (isSessionGone()) { markSessionGone(); return; } @@ -1083,6 +1104,22 @@ 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; + if (pending) { + deferredCacheReplay = null; + setStatusText("Restoring terminal..."); + replay?.cancel(); + replay = replayCacheTail(pending); + void replay.done.then(() => { + if (destroyed || isParked()) return; + outputPosition = pending.outputPosition; + term?.scrollToBottom(); + connect(); + }); + return; + } setStatusText("Loading terminal..."); connect(); } diff --git a/web/src/__tests__/shouldDeferCacheReplay.test.ts b/web/src/__tests__/shouldDeferCacheReplay.test.ts new file mode 100644 index 00000000..1e5a1a90 --- /dev/null +++ b/web/src/__tests__/shouldDeferCacheReplay.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { shouldDeferCacheReplay } from "../terminalReplay"; + +describe("shouldDeferCacheReplay", () => { + it("defers only a parked pane that has cached bytes", () => { + // A retained-but-parked tab on reload keeps its cache in memory and replays + // it lazily on activation, not into the shared FIFO with the active pane. + expect(shouldDeferCacheReplay(true, true)).toBe(true); + }); + + it("does not defer the active pane", () => { + // The pane the user is looking at replays its cache immediately. + expect(shouldDeferCacheReplay(false, true)).toBe(false); + }); + + it("does not defer when there is nothing cached", () => { + // No cache means nothing to replay: a parked pane with an empty cache just + // waits to connect, it does not enter the deferred path. + expect(shouldDeferCacheReplay(true, false)).toBe(false); + expect(shouldDeferCacheReplay(false, false)).toBe(false); + }); +}); diff --git a/web/src/terminalReplay.ts b/web/src/terminalReplay.ts index 31552963..fe1c56e5 100644 --- a/web/src/terminalReplay.ts +++ b/web/src/terminalReplay.ts @@ -11,6 +11,23 @@ export const REPLAY_TAIL_MAX_BYTES = 1 * 1024 * 1024; /** Keep each xterm parser turn bounded and aligned with the server frame size. */ export const REPLAY_SLICE_BYTES = 64 * 1024; +/** + * On reload, should a pane defer its cached-scrollback replay? + * + * A parked pane — a retained tab that is not the active one, or the unfocused + * half of a split — must NOT replay its cache into the shared replay FIFO on + * reload: N retained tabs replaying at once time-slice the single parser N ways + * and starve the pane the user is actually looking at. A parked pane keeps its + * cache in memory and replays lazily on first activation (`resumeSocket`) + * instead. An active pane with a cache replays immediately. + */ +export function shouldDeferCacheReplay( + parked: boolean, + hasCachedBytes: boolean, +): boolean { + return parked && hasCachedBytes; +} + /** * The absolute output position at the START of a snapshot payload: the byte * offset the first snapshot byte sits at. The server reports the position at