Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/ENGINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 108 additions & 5 deletions web/src/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -173,6 +179,11 @@ const TerminalView: Component<Props> = (props) => {
let hiddenTimer: ReturnType<typeof setTimeout> | 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;
Expand Down Expand Up @@ -1109,6 +1120,12 @@ const TerminalView: Component<Props> = (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.
Expand Down Expand Up @@ -1153,6 +1170,9 @@ const TerminalView: Component<Props> = (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();
Expand All @@ -1169,10 +1189,92 @@ const TerminalView: Component<Props> = (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
Expand All @@ -1193,12 +1295,13 @@ const TerminalView: Component<Props> = (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 = [];
Expand Down
2 changes: 2 additions & 0 deletions web/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -72,4 +73,5 @@ afterEach(() => {
resetFileTreeState();
invalidateAssistantSnapshot();
clearTaxonomyCache();
resetDormancyForTest();
});
89 changes: 89 additions & 0 deletions web/src/__tests__/terminalDormancy.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
Loading
Loading