From ccb06262205a6ec57857390627e0e4cb56995091 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:34:39 +0000 Subject: [PATCH] feat(web): F4 dormant terminal panes keep the socket open and buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching away from a terminal parked its socket (closed it); returning reattached and re-streamed everything produced while away (bounded by F1, but still a re-stream, and the source of the switch-away flood). F4 keeps the socket open for inactive panes and stops writing to xterm — output buffers into the in-memory ring — so returning renders the buffered delta with no reattach. - terminalDormancy.ts (pure, unit-tested): a per-document budget of MAX_DORMANT_SOCKETS (4), most-recently-active kept, the rest evicted to parking; and planDormantResume() deciding delta-write vs reset+tail on return. - Terminal.tsx state machine: active | dormant (socket open, buffering, no "Suspended" banner — e.g. the unfocused half of a split) | parked (socket closed, "Suspended"). suspend() goes dormant if the budget allows and the pane is past its snapshot, else parks; activate() resumes a live dormant socket in place with a SYNCHRONOUS catch-up (flip the flag before any write so live frames queue in order — no gap), or reconnects if the socket died while dormant (mobile OS reclaim). Preserves the no-second-socket invariant and the cache-load initial-connect path; releases the slot on park and unmount. - Docs: USER_GUIDE (dormant vs Suspended) and ENGINE.md (client note: dormant panes are extra broadcast subscribers, bounded). Tests: terminalDormancy.test.ts (budget, MRU eviction, release, resume plan); the setup resets the shared registry between tests. Verification: pnpm typecheck clean; pnpm vitest 947 passed (9 new); pnpm build bundles. LIVE ACCEPTANCE STILL OWED: the switch-away-under-load symptom-1 assertion is H3's live spec (WI-124) against a real stack — not reachable from an agent session — so review this against that before merge. WI-128. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UtEFLJAhLiq4NfZCN2vuBb --- docs/ENGINE.md | 9 ++ docs/USER_GUIDE.md | 8 ++ web/src/Terminal.tsx | 113 ++++++++++++++++++++- web/src/__tests__/setup.ts | 2 + web/src/__tests__/terminalDormancy.test.ts | 89 ++++++++++++++++ web/src/terminalDormancy.ts | 106 +++++++++++++++++++ 6 files changed, 322 insertions(+), 5 deletions(-) create mode 100644 web/src/__tests__/terminalDormancy.test.ts create mode 100644 web/src/terminalDormancy.ts diff --git a/docs/ENGINE.md b/docs/ENGINE.md index 068be6b1..c5b0812a 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -680,6 +680,15 @@ because some tools send keystrokes as text. Snapshot chunks are capped at 64 KiB each. Close codes a client should recognize: `4408` no auth frame within five seconds, `4401` bad or missing auth frame, `4404` no such session. +**Client note — dormant sockets.** The web client keeps a bounded number of +*inactive* terminal panes attached (a socket held open, output buffered but not +rendered) so switching back to one needs no reattach and no re-stream. Each such +pane is a live broadcast subscriber on its session, so a browser can hold +several open attach sockets at once; the per-session broadcast fan-out and the +in-band lag resync already bound the cost, and the client caps how many panes +stay dormant. A socket a mobile OS reclaims while dormant simply reattaches +(bounded) when the reader returns to that pane. + ### Events and status - `GET /api/events` -> `text/event-stream` of `ServerEvent`, one JSON object diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a734d727..8798cdc3 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -477,6 +477,14 @@ listeners and large editor surfaces do not run in the background. Editor text and view position are retained while switching tools, and any dirty editor also activates the browser/PWA exit confirmation until it is saved. +A terminal you switch away from stays **dormant**: its connection is held open +and output keeps arriving in the background, so switching back shows what ran +while away with nothing to re-stream — the unfocused half of a split behaves the +same and keeps its screen. Only a handful of terminals stay dormant at once; the +rest, and any terminal a phone's OS suspends, show **Suspended** and reattach +the moment you return to them, replaying a bounded tail of recent output rather +than the whole history. + **Saving is guarded against clobbering.** The editor remembers the version it last read; a save that would overwrite a file changed on disk since then is refused and shown inline as **File changed on disk** with **Overwrite** and diff --git a/web/src/Terminal.tsx b/web/src/Terminal.tsx index 5a997c18..907981af 100644 --- a/web/src/Terminal.tsx +++ b/web/src/Terminal.tsx @@ -26,12 +26,18 @@ import { import { createReplayQueue, prepareReplayTail, + REPLAY_TAIL_MAX_BYTES, scheduleReplay, shouldDeferCacheReplay, snapshotStartPosition, type ReplayHandle, type ReplayTail, } from "./terminalReplay"; +import { + acquireDormant, + planDormantResume, + releaseDormant, +} from "./terminalDormancy"; // 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 @@ -173,6 +179,11 @@ const TerminalView: Component = (props) => { let hiddenTimer: ReturnType | null = null; const [hiddenParked, setHiddenParked] = createSignal(false); let socketParked = false; + // F4: a dormant pane keeps its socket OPEN but stops writing to xterm, + // buffering output into the ring. `dormantAtPosition` is the stream position + // when it went dormant, so the return path knows how much to catch up. + let dormant = false; + let dormantAtPosition: number | undefined; let pingId = 0; let destroyed = false; let sessionGone = false; @@ -1109,6 +1120,12 @@ const TerminalView: Component = (props) => { watchdog.noteOutput(Date.now()); outputPosition = (outputPosition ?? 0) + buf.byteLength; appendToCache(buf); + if (dormant) { + // F4: buffer only — the socket stays open but xterm is frozen until this + // pane is activated, when the buffered delta is written in one go. + scheduleCachePersist(); + return; + } if (inSnapshot) { // Snapshot and immediately-following live frames share one ordered // queue until the snapshot parser has drained. @@ -1153,6 +1170,9 @@ const TerminalView: Component = (props) => { function parkSocket() { if (socketParked) return; socketParked = true; + dormant = false; + dormantAtPosition = undefined; + releaseDormant(props.sessionId); // A parked pane is no longer foreground; release the pre-warm gate. exitForegroundReplay(); persistCache(); @@ -1169,10 +1189,92 @@ const TerminalView: Component = (props) => { ws = null; } - function resumeSocket() { - if (!socketParked) return; - socketParked = false; + // F4: keep the socket open but stop rendering; output buffers into the ring. + // No "Suspended" banner — the pane keeps its last screen (e.g. the unfocused + // half of a split). The watchdog is paused (it gates on isParked); a socket + // that dies while dormant is detected on return and reconnected. + function goDormant() { + if (dormant) return; + dormant = true; + dormantAtPosition = outputPosition; + exitForegroundReplay(); + replay?.cancel(); + clearCountdown(); + setReconnectView(null); + setStatusText(null); + persistCache(); + } + + // Evicted from the dormant budget by a more-recently-active pane: fall back to + // parking (close the socket). Deferred a microtask so it never runs re-entrant + // inside another pane's reactive effect. + function evictFromDormant() { + queueMicrotask(() => { + if (!dormant || destroyed) return; + parkSocket(); + }); + } + + // Return from dormant with the socket still alive: write what buffered while + // away in one synchronous step, so live frames that follow render in order. + function resumeFromDormant() { + releaseDormant(props.sessionId); + const start = dormantAtPosition; + dormantAtPosition = undefined; + setStatusText(null); + const buffered = cachedBytes(); + const end = outputPosition ?? buffered.byteLength; + const since = start !== undefined ? end - start : 0; + // Flip synchronously BEFORE any xterm write: subsequent live frames now + // render, queued after the catch-up write, so the buffer never gaps. + dormant = false; + const plan = planDormantResume(since, buffered.byteLength, REPLAY_TAIL_MAX_BYTES); + if (plan.kind === "delta") { + term?.write(buffered.subarray(buffered.byteLength - plan.bytes)); + term?.scrollToBottom(); + } else if (plan.kind === "reset-tail") { + term?.reset(); + term?.write(prepareReplayTail(buffered, end).data); + term?.scrollToBottom(); + } + sendResize(); + } + + // The pane became inactive: keep its socket open (dormant) if the per-document + // budget allows and it is safely past its snapshot, otherwise park it. + function suspend() { + if (destroyed || dormant || socketParked) return; + const canDormant = + !!ws && + ws.readyState === WebSocket.OPEN && + !inSnapshot && + acquireDormant(props.sessionId, Date.now(), evictFromDormant); + if (canDormant) goDormant(); + else parkSocket(); + } + + // The pane became active: resume a live dormant socket in place, else + // (re)connect from a parked state. The initial connect is NOT driven here — + // it comes from the cache-load path in onMount — so an already-active pane and + // a fresh mount both no-op. + function activate() { if (destroyed || !readyToConnect() || isParked()) return; + if (dormant) { + if (ws && ws.readyState === WebSocket.OPEN) { + resumeFromDormant(); + return; + } + // The socket died while dormant (e.g. a mobile OS reclaimed it); fall + // through to a fresh reconnect. + dormant = false; + dormantAtPosition = undefined; + releaseDormant(props.sessionId); + } else if (!socketParked) { + // Active already, or the initial mount (the cache-load path connects): the + // transition effect has nothing to do. + return; + } + socketParked = false; // Resuming to the foreground: hold the pre-warm gate through this attach. enterForegroundReplay(); // A tab parked on reload deferred its cache restore (F3); run it now, before @@ -1193,12 +1295,13 @@ const TerminalView: Component = (props) => { createEffect(() => { if (!readyToConnect()) return; - if (isParked()) parkSocket(); - else resumeSocket(); + if (isParked()) suspend(); + else activate(); }); onCleanup(() => { destroyed = true; + releaseDormant(props.sessionId); exitForegroundReplay(); replay?.cancel(); pendingInput = []; diff --git a/web/src/__tests__/setup.ts b/web/src/__tests__/setup.ts index 96583cbe..02f53ef1 100644 --- a/web/src/__tests__/setup.ts +++ b/web/src/__tests__/setup.ts @@ -16,6 +16,7 @@ import { resetFileTreeState } from "../fileTreeState"; import { invalidate } from "../swr"; import { clearTaxonomyCache } from "../taxonomyCache"; import { invalidateAssistantSnapshot } from "../assistantCache"; +import { resetDormancyForTest } from "../terminalDormancy"; class StubResizeObserver implements ResizeObserver { observe(): void {} @@ -72,4 +73,5 @@ afterEach(() => { resetFileTreeState(); invalidateAssistantSnapshot(); clearTaxonomyCache(); + resetDormancyForTest(); }); diff --git a/web/src/__tests__/terminalDormancy.test.ts b/web/src/__tests__/terminalDormancy.test.ts new file mode 100644 index 00000000..21cb24d2 --- /dev/null +++ b/web/src/__tests__/terminalDormancy.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + MAX_DORMANT_SOCKETS, + acquireDormant, + dormantCount, + planDormantResume, + releaseDormant, + resetDormancyForTest, +} from "../terminalDormancy"; + +describe("dormant socket budget", () => { + beforeEach(resetDormancyForTest); + + it("grants up to the budget without evicting", () => { + for (let i = 0; i < MAX_DORMANT_SOCKETS; i++) { + expect(acquireDormant(`p${i}`, i, () => {})).toBe(true); + } + expect(dormantCount()).toBe(MAX_DORMANT_SOCKETS); + }); + + it("evicts the least-recently-active pane for a newer one", () => { + const evicted: string[] = []; + for (let i = 0; i < MAX_DORMANT_SOCKETS; i++) { + acquireDormant(`p${i}`, i, () => evicted.push(`p${i}`)); + } + // p0 is the oldest (at=0). A newer pane evicts it. + expect(acquireDormant("new", 100, () => {})).toBe(true); + expect(evicted).toEqual(["p0"]); + expect(dormantCount()).toBe(MAX_DORMANT_SOCKETS); + }); + + it("denies (parks) a pane older than every dormant one", () => { + for (let i = 0; i < MAX_DORMANT_SOCKETS; i++) { + acquireDormant(`p${i}`, 10 + i, () => { + throw new Error("must not evict for an older pane"); + }); + } + expect(acquireDormant("stale", 1, () => {})).toBe(false); + expect(dormantCount()).toBe(MAX_DORMANT_SOCKETS); + }); + + it("re-acquiring refreshes recency instead of evicting", () => { + const evict = vi.fn(); + for (let i = 0; i < MAX_DORMANT_SOCKETS; i++) acquireDormant(`p${i}`, i, evict); + // Refresh p0 to be the newest; now p1 (at=1) is the oldest. + expect(acquireDormant("p0", 100, evict)).toBe(true); + expect(evict).not.toHaveBeenCalled(); + const evicted: string[] = []; + acquireDormant("new", 200, () => {}); + // The victim is p1 now, not p0. + for (let i = 0; i < MAX_DORMANT_SOCKETS; i++) { + acquireDormant(`probe${i}`, 300 + i, () => evicted.push("x")); + } + // (Just assert the count stays capped; recency correctness is the refresh above.) + expect(dormantCount()).toBe(MAX_DORMANT_SOCKETS); + }); + + it("releasing frees a slot so the next pane is granted without eviction", () => { + const evict = vi.fn(); + for (let i = 0; i < MAX_DORMANT_SOCKETS; i++) acquireDormant(`p${i}`, i, evict); + releaseDormant("p2"); + expect(dormantCount()).toBe(MAX_DORMANT_SOCKETS - 1); + expect(acquireDormant("late", 5, () => {})).toBe(true); + expect(evict).not.toHaveBeenCalled(); + }); +}); + +describe("planDormantResume", () => { + const BUDGET = 1024 * 1024; + + it("is a no-op when nothing arrived", () => { + expect(planDormantResume(0, 5000, BUDGET)).toEqual({ kind: "noop" }); + expect(planDormantResume(-1, 5000, BUDGET)).toEqual({ kind: "noop" }); + }); + + it("writes the exact buffered delta when it fits the budget and is retained", () => { + expect(planDormantResume(4096, 100_000, BUDGET)).toEqual({ kind: "delta", bytes: 4096 }); + }); + + it("resets and replays a tail when the delta exceeds the budget", () => { + expect(planDormantResume(BUDGET + 1, 10 * BUDGET, BUDGET)).toEqual({ kind: "reset-tail" }); + }); + + it("resets and replays a tail when the ring trimmed below the delta", () => { + // 500 KiB arrived but the ring only retained 100 KiB of it. + expect(planDormantResume(500 * 1024, 100 * 1024, BUDGET)).toEqual({ kind: "reset-tail" }); + }); +}); diff --git a/web/src/terminalDormancy.ts b/web/src/terminalDormancy.ts new file mode 100644 index 00000000..3179ad4e --- /dev/null +++ b/web/src/terminalDormancy.ts @@ -0,0 +1,106 @@ +// Dormant-socket budget for terminal panes (F4, WI-128). +// +// A pane that is no longer active (another tab is showing, or it is the +// unfocused half of a split) used to *park*: close its WebSocket and, on +// return, reattach with `resume_from` and re-stream everything produced while +// away (bounded by F1, but still a re-stream). A *dormant* pane instead keeps +// its socket open and simply stops writing to xterm — it buffers output into +// its in-memory ring — so returning to it renders the buffered delta with no +// reattach and no re-stream. +// +// Open sockets are not free (each is a broadcast subscriber on the engine), so +// only the most-recently-active `MAX_DORMANT_SOCKETS` panes stay dormant; the +// rest fall back to parking and reattach via F1's bounded path. This module is +// the shared, per-document budget: pure book-keeping plus the eviction policy, +// with no reference to xterm or a socket, so it is unit-testable on its own. + +/** How many panes may hold an open-but-idle socket at once, per document. */ +export const MAX_DORMANT_SOCKETS = 4; + +interface DormantEntry { + /** Last time this pane was active; the eviction key (most-recent wins). */ + at: number; + /** Close this pane's socket and fall back to parking. */ + evict: () => void; +} + +const dormant = new Map(); + +/** + * Ask to keep `id`'s socket open (dormant) instead of parking it. Returns true + * if granted. When the budget is full, the least-recently-active dormant pane + * is evicted (its `evict` runs) in favour of a newer one; a pane older than + * every current dormant pane is denied and should park. + * + * Re-acquiring for a pane already dormant just refreshes its recency. + */ +export function acquireDormant(id: string, at: number, evict: () => void): boolean { + const existing = dormant.get(id); + if (existing) { + existing.at = at; + existing.evict = evict; + return true; + } + if (dormant.size < MAX_DORMANT_SOCKETS) { + dormant.set(id, { at, evict }); + return true; + } + // Full: evict the least-recently-active entry, but only for a newer pane. + let lruId: string | null = null; + let lruAt = Infinity; + for (const [key, entry] of dormant) { + if (entry.at < lruAt) { + lruAt = entry.at; + lruId = key; + } + } + if (lruId === null || at <= lruAt) return false; + const victim = dormant.get(lruId)!; + dormant.delete(lruId); + dormant.set(id, { at, evict }); + victim.evict(); + return true; +} + +/** This pane is active again (or gone): give up its dormant slot. */ +export function releaseDormant(id: string): void { + dormant.delete(id); +} + +/** Current number of dormant sockets (tests, diagnostics). */ +export function dormantCount(): number { + return dormant.size; +} + +/** Forget all dormant slots. For tests between cases. */ +export function resetDormancyForTest(): void { + dormant.clear(); +} + +/** How a returning dormant pane should catch its xterm up. */ +export type DormantResume = + | { kind: "noop" } + | { kind: "delta"; bytes: number } + | { kind: "reset-tail" }; + +/** + * Decide how to render what arrived while a pane was dormant. + * + * - `noop`: nothing arrived. + * - `delta`: the buffered bytes fit the replay budget and are still retained in + * the ring, so write exactly them onto the frozen screen — no reset, no flash. + * - `reset-tail`: too much was buffered (or the ring trimmed below it), so the + * screen is reset and a bounded ground-state tail replayed instead. + * + * `since` is `outputPosition - dormantStart`; `retained` is the current + * in-memory ring length; `budget` is the per-pane replay cap. + */ +export function planDormantResume( + since: number, + retained: number, + budget: number, +): DormantResume { + if (since <= 0) return { kind: "noop" }; + if (since <= budget && since <= retained) return { kind: "delta", bytes: since }; + return { kind: "reset-tail" }; +}