Merge the final checkup's tile fixes into the 1.40.0 release

From the read-only final review of the release, adversarially verified:
- a server clear frame refreshes a tile instead of blanking it, so a Claude session Run into the grid keeps its banner and transcript
- a tile refresh fetches first and resets in-stream, so the screen never goes blank while it waits
- Redraw on a tile sends the forced resize and reports only what it sent
- each tile caps its live-output backlog (4 MiB) and recovers dropped output with one bounded refresh
- the tile grid plan is marked merged

Gate green on the branch: static checks, 41 tile and split unit files (836 tests) and 7 browser files (36 tests).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 09:52:42 +02:00
10 changed files with 1081 additions and 164 deletions
File diff suppressed because one or more lines are too long
+9 -5
View File
@@ -1,8 +1,8 @@
# Tile Grid: Design Spec # Tile Grid: Design Spec
**Status**: PR 1 (tile foundation) implemented on `feat/terminal-tile`; PR 2 (the grid) implemented on `feat/tile-grid`, both local only. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays. **Status**: Merged for the 1.40.0 release as #560 (the TerminalTile foundation) and #561 (the grid). Where the "As built" section below differs from this spec, As built is authoritative. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays.
**Author**: Claude (planning session with the maintainer), 2026-10-06 **Author**: Claude (planning session with the maintainer), 2026-10-06
**Branches**: PR 1 `feat/terminal-tile`, PR 2 `feat/tile-grid` stacked on it (worktrees `claudeman-tiles`, `claudeman-tilegrid`) **Branches**: developed as PR 1 `feat/terminal-tile` and PR 2 `feat/tile-grid` stacked on it, both merged
**Scope**: v1 is fully designed here; follow-ups are named at the end and explicitly deferred. **Scope**: v1 is fully designed here; follow-ups are named at the end and explicitly deferred.
## As built: where PR 2 differs from this spec ## As built: where PR 2 differs from this spec
@@ -30,8 +30,11 @@ or settled a question the spec left open. The invariants as built are in
- **Zoom follows tmux.** Moving focus to another tile restores the grid; an automatic zoom - **Zoom follows tmux.** Moving focus to another tile restores the grid; an automatic zoom
(window too small for the minimum tile) follows focus instead. (window too small for the minimum tile) follows focus instead.
- **Tile loads are bounded** (`boundedLoad`), carry a fetch deadline covering the body (Pane - **Tile loads are bounded** (`boundedLoad`), carry a fetch deadline covering the body (Pane
B too), and a refresh clears the screen at its turn in the queue, so a waiting tile keeps B too), and a refresh fetches at its turn in the queue: the tile keeps its last frame
its last frame. through its wait and its own round trip, and is reset with the queued in-stream `\x1bc`
only once the capture is in hand, right before the replay (never xterm's `clear()` before
the fetch). A failed, aborted or empty fetch writes nothing and resets nothing: the tile
keeps its last frame and every held live frame.
- **4009 lands on the Attach overlay**, and 4003/4004/4010 remove the tile. - **4009 lands on the Attach overlay**, and 4003/4004/4010 remove the tile.
- **Tile header buttons are 26px targets with 16 to 19px glyphs** (owner feedback: the - **Tile header buttons are 26px targets with 16 to 19px glyphs** (owner feedback: the
first build's 12px glyphs read as tiny next to the name), the size of the app header's own first build's 12px glyphs read as tiny next to the name), the size of the app header's own
@@ -408,7 +411,8 @@ against the live list without rebuilding tiles that are still alive.
### Gating ### Gating
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the - Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
settings PUT, NOT in `SettingsUpdateSchema`), default OFF. Independent of settings PUT, NOT in `SettingsUpdateSchema`), default ON on desktop and OFF on
handhelds (specified OFF; superseded, see "As built"). Independent of
`showSplitButton`, which is unchanged; a desk can show both buttons. `showSplitButton`, which is unchanged; a desk can show both buttons.
- Hidden below 1180 px by both a JS width check with a `matchMedia` listener and - Hidden below 1180 px by both a JS width check with a `matchMedia` listener and
a CSS `@media (max-width: 1179px)` backstop, exactly like the split button. a CSS `@media (max-width: 1179px)` backstop, exactly like the split button.
+294 -74
View File
@@ -49,7 +49,8 @@
*/ */
(function (global) { (function (global) {
// How long a scroll-to-top history pull may hold this pane's live output. // How long a load may hold this pane's live output while it reads a bounded
// body: a scroll-to-top history pull, or a refresh of a bounded window.
const HISTORY_PULL_TIMEOUT_MS = 10000; const HISTORY_PULL_TIMEOUT_MS = 10000;
// How much of a replay is queued in xterm at once: a 1 MiB load goes in one // How much of a replay is queued in xterm at once: a 1 MiB load goes in one
@@ -158,7 +159,19 @@
this._historyPullAt = 0; this._historyPullAt = 0;
this._historyPullUseless = false; this._historyPullUseless = false;
this._liveQueue = null; this._liveQueue = null;
this._liveQueueBytes = 0;
this._markerOwed = false; this._markerOwed = false;
// Live-output flow control (_writeLive, TerminalTile.LIVE_BACKLOG_BUDGET):
// code units written into this xterm and not yet parsed (each write's
// callback counts its own back down, unless a reset bumped `_liveEpoch`
// since), whether output was dropped and not yet recovered, when the last
// frame was dropped, and the debounced, bounded recovery refresh.
this._liveInFlight = 0;
this._liveEpoch = 0;
this._liveDropped = false;
this._liveDropAt = 0;
this._dropRecoveryTimer = null;
this._dropRecoveryAttempt = 0;
this._onWheel = null; this._onWheel = null;
// `{ ws, lastRecvAt }`, registered with the app's input-socket map while // `{ ws, lastRecvAt }`, registered with the app's input-socket map while
// this pane's socket is open, so the exactly-once input queue delivers this // this pane's socket is open, so the exactly-once input queue delivers this
@@ -506,7 +519,12 @@
this._lastSentDims = null; this._lastSentDims = null;
this._registerInputSocket(); this._registerInputSocket();
this._sendResize(); this._sendResize();
if (reconnected) this._refreshBuffer(); if (reconnected) {
// The gap already cost output, and the refresh below replaces the
// screen, so live-output accounting starts over with it.
this._resetLiveFlow();
this._refreshBuffer();
}
} }
// Opens a replacement socket now instead of waiting out the backoff (for an // Opens a replacement socket now instead of waiting out the backoff (for an
@@ -666,10 +684,11 @@
// a closed socket. Called from each load's own finally, just before // a closed socket. Called from each load's own finally, just before
// _endBufferLoad() starts any trailing refresh. // _endBufferLoad() starts any trailing refresh.
_stampMarkerIfOwed() { _stampMarkerIfOwed() {
// A trailing refresh is about to clear() synchronously, while xterm parses // A trailing refresh is about to run, and it settles the marker itself:
// a write() on a later tick: a marker written here would land in the // a replay's queued `\x1bc` would wipe one written here (it re-owes the
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy. // marker on a closed socket and stamps it below the replay), and a refresh
// The refresh re-owes the marker on a closed socket and stamps it itself. // that writes nothing stamps the one still owed. Stamped here as well,
// there would be two marker writes for one close.
if (this._bufferRefreshPending && !this._destroyed) return; if (this._bufferRefreshPending && !this._destroyed) return;
const owed = this._markerOwed; const owed = this._markerOwed;
this._markerOwed = false; this._markerOwed = false;
@@ -683,11 +702,12 @@
} }
// Fetches and writes the session's current scrollback. Used both by // Fetches and writes the session's current scrollback. Used both by
// connect() (initial load) and by the `{t:'r'}` server-refresh frame // connect() (initial load) and by the refresh frames (`{t:'r'}`, `{t:'c'}`)
// (above). The primary pane's own _onSessionNeedsRefresh (app.js) is // and a reconnect (_refreshBuffer). The primary pane's own
// scoped to `this.activeSessionId` and clears/rewrites the primary // _onSessionNeedsRefresh (app.js) is scoped to `this.activeSessionId` and
// terminal, neither of which applies to this independent pane, so this is // rewrites the primary terminal, neither of which applies to this
// a standalone equivalent rather than a call into it. // independent pane, so this is a standalone equivalent rather than a call
// into it, in the primary's order (below).
// //
// Mirrors the primary pane's own mode check (app.js's selectSession / // Mirrors the primary pane's own mode check (app.js's selectSession /
// _onSessionNeedsRefresh): a shell session can retain hundreds of // _onSessionNeedsRefresh): a shell session can retain hundreds of
@@ -699,6 +719,21 @@
// wrapper (constants.js), which already prefixes CodemanBase, unlike the // wrapper (constants.js), which already prefixes CodemanBase, unlike the
// raw WebSocket URL above, which does not. // raw WebSocket URL above, which does not.
// //
// A refresh replaces what the pane shows, in the primary pane's order
// (_onSessionNeedsRefresh, _resetTerminalForReplay): fetch FIRST, so the
// pane keeps its last frame through the round trip (and through a grid
// tile's wait in the load queue); then the queued in-stream `\x1bc`, never
// xterm's clear(): clear() is synchronous while write() is parsed on a later
// tick, so live bytes still queued would land after it and fuse into the
// snapshot, and it keeps the cursor's row, column, SGR and margins, so the
// capture (raw rows, no home) started wherever the cursor sat. Live frames
// from the response onward are held (`_liveQueue`, the primary's
// _finishBufferLoad `since` rule, as _pullHistory() holds them) and only
// those that arrived after it are written behind the replay. A failed,
// aborted or empty fetch writes nothing and resets nothing: the pane keeps
// its last frame and every held frame. The initial load needs none of
// this: it runs before the pane has a socket, onto a fresh xterm.
//
// Single-flight: the flag is held across the fetch AND the chunked write // Single-flight: the flag is held across the fetch AND the chunked write
// (writeChunked resolves after its last chunk), so two replays can never // (writeChunked resolves after its last chunk), so two replays can never
// interleave their chunks into one terminal. A second call while one is // interleave their chunks into one terminal. A second call while one is
@@ -709,42 +744,60 @@
this._bufferLoading = true; this._bufferLoading = true;
await this._runLoad(refresh ? 'refresh' : 'initial', async () => { await this._runLoad(refresh ? 'refresh' : 'initial', async () => {
this._loadRunning = true; this._loadRunning = true;
let replayed = false;
let capturedAt = 0;
// A deadline covering the body as well as the headers (the primary
// pane's budgets, CodemanFetchDeadline): a capture that never answers
// would otherwise hold this pane's single-flight flag, and in the grid
// the one load queue every tile waits behind, forever. Re-armed once a
// refresh's headers land (below), so one signal carries both budgets.
const controller = global.AbortController ? new global.AbortController() : null;
let abortTimer = null;
const armDeadline = (ms) => {
if (!controller) return;
clearTimeout(abortTimer);
abortTimer = setTimeout(() => controller.abort(), ms);
};
try { try {
if (this._destroyed) return; if (this._destroyed) return;
if (refresh) {
// Cleared at the load's turn, not when it was asked for: a grid tile
// waiting in the queue keeps its last frame instead of sitting blank.
this.terminal?.clear();
this._overflowRows = 0;
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue
// a trailing refresh behind a pull that the socket's close then
// interrupts), so a refresh on a closed socket owes it back once its
// replay is written.
if (this._wsClosed) this._markerOwed = true;
}
const shell = this.sessionMode === 'shell'; const shell = this.sessionMode === 'shell';
let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1'; let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`; if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
// A deadline covering the body as well as the headers (the primary
// pane's budgets, CodemanFetchDeadline): a capture that never answers
// would otherwise hold this pane's single-flight flag, and in the grid
// the one load queue every tile waits behind, forever.
const controller = global.AbortController ? new global.AbortController() : null;
this._loadAbort = controller; this._loadAbort = controller;
const budget = global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000; armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000);
const timer = controller ? setTimeout(() => controller.abort(), budget) : null;
let payload; let payload;
try { try {
const res = await fetch( const res = await fetch(
`/api/sessions/${this.sessionId}/terminal?${query}`, `/api/sessions/${this.sessionId}/terminal?${query}`,
controller ? { signal: controller.signal } : undefined controller ? { signal: controller.signal } : undefined
); );
if (refresh) {
// The response's arrival stands in for the instant tmux took the
// capture (see _pullHistory()). Frames from here on are news the
// capture cannot hold, so they wait for the replay. From now on
// live output IS held, so a bounded window's body (at most
// TERMINAL_TAIL_SIZE) gets the pull's short budget; an unbounded
// capture (the split's Pane B, up to 32 MB) keeps the request's.
capturedAt = performance.now();
this._openLiveQueue();
if (shell || this.boundedLoad) armDeadline(HISTORY_PULL_TIMEOUT_MS);
}
payload = (await res.json())?.data ?? {}; payload = (await res.json())?.data ?? {};
} finally { } finally {
clearTimeout(timer); clearTimeout(abortTimer);
this._loadAbort = null; this._loadAbort = null;
} }
if (payload.terminalBuffer && this.terminal) { if (payload.terminalBuffer && this.terminal && !this._destroyed) {
if (refresh) {
this.terminal.write('\x1bc');
this._overflowRows = 0; // the reset leaves nothing above the screen
replayed = true;
// The reset wipes a "disconnected" marker (a `{t:'r'}` frame can
// queue a trailing refresh behind a pull that the socket's close
// then interrupts), so a refresh on a closed socket owes it back
// once its replay is written.
if (this._wsClosed) this._markerOwed = true;
}
await writeChunked( await writeChunked(
this.terminal, this.terminal,
payload.terminalBuffer, payload.terminalBuffer,
@@ -756,7 +809,15 @@
} catch { } catch {
/* Best-effort: live output still arrives once the socket connects. */ /* Best-effort: live output still arrives once the socket connects. */
} finally { } finally {
clearTimeout(abortTimer);
this._loadAbort = null;
this._loadRunning = false; this._loadRunning = false;
// Before the flush: a refresh that recovered dropped output lets its
// held frames through.
if (refresh) this._settleDropRecovery({ replayed, capturedAt, timedOut: !!controller?.signal?.aborted });
// Held frames before the marker, so the marker stays the last thing on
// screen (see _pullHistory()).
this._flushLiveQueue(replayed ? capturedAt : 0);
this._stampMarkerIfOwed(); this._stampMarkerIfOwed();
this._endBufferLoad(); this._endBufferLoad();
} }
@@ -800,29 +861,168 @@
} }
} }
// Live terminal output. Written straight through, except while a history // Live terminal output. Written straight through, except while a refresh or
// pull is replaying: a capture is current only up to the instant tmux took // a history pull is replaying: a capture is current only up to the instant
// it, so a frame arriving mid-replay is held with its arrival time and // tmux took it, so a frame arriving mid-replay is held with its arrival time
// replayed behind the snapshot by _pullHistory() (the primary pane's // and written behind the snapshot by that load's _flushLiveQueue() (the
// _finishBufferLoad `since` rule), never written underneath it. // primary pane's _finishBufferLoad `since` rule), never underneath it.
// Held frames count against the same budget as unparsed ones: a pull or a
// refresh holds output for up to its body budget, and a flood meanwhile
// must not grow the queue without bound either.
_onLiveOutput(data) { _onLiveOutput(data) {
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data }); if (!data) return;
else this.terminal?.write(data); if (this._liveQueue) {
if (this._liveQueueBytes + data.length > TerminalTile.LIVE_BACKLOG_BUDGET) {
this._noteLiveDrop();
return;
}
this._liveQueueBytes += data.length;
this._liveQueue.push({ at: performance.now(), data });
return;
}
this._writeLive(data);
} }
// The server's `{t:'c'}` clear frame takes the same route as output, for the _openLiveQueue() {
// same reason: clearing straight away, mid-replay, would wipe the half-written this._liveQueue = [];
// snapshot and leave _pullHistory() measuring a buffer that is no longer the this._liveQueueBytes = 0;
// one it is restoring. Queued, it lands in order with the frames around it. }
// Releases the frames a load held (_liveQueue) and closes the queue. After a
// replay only those that arrived after the capture are news (`cutoff`, the
// response's arrival; earlier ones are already in it); with no replay
// (`cutoff` 0) every one is. Through _writeLive(), so they are counted (and
// a write that throws cannot skip the load's marker and trailing refresh).
_flushLiveQueue(cutoff) {
const queued = this._liveQueue ?? [];
this._liveQueue = null;
this._liveQueueBytes = 0;
for (const entry of queued) {
if (entry.at < cutoff) continue;
this._writeLive(entry.data);
}
}
// Writes one live frame into this xterm, under flow control. The server
// applies no backpressure (16 KB / 8 ms batches, never a bufferedAmount
// check), so a flood a tile cannot parse as fast as it arrives (a shell
// tile running `cat` on a huge log, `yes`) used to pile up in xterm's own
// write queue without bound, on a main thread six tiles share, until
// xterm's WriteBuffer throws past 50M code units and the frames were
// silently lost in onmessage's catch. The primary pane caps its queues
// and drops then recaptures (_onSessionTerminal, app.js); this is the
// tile's equivalent. Past TerminalTile.LIVE_BACKLOG_BUDGET unparsed, a
// frame is dropped and the tile stops writing until a refresh recaptures
// the screen (_scheduleDropRecovery): every byte after a hole is written
// onto a screen out of step with the PTY, which that refresh replaces
// anyway. A write that throws is the same drop, never a malformed frame.
_writeLive(data) {
const terminal = this.terminal;
if (!terminal || this._destroyed || !data) return;
if (this._liveDropped) {
this._noteLiveDrop();
return;
}
const n = data.length;
if (this._liveInFlight + n > TerminalTile.LIVE_BACKLOG_BUDGET) {
this._noteLiveDrop();
return;
}
const epoch = this._liveEpoch;
this._liveInFlight += n;
try {
terminal.write(data, () => {
if (epoch === this._liveEpoch) this._liveInFlight -= n;
});
} catch {
if (epoch === this._liveEpoch) this._liveInFlight -= n;
this._noteLiveDrop();
}
}
// A live frame was dropped. Marks the tile out of step and arms ONE
// recovery; later drops only move the stamp the recovery has to beat.
_noteLiveDrop() {
this._liveDropAt = performance.now();
if (this._liveDropped) return;
this._liveDropped = true;
this._scheduleDropRecovery();
}
// The primary pane's dropped-output recovery (_scheduleDroppedOutputRecovery,
// app.js), aimed at this tile: debounced by DROP_RECOVERY_DELAY_MS so a
// sustained flood collapses into one attempt, and run as an ordinary
// refresh, which is single-flight, bounded (`lines=`/`tail=`) and waits its
// turn in the grid's load queue. _settleDropRecovery() decides what the
// refresh it starts achieved.
_scheduleDropRecovery() {
if (this._dropRecoveryTimer || this._destroyed) return;
const delay = global.CodemanDroppedOutput?.DROP_RECOVERY_DELAY_MS ?? 2000;
this._dropRecoveryTimer = setTimeout(() => {
this._dropRecoveryTimer = null;
if (this._destroyed || !this._liveDropped) return;
this._dropRecoveryAttempt++;
this._refreshBuffer();
}, delay);
}
// A refresh finished while output was marked dropped. Recovered when its
// replay's capture was taken after the last dropped frame (the response's
// arrival, the cutoff every load uses): output flows again. Otherwise one
// more attempt, bounded by the primary pane's rule
// (shouldRetryDroppedOutputRecovery: DROP_RECOVERY_MAX_ATTEMPTS, and never
// after a capture cut off at its deadline, a stalled link); past that the
// flag is released so the tile is never left frozen, and it writes on, out
// of step, as every tile did before this existed.
_settleDropRecovery({ replayed, capturedAt, timedOut }) {
if (!this._liveDropped || this._destroyed) return;
if (replayed && capturedAt >= this._liveDropAt) {
this._liveDropped = false;
this._dropRecoveryAttempt = 0;
return;
}
// Another try is already on its way: the debounce, or a trailing refresh.
if (this._dropRecoveryTimer || this._bufferRefreshPending) return;
const retry =
global.CodemanDroppedOutput?.shouldRetryDroppedOutputRecovery?.({
repainted: false,
timedOut,
attempt: Math.max(0, this._dropRecoveryAttempt - 1),
stillActive: true,
}) === true;
if (retry) {
this._scheduleDropRecovery();
return;
}
this._liveDropped = false;
this._dropRecoveryAttempt = 0;
}
// Starts live-output accounting over (a reconnect, destroy): write callbacks
// still pending from before carry the old epoch and count nothing.
_resetLiveFlow() {
this._liveEpoch++;
this._liveInFlight = 0;
this._liveDropped = false;
this._dropRecoveryAttempt = 0;
clearTimeout(this._dropRecoveryTimer);
this._dropRecoveryTimer = null;
}
// The server's `{t:'c'}` frame, which is a refresh, not a wipe. Its one
// emitter (Session.startInteractive, session.ts) sends it once a fresh Claude
// pane first shows its prompt: the server has just trimmed its own buffer and
// means "refresh after startup". The primary pane refetches the capture and
// replays it (_onSessionClearTerminal, app.js), and while the grid is open
// that handler stands aside for the tiles. A bare xterm clear() here kept
// only the cursor's row and dropped the banner and every row above it, and an
// idle Claude never repaints static rows, so a Claude session Run into the
// grid (or Attached in a tile) sat there as a near-empty tile. So it takes
// the `{t:'r'}` route: single-flight, coalesced into one trailing refresh
// behind a load already running (a pull's held frames included), and paced
// by the grid's load queue.
_onLiveClear() { _onLiveClear() {
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true }); this._refreshBuffer();
else this._clearTerminal();
}
// A clear leaves no rows above the screen, the pane's own overflow included.
_clearTerminal() {
this.terminal?.clear();
this._overflowRows = 0;
} }
// Capture phase, because xterm's own wheel handler stopPropagation()s every // Capture phase, because xterm's own wheel handler stopPropagation()s every
@@ -931,8 +1131,8 @@
} }
// History rows in this xterm, for the paging gate: baseY less the rows this // History rows in this xterm, for the paging gate: baseY less the rows this
// pane pushed up itself. Clamped, because a clear (Ctrl+L, a `{t:'c'}` // pane pushed up itself. Clamped, because a clear (Ctrl+L) or an ED3/RIS in
// frame) or an ED3/RIS in the stream drops rows behind this count's back. // the stream drops rows behind this count's back.
_localRows() { _localRows() {
const baseY = this.terminal?.buffer?.active?.baseY || 0; const baseY = this.terminal?.buffer?.active?.baseY || 0;
this._overflowRows = Math.min(this._overflowRows, baseY); this._overflowRows = Math.min(this._overflowRows, baseY);
@@ -1044,7 +1244,7 @@
// Opened only now: a frame from before the response is either replaced by // Opened only now: a frame from before the response is either replaced by
// the capture or written unchanged, so holding it for the round trip // the capture or written unchanged, so holding it for the round trip
// would buy nothing and freeze the pane for as long as the fetch took. // would buy nothing and freeze the pane for as long as the fetch took.
this._liveQueue = []; this._openLiveQueue();
const payload = (await res.json())?.data; const payload = (await res.json())?.data;
clearTimeout(abortTimer); clearTimeout(abortTimer);
const buffer = payload?.terminalBuffer; const buffer = payload?.terminalBuffer;
@@ -1096,16 +1296,9 @@
clearTimeout(abortTimer); clearTimeout(abortTimer);
this._loadAbort = null; this._loadAbort = null;
this._loadRunning = false; this._loadRunning = false;
const queued = this._liveQueue ?? [];
this._liveQueue = null;
// After a replay, only frames that arrived after the capture are news; // After a replay, only frames that arrived after the capture are news;
// earlier ones are already in it. With no replay, every held frame is. // earlier ones are already in it. With no replay, every held frame is.
const cutoff = replayed ? capturedAt : 0; this._flushLiveQueue(replayed ? capturedAt : 0);
for (const entry of queued) {
if (entry.at < cutoff) continue;
if (entry.clear) this._clearTerminal();
else this.terminal?.write(entry.data);
}
// Settled after the queue flush so the marker is the last thing on // Settled after the queue flush so the marker is the last thing on
// screen: a close during the pull wrote nothing (_onSocketClosed() defers // screen: a close during the pull wrote nothing (_onSocketClosed() defers
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes // it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
@@ -1128,12 +1321,13 @@
return `&lines=${this.scrollback + (this.terminal?.rows || 0)}`; return `&lines=${this.scrollback + (this.terminal?.rows || 0)}`;
} }
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh // The refresh path (`{t:'r'}`, `{t:'c'}`, a reconnect): fetch, then reset
// frames in a row must not start two concurrent replays, each clearing // in-stream and replay (_loadBuffer). Two refresh frames in a row must not
// the terminal under the other's chunked write. A refresh that arrives // start two concurrent replays, each resetting the terminal under the
// mid-replay is COALESCED into one trailing re-run rather than ignored: // other's chunked write. A refresh that arrives mid-replay is COALESCED
// the in-flight fetch may predate the drop the new frame is reporting, // into one trailing re-run rather than ignored: the in-flight fetch may
// and no further frame is coming to correct stale content. // predate the drop the new frame is reporting, and no further frame is
// coming to correct stale content.
_refreshBuffer() { _refreshBuffer() {
if (this._bufferLoading) { if (this._bufferLoading) {
this._bufferRefreshPending = true; this._bufferRefreshPending = true;
@@ -1156,23 +1350,26 @@
// Reflow to the container and tell the PTY, as one step: the xterm and the // Reflow to the container and tell the PTY, as one step: the xterm and the
// PTY must never disagree about size (#464), and a font change is a size // PTY must never disagree about size (#464), and a font change is a size
// change too, so the font setters call this rather than localFit(). // change too, so the font setters call this rather than localFit().
// `force` resends an unchanged size. // `force` resends an unchanged size and asks the server to apply it anyway
// (Redraw, restoreTerminalSize). Returns whether a resize went out.
fit({ force = false } = {}) { fit({ force = false } = {}) {
this.localFit(); this.localFit();
this._sendResize({ force }); return this._sendResize({ force });
} }
// Returns true only once a `{t:'z'}` frame was sent, false at every early
// exit, so Redraw can say when nothing reached the PTY.
_sendResize({ force = false } = {}) { _sendResize({ force = false } = {}) {
if (!this._wsReady || !this.fitAddon || !this.terminal) return; if (!this._wsReady || !this.fitAddon || !this.terminal) return false;
// One PTY cannot hold two sizes (mirrors sendResize's own // One PTY cannot hold two sizes (mirrors sendResize's own
// detachedElsewhere yield in terminal-ui.js): the session got detached // detachedElsewhere yield in terminal-ui.js): the session got detached
// to its own window AFTER this pane was opened, so its own window now // to its own window AFTER this pane was opened, so its own window now
// owns the PTY's size and this pane must stand aside. // owns the PTY's size and this pane must stand aside.
if (this.detachedSessions?.has(this.sessionId)) return; if (this.detachedSessions?.has(this.sessionId)) return false;
// A hidden pane (a web tab over it, a zoomed neighbour) measures NaN, and // A hidden pane (a web tab over it, a zoomed neighbour) measures NaN, and
// fit() then leaves the xterm alone: there is no size worth reporting. // fit() then leaves the xterm alone: there is no size worth reporting.
const dims = this.fitAddon.proposeDimensions(); const dims = this.fitAddon.proposeDimensions();
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return; if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return false;
// Report what the xterm actually holds, so the PTY gets exactly the size // Report what the xterm actually holds, so the PTY gets exactly the size
// the pane renders at. Unclamped, unlike the primary pane's 40x10 floor: // the pane renders at. Unclamped, unlike the primary pane's 40x10 floor:
// a floor would misreport the split's Pane B at its divider's reachable // a floor would misreport the split's Pane B at its divider's reachable
@@ -1182,9 +1379,20 @@
const cols = this.terminal.cols; const cols = this.terminal.cols;
const rows = this.terminal.rows; const rows = this.terminal.rows;
const last = this._lastSentDims; const last = this._lastSentDims;
if (!force && last && last.cols === cols && last.rows === rows) return; if (!force && last && last.cols === cols && last.rows === rows) return false;
// `f` is the primary pane's forced resize (sendResize, terminal-ui.js):
// Session.resize (session.ts) otherwise skips a size equal to the one it
// last applied, so without it a forced resend reached the server and did
// nothing there (no tmux resize-window, no PTY resize).
const msg = { t: 'z', c: cols, r: rows, v: 'desktop' };
if (force) msg.f = true;
try {
this.ws.send(JSON.stringify(msg));
} catch {
return false; // nothing went out, so nothing is recorded as sent
}
this._lastSentDims = { cols, rows }; this._lastSentDims = { cols, rows };
this.ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: 'desktop' })); return true;
} }
// The session's PTY is new: a tile can connect before its session has a // The session's PTY is new: a tile can connect before its session has a
@@ -1244,6 +1452,9 @@
this.mountEl?.removeEventListener('click', this._onClick); this.mountEl?.removeEventListener('click', this._onClick);
this._onClick = null; this._onClick = null;
} }
// A disposed xterm never runs its write callbacks, and a pending
// recovery would refresh a pane nobody can see.
this._resetLiveFlow();
// Page keys still waiting for their flush go nowhere: the pane is gone. // Page keys still waiting for their flush go nowhere: the pane is gone.
clearTimeout(this._scrollFlushTimer); clearTimeout(this._scrollFlushTimer);
this._scrollFlushTimer = null; this._scrollFlushTimer = null;
@@ -1269,6 +1480,15 @@
} }
} }
// Code units of live output a pane lets sit unparsed in its xterm (or held
// behind a replay) before it drops a frame and recaptures (_writeLive). Not
// the primary pane's 128 KB: that caps its own rAF-paced queues, about two
// frames of them, while here xterm itself is the pacer, a burst normally
// parses within a frame or two, and a tight cap would trip on ordinary
// bursts and blank-and-reload the tile over and over. A few MB keeps a flood
// far below xterm's 50M code-unit throw and bounds each tile's memory.
TerminalTile.LIVE_BACKLOG_BUDGET = 4 * 1024 * 1024;
// The marker a pane writes when its socket drops: a transient drop says it is // The marker a pane writes when its socket drops: a transient drop says it is
// reconnecting; a permanent stop says why, keyed by close code. All start // reconnecting; a permanent stop says why, keyed by close code. All start
// with `[disconnected` so a reader (and a test) can tell any of them apart // with `[disconnected` so a reader (and a test) can tell any of them apart
+16 -3
View File
@@ -4664,11 +4664,24 @@ Object.assign(CodemanApp.prototype, {
*/ */
async restoreTerminalSize() { async restoreTerminalSize() {
// A second pane owns its own geometry: refit it and force its PTY to the // A second pane owns its own geometry: refit it and force its PTY to the
// size it renders at (TerminalTile.fit), whatever another device set. // size it renders at (TerminalTile.fit sends the same forced `f` resize
// sendResize sends below), whatever another device set. fit() says whether
// the resize went out; when it did not, say why rather than report a size
// that was never sent, as the primary branch does below.
const pane = this._focusedPane(); const pane = this._focusedPane();
if (!pane.isPrimary) { if (!pane.isPrimary) {
pane.tile.fit({ force: true }); const sent = pane.tile.fit({ force: true });
this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success'); if (sent !== false) {
this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success');
} else if (this.detachedSessions?.has(pane.sessionId)) {
// Its own window owns the PTY's size (TerminalTile._sendResize yields).
this.showToast('This session is sized by its own window', 'warning');
} else if (!pane.tile._wsReady) {
// The tile announces its size again as soon as its socket reopens.
this.showToast('Terminal not connected: its size is sent when it reconnects', 'warning');
} else {
this.showToast('Could not determine terminal size', 'error');
}
return; return;
} }
if (!this.activeSessionId) { if (!this.activeSessionId) {
+30
View File
@@ -127,6 +127,36 @@ describe('terminal shortcuts follow the focused pane', () => {
expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 60x30', 'success'); expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 60x30', 'success');
}); });
// TerminalTile.fit() returns whether its resize went out. When it did not,
// Redraw used to report a size that was never sent.
it.each([
[
'popped out to its own window',
{ detached: true, wsReady: true },
'This session is sized by its own window',
'warning',
],
[
'whose socket is down',
{ detached: false, wsReady: false },
'Terminal not connected: its size is sent when it reconnects',
'warning',
],
['that could not measure itself', { detached: false, wsReady: true }, 'Could not determine terminal size', 'error'],
])('Ctrl+Shift+R on a second pane %s reports no success', async (_label, state, message, level) => {
const app = loadApp();
app.detachedSessions = new Set(state.detached ? ['session-b'] : []);
const tile = paneB({ fit: vi.fn(() => false), _wsReady: state.wsReady });
app._noteFocusedTile(tile);
await app.restoreTerminalSize();
expect(tile.fit).toHaveBeenCalledWith({ force: true });
expect(app.showToast).toHaveBeenCalledTimes(1);
expect(app.showToast).toHaveBeenCalledWith(message, level);
expect(app.sendResize).not.toHaveBeenCalled();
});
it('Ctrl+Shift+R keeps restoring the primary when it holds the keyboard', async () => { it('Ctrl+Shift+R keeps restoring the primary when it holds the keyboard', async () => {
const app = loadApp(); const app = loadApp();
+24 -4
View File
@@ -64,7 +64,8 @@ export class FakeTerminal {
/** /**
* Opt-in, set by a test BEFORE the tile connects: the buffer's rows follow * Opt-in, set by a test BEFORE the tile connects: the buffer's rows follow
* what is written, as in xterm. Every `\n` adds a line, `baseY` is the lines * what is written, as in xterm. Every `\n` adds a line, `baseY` is the lines
* beyond the screen, a clear leaves one line, and a resize recomputes it * beyond the screen, a clear or an in-stream reset (RIS, `\x1bc`) leaves one
* line, and a resize recomputes it
* (a row-shrinking fit pushes rows above the screen, a growing one pulls them * (a row-shrinking fit pushes rows above the screen, a growing one pulls them
* back). The viewport follows the bottom. Off, `baseY` stays where a test * back). The viewport follows the bottom. Off, `baseY` stays where a test
* puts it. * puts it.
@@ -148,17 +149,36 @@ export class FakeTerminal {
} }
registerLinkProvider() {} registerLinkProvider() {}
writes: string[] = []; writes: string[] = [];
/** Set by a test: write callbacks never run, as on a disposed xterm. */ /**
* Set by a test: write callbacks do not run, as while xterm is still parsing
* (or never, on a disposed xterm). They wait in `heldParses` until `parse()`.
*/
holdParse = false; holdParse = false;
heldParses: Array<() => void> = [];
/** xterm catches up: runs every write callback held so far, in order. */
parse() {
for (const cb of this.heldParses.splice(0)) cb();
}
/** Set by a test: the next write of exactly this data throws, as xterm's WriteBuffer does past 50M. */
throwOnWrite: string | null = null;
write(data: string, cb?: () => void) { write(data: string, cb?: () => void) {
if (this.throwOnWrite !== null && data === this.throwOnWrite) {
this.throwOnWrite = null;
throw new Error('write data discarded, use flow control to avoid losing data');
}
// An empty write puts nothing on screen; the replay queues one only to hear // An empty write puts nothing on screen; the replay queues one only to hear
// (its callback) that everything before it has been parsed. // (its callback) that everything before it has been parsed.
if (data) this.writes.push(data); if (data) this.writes.push(data);
if (data && this.emulate) { if (data && this.emulate) {
this.lineCount += data.split('\n').length - 1; // A replay's reset (RIS) empties the buffer, as clear() does.
const reset = data.lastIndexOf('\x1bc');
if (reset !== -1) this.lineCount = 1;
this.lineCount += data.slice(reset === -1 ? 0 : reset + 2).split('\n').length - 1;
this.settleRows(); this.settleRows();
} }
if (!this.holdParse) cb?.(); if (!cb) return;
if (this.holdParse) this.heldParses.push(cb);
else cb();
} }
clear() { clear() {
this.writes.push('<CLEAR>'); this.writes.push('<CLEAR>');
+394 -11
View File
@@ -118,7 +118,7 @@ type Tile = {
connect(): Promise<void>; connect(): Promise<void>;
destroy(): void; destroy(): void;
reconnectNow(): void; reconnectNow(): void;
fit(opts?: { force?: boolean }): void; fit(opts?: { force?: boolean }): boolean;
detachedSessions?: Set<string>; detachedSessions?: Set<string>;
ws: FakeSocket | null; ws: FakeSocket | null;
_reconnectAttempts: number; _reconnectAttempts: number;
@@ -333,12 +333,14 @@ describe('TerminalTile reconnects after a transient drop', () => {
ws2.open(); ws2.open();
await settle(); await settle();
// The refresh cleared the pane and replayed the current screen, and nothing // The refresh reset the pane in-stream (never xterm's clear()) and replayed
// after that clear is a marker: the pane is healthy again. // the current screen, and nothing after that reset is a marker: the pane is
const lastClear = term.writes.lastIndexOf('<CLEAR>'); // healthy again.
expect(lastClear).toBeGreaterThan(-1); expect(term.writes).not.toContain('<CLEAR>');
expect(term.writes.slice(lastClear)).toContain('fresh screen'); const lastReset = term.writes.lastIndexOf('\x1bc');
expect(term.writes.slice(lastClear).some(isMarker)).toBe(false); expect(lastReset).toBeGreaterThan(-1);
expect(term.writes.slice(lastReset)).toEqual(['\x1bc', 'fresh screen']);
expect(term.writes.slice(lastReset).some(isMarker)).toBe(false);
expect(tile._reconnectAttempts).toBe(0); expect(tile._reconnectAttempts).toBe(0);
expect(tile.ws).toBe(ws2); expect(tile.ws).toBe(ws2);
}); });
@@ -387,6 +389,55 @@ describe('TerminalTile reconnects after a transient drop', () => {
}); });
}); });
describe("the server's {t:'c'} frame refreshes the tile (a Claude pane's first prompt)", () => {
// Session.startInteractive (session.ts) sends it once a fresh Claude pane
// shows its prompt, meaning "refresh after startup"; the primary pane refetches
// and replays (_onSessionClearTerminal). A tile used to run a bare xterm
// clear(), which kept only the cursor's row: banner and transcript gone, and an
// idle Claude never repaints them.
it('refetches the capture and replays it, never a bare clear', async () => {
const { ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ data: { terminalBuffer: 'Claude Code banner\r\n❯ ' } }),
}));
ws.receive({ t: 'o', d: 'banner painted live' });
ws.receive({ t: 'c' });
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('/api/sessions/s-tile/terminal?full=1');
expect(term.writes.at(-1)).toBe('Claude Code banner\r\n❯ ');
});
it('two clear frames during one refresh fetch once more, not twice', async () => {
const { ws } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
let release!: () => void;
fetchMock.mockImplementationOnce(
() =>
new Promise((resolveFetch) => {
release = () =>
resolveFetch({ ok: true, status: 200, json: async () => ({ data: { terminalBuffer: 'a' } }) });
})
);
ws.receive({ t: 'c' });
ws.receive({ t: 'c' });
ws.receive({ t: 'c' });
expect(fetchMock).toHaveBeenCalledTimes(1);
release();
await settle();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('TerminalTile stops for good on codes that cannot get better', () => { describe('TerminalTile stops for good on codes that cannot get better', () => {
it.each([ it.each([
[4004, 'the session ended'], [4004, 'the session ended'],
@@ -463,15 +514,80 @@ describe('TerminalTile geometry (#464: the pane and its PTY never disagree)', ()
const { tile, ws } = await connectTile(makeApp()); const { tile, ws } = await connectTile(makeApp());
ws.open(); ws.open();
tile.fit(); expect(tile.fit()).toBe(false); // unchanged: nothing sent
expect(resizeFrames(ws)).toHaveLength(1); expect(resizeFrames(ws)).toHaveLength(1);
FakeFit.proposed = { cols: 100, rows: 30 }; FakeFit.proposed = { cols: 100, rows: 30 };
tile.fit(); expect(tile.fit()).toBe(true);
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop' }); expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop' });
tile.fit({ force: true }); expect(tile.fit({ force: true })).toBe(true);
expect(resizeFrames(ws)).toHaveLength(3); expect(resizeFrames(ws)).toHaveLength(3);
// The primary pane's forced resize flag: without it Session.resize skips a
// size equal to the one it last applied, and the forced resend did nothing.
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop', f: true });
});
it('force on a closed socket sends nothing and says so', async () => {
const { tile, ws } = await connectTile(makeApp());
ws.open();
ws.drop(1006);
expect(tile.fit({ force: true })).toBe(false);
expect(resizeFrames(ws)).toHaveLength(1); // only the open's own announcement
});
describe('Redraw (Ctrl+Shift+R, the header button) on a focused tile', () => {
type RedrawApp = App & {
restoreTerminalSize(): Promise<void>;
_noteFocusedTile(tile: unknown): void;
detachedSessions?: Set<string>;
};
it('forces the resize through to the server and reports the size it sent', async () => {
const app = makeApp() as RedrawApp;
const { tile, ws } = await connectTile(app);
ws.open();
app._noteFocusedTile(tile);
await app.restoreTerminalSize();
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 80, r: 24, v: 'desktop', f: true });
expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 80x24', 'success');
});
it('with the socket down, sends nothing and does not report a success', async () => {
const app = makeApp() as RedrawApp;
const { tile, ws } = await connectTile(app);
ws.open();
app._noteFocusedTile(tile);
ws.drop(1006);
await app.restoreTerminalSize();
expect(resizeFrames(ws)).toHaveLength(1);
expect(app.showToast).not.toHaveBeenCalledWith(expect.stringContaining('restored'), 'success');
expect(app.showToast).toHaveBeenCalledWith(
'Terminal not connected: its size is sent when it reconnects',
'warning'
);
});
it('for a session popped out to its own window, says that window sizes it', async () => {
const app = makeApp() as RedrawApp;
const detached = new Set<string>();
app.detachedSessions = detached;
const { tile, ws } = await connectTile(app, { detachedSessions: detached });
ws.open();
app._noteFocusedTile(tile);
detached.add('s-tile'); // popped out after the tile opened
await app.restoreTerminalSize();
expect(resizeFrames(ws)).toHaveLength(1);
expect(app.showToast).not.toHaveBeenCalledWith(expect.stringContaining('restored'), 'success');
expect(app.showToast).toHaveBeenCalledWith('This session is sized by its own window', 'warning');
});
}); });
it('re-announces an unchanged size on a reconnected socket', async () => { it('re-announces an unchanged size on a reconnected socket', async () => {
@@ -547,7 +663,7 @@ describe('TerminalTile geometry (#464: the pane and its PTY never disagree)', ()
ws.open(); ws.open();
FakeFit.proposed = { cols: 120, rows: 40 }; FakeFit.proposed = { cols: 120, rows: 40 };
tile.fit(); expect(tile.fit()).toBe(false);
expect(resizeFrames(ws)).toEqual([]); expect(resizeFrames(ws)).toEqual([]);
}); });
@@ -686,6 +802,273 @@ describe('TerminalTile claims the keyboard for the app-level shortcuts', () => {
}); });
}); });
describe('TerminalTile live-output flow control: a flood cannot pile up in xterm', () => {
// The server applies no backpressure, and a tile used to write every live
// frame straight into xterm: a flood it could not parse as fast piled up in
// xterm's own queue without bound, until xterm's WriteBuffer threw past 50M
// code units and onmessage's catch silently lost the frames. Now unparsed
// (and held) output is counted per tile; past the budget a frame is dropped,
// the tile stops writing onto the hole, and one debounced, bounded refresh
// recaptures the screen (the primary pane's _scheduleDroppedOutputRecovery).
const TileStatics = TerminalTile as unknown as { LIVE_BACKLOG_BUDGET: number };
const defaultBudget = TileStatics.LIVE_BACKLOG_BUDGET;
afterEach(() => {
TileStatics.LIVE_BACKLOG_BUDGET = defaultBudget;
delete windowStub.AbortController;
});
const out = (ws: FakeSocket, d: string) => ws.receive({ t: 'o', d });
const inFlight = (tile: Tile) => (tile as unknown as { _liveInFlight: number })._liveInFlight;
function serve(terminalBuffer: string) {
fetchMock.mockClear();
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ data: { terminalBuffer } }),
}));
}
it("the budget is a few MB, not the primary pane's 128 KB: a 1 MiB burst xterm has not parsed yet is still written", async () => {
expect(defaultBudget).toBeGreaterThanOrEqual(2 * 1024 * 1024);
expect(defaultBudget).toBeLessThanOrEqual(16 * 1024 * 1024);
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
serve('');
term.holdParse = true;
out(ws, 'z'.repeat(1024 * 1024));
expect(term.writes.at(-1)).toHaveLength(1024 * 1024);
await vi.advanceTimersByTimeAsync(10_000);
expect(fetchMock).not.toHaveBeenCalled();
});
it('counts output until xterm has parsed it, so parsed bytes no longer hold the budget', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
term.holdParse = true;
out(ws, 'a'.repeat(40));
out(ws, 'b'.repeat(40));
expect(inFlight(tile)).toBe(80);
term.parse();
expect(inFlight(tile)).toBe(0);
out(ws, 'c'.repeat(90));
expect(term.writes.slice(-3)).toEqual(['a'.repeat(40), 'b'.repeat(40), 'c'.repeat(90)]);
});
it('stops writing past the budget, and ONE debounced refresh recaptures the screen', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
serve('recovered screen');
term.holdParse = true; // xterm falls behind
out(ws, 'a'.repeat(60));
out(ws, 'b'.repeat(60)); // 120 unparsed: past the budget, dropped
out(ws, 'c');
term.parse(); // xterm catches up, but the stream already has a hole:
out(ws, 'd'); // nothing more is written onto it
expect(term.writes.filter((w) => /^[abcd]/.test(w))).toEqual(['a'.repeat(60)]);
expect(fetchMock).not.toHaveBeenCalled();
term.holdParse = false;
await vi.advanceTimersByTimeAsync(1999);
expect(fetchMock).not.toHaveBeenCalled(); // debounced, like the primary pane's
await vi.advanceTimersByTimeAsync(1);
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('/api/sessions/s-tile/terminal?full=1');
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'recovered screen']);
out(ws, 'live again');
expect(term.writes.at(-1)).toBe('live again');
await vi.advanceTimersByTimeAsync(10_000);
expect(fetchMock).toHaveBeenCalledTimes(1); // one recovery for the whole burst
});
it("a write xterm refuses (its 50M throw) is a drop that schedules the same recovery, never a swallowed 'malformed frame'", async () => {
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
serve('recovered screen');
term.throwOnWrite = 'boom';
out(ws, 'boom');
out(ws, 'after the hole');
expect(term.writes).not.toContain('after the hole');
await vi.advanceTimersByTimeAsync(2000);
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'recovered screen']);
});
it('frames held behind a replay count against the budget too, and a hole there is recovered by another refresh', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
let answer!: (body: string) => void;
fetchMock.mockImplementationOnce(async () => ({
ok: true,
status: 200,
json: () =>
new Promise((resolveBody) => {
answer = (terminalBuffer) => resolveBody({ data: { terminalBuffer } });
}),
}));
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ data: { terminalBuffer: 'second capture' } }),
}));
ws.receive({ t: 'r' });
await settle(); // the headers landed: from here frames are held
out(ws, 'x'.repeat(60));
out(ws, 'y'.repeat(60)); // the held queue would pass the budget: dropped
answer('first capture');
await settle();
// The capture predates the hole, so nothing after it is written onto it.
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'first capture']);
expect(term.writes).not.toContain('y'.repeat(60));
await vi.advanceTimersByTimeAsync(2000);
await settle();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'second capture']);
out(ws, 'live again');
expect(term.writes.at(-1)).toBe('live again');
});
it('a recovery that keeps failing is retried a bounded number of times, then lets live output through again', async () => {
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
fetchMock.mockImplementation(async () => {
throw new Error('offline');
});
term.throwOnWrite = 'boom';
out(ws, 'boom');
out(ws, 'held back');
for (let i = 0; i < 10; i++) {
await vi.advanceTimersByTimeAsync(2000);
await settle();
}
const { DROP_RECOVERY_MAX_ATTEMPTS } = windowStub.CodemanDroppedOutput as { DROP_RECOVERY_MAX_ATTEMPTS: number };
expect(fetchMock).toHaveBeenCalledTimes(DROP_RECOVERY_MAX_ATTEMPTS);
expect(term.writes).not.toContain('held back');
out(ws, 'flowing');
expect(term.writes.at(-1)).toBe('flowing'); // never left frozen
});
it('a recovery cut off at its deadline is not retried (a stalled link), and lets live output through again', async () => {
windowStub.AbortController = AbortController;
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
fetchMock.mockImplementation(
(_url: string, init?: { signal?: AbortSignal }) =>
new Promise((_resolveFetch, rejectFetch) => {
init?.signal?.addEventListener('abort', () => rejectFetch(new Error('aborted')));
})
);
term.throwOnWrite = 'boom';
out(ws, 'boom');
await vi.advanceTimersByTimeAsync(2000);
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(45_000); // the full-capture budget runs out
await settle();
out(ws, 'flowing');
expect(term.writes.at(-1)).toBe('flowing');
await vi.advanceTimersByTimeAsync(20_000);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('a reconnect starts the count over, and callbacks from before it count nothing', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
vi.useFakeTimers();
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
term.holdParse = true;
out(ws, 'a'.repeat(60));
expect(inFlight(tile)).toBe(60);
ws.drop(1006);
await vi.advanceTimersByTimeAsync(300);
FakeSocket.instances.at(-1)!.open(); // reconnected: its refresh replaces the screen
expect(inFlight(tile)).toBe(0);
term.parse(); // the old write's callback lands after the reset
expect(inFlight(tile)).toBe(0);
});
it('a reconnect drops a pending recovery: its own refresh replaces the screen', async () => {
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
serve('');
term.throwOnWrite = 'boom';
out(ws, 'boom');
ws.drop(1006);
await vi.advanceTimersByTimeAsync(300);
const ws2 = FakeSocket.instances.at(-1)!;
ws2.open();
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1); // the reconnect's refresh
out(ws2, 'flowing');
expect(term.writes.at(-1)).toBe('flowing');
await vi.advanceTimersByTimeAsync(5000);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('destroy() cancels a pending recovery', async () => {
vi.useFakeTimers();
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
term.throwOnWrite = 'boom';
out(ws, 'boom');
const flow = tile as unknown as { _dropRecoveryTimer: unknown; _liveDropped: boolean };
expect(flow._dropRecoveryTimer).not.toBeNull(); // the drop armed one recovery
tile.destroy();
// Cleared by destroy() itself, read before any timer runs: the timer's own
// callback nulls the field and its destroyed guard skips the fetch, so the
// fetch check below alone cannot tell a cleared timer from a leaked one.
expect(flow._dropRecoveryTimer).toBeNull();
expect(flow._liveDropped).toBe(false);
await vi.advanceTimersByTimeAsync(5000);
expect(fetchMock).not.toHaveBeenCalled();
});
it('destroy() starts the count over: a write callback xterm still owed counts nothing', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
term.holdParse = true;
out(ws, 'a'.repeat(60));
expect(inFlight(tile)).toBe(60);
tile.destroy();
expect(inFlight(tile)).toBe(0);
term.parse(); // a callback from before destroy() carries the old epoch
expect(inFlight(tile)).toBe(0);
});
});
describe('the server coming back kicks Pane B', () => { describe('the server coming back kicks Pane B', () => {
it("handleInit's reconnect branch asks the split pane's tile to reconnect without waiting out its backoff", () => { it("handleInit's reconnect branch asks the split pane's tile to reconnect without waiting out its backoff", () => {
// handleInit needs a whole app to run, so the wiring is pinned by source; // handleInit needs a whole app to run, so the wiring is pinned by source;
+8 -1
View File
@@ -436,11 +436,18 @@ describe('rows the tile pushed above the screen itself are not history', () => {
expect(flushed(ws)).toEqual([]); expect(flushed(ws)).toEqual([]);
}); });
it('forgets the overflow on a server clear, so later real history counts', async () => { it('forgets the overflow when a server clear refreshes the tile, so later real history counts', async () => {
// A `{t:'c'}` is a refresh (a fresh Claude pane's first prompt), and its
// in-stream reset leaves nothing above the screen; the fresh capture is one
// line, so it adds no overflow of its own.
serveCapture(lines(40), 40); serveCapture(lines(40), 40);
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
expect(term.buffer.active.baseY).toBe(16);
serveCapture('fresh screen', 24);
ws.receive({ t: 'c' }); ws.receive({ t: 'c' });
await vi.advanceTimersByTimeAsync(0);
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'fresh screen']);
expect(term.buffer.active.baseY).toBe(0); expect(term.buffer.active.baseY).toBe(0);
ws.receive({ t: 'o', d: '\r\n'.repeat(term.rows + 1) }); // two real lines above the screen ws.receive({ t: 'o', d: '\r\n'.repeat(term.rows + 1) }); // two real lines above the screen
expect(term.buffer.active.baseY).toBe(2); expect(term.buffer.active.baseY).toBe(2);
+252 -56
View File
@@ -185,6 +185,10 @@ const isMarker = (data: unknown) => typeof data === 'string' && data.includes('[
*/ */
const screenWrites = (pane: { terminal: FakeTerminal }) => const screenWrites = (pane: { terminal: FakeTerminal }) =>
pane.terminal.write.mock.calls.map((call) => call[0]).filter((data) => data !== ''); pane.terminal.write.mock.calls.map((call) => call[0]).filter((data) => data !== '');
// Live frames are written with a callback (the tile counts them until xterm
// has parsed them, _writeLive), so they are matched on the data argument
// alone, never with toHaveBeenCalledWith(data): that would also miss, and a
// `.not` on it would then pass for nothing.
/** /**
* Holds xterm's write callbacks, as a real xterm still parsing a replay does: * Holds xterm's write callbacks, as a real xterm still parsing a replay does:
@@ -203,6 +207,10 @@ function holdParses(pane: { terminal: FakeTerminal }) {
}; };
} }
/** The in-stream reset (RIS) a refresh queues right before its replay, never xterm's clear(). */
const RIS = '\x1bc';
const resets = (pane: { terminal: FakeTerminal }) => screenWrites(pane).filter((data) => data === RIS).length;
/** Lets every microtask the vm-side promise chain queued run. */ /** Lets every microtask the vm-side promise chain queued run. */
const settle = () => new Promise((r) => setTimeout(r, 0)); const settle = () => new Promise((r) => setTimeout(r, 0));
@@ -236,17 +244,25 @@ describe('TerminalTile.destroy()', () => {
}); });
describe('TerminalTile server-refresh single-flight', () => { describe('TerminalTile server-refresh single-flight', () => {
it('a refresh with nothing in flight clears and fetches straight away', async () => { // These used to count xterm clear() calls: the refresh wiped the pane with a
// synchronous clear() BEFORE its fetch. It now fetches first and resets with
// the queued in-stream RIS right before the replay (CLAUDE.md, Terminal
// resilience), so they count that reset instead, and pin clear() at zero.
it('a refresh with nothing in flight fetches straight away, then resets in-stream and replays', async () => {
const pane = makePane(); const pane = makePane();
fetchMock.mockResolvedValueOnce(jsonResponse('one')); fetchMock.mockResolvedValueOnce(jsonResponse('one'));
pane._refreshBuffer(); pane._refreshBuffer();
// Fetching: the pane keeps its last frame, nothing written or cleared yet.
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(pane.terminal.write).not.toHaveBeenCalled();
await settle(); await settle();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1); expect(pane.terminal.clear).not.toHaveBeenCalled();
// The second argument carries the load's deadline (an AbortSignal). // The second argument carries the load's deadline (an AbortSignal).
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1', expect.anything()); expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1', expect.anything());
expect(pane.terminal.write).toHaveBeenCalledWith('one'); // The reset immediately before the replay, queued in the same stream.
expect(screenWrites(pane)).toEqual([RIS, 'one']);
expect(pane._bufferLoading).toBe(false); expect(pane._bufferLoading).toBe(false);
}); });
@@ -260,35 +276,37 @@ describe('TerminalTile server-refresh single-flight', () => {
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`, expect.anything()); expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`, expect.anything());
}); });
it('refreshes arriving mid-fetch neither clear nor fetch again, and run ONCE after the replay lands', async () => { it('refreshes arriving mid-fetch neither reset nor fetch again, and run ONCE after the replay lands', async () => {
const pane = makePane(); const pane = makePane();
const first = deferred<ReturnType<typeof jsonResponse>>(); const first = deferred<ReturnType<typeof jsonResponse>>();
const second = deferred<ReturnType<typeof jsonResponse>>(); const second = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
pane._refreshBuffer(); pane._refreshBuffer();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1); expect(resets(pane)).toBe(0); // fetch first
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
// Two more frames while the first replay is still in flight. // Two more frames while the first replay is still in flight.
pane._refreshBuffer(); pane._refreshBuffer();
pane._refreshBuffer(); pane._refreshBuffer();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1); expect(resets(pane)).toBe(0);
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
expect(pane._bufferRefreshPending).toBe(true); expect(pane._bufferRefreshPending).toBe(true);
first.resolve(jsonResponse('replay-1')); first.resolve(jsonResponse('replay-1'));
await settle(); await settle();
expect(pane.terminal.write).toHaveBeenCalledWith('replay-1'); expect(screenWrites(pane)).toEqual([RIS, 'replay-1']);
// Exactly one trailing re-run for the two coalesced frames, not two. // Exactly one trailing re-run for the two coalesced frames, not two; it is
expect(pane.terminal.clear).toHaveBeenCalledTimes(2); // fetching, so it has not reset anything yet.
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
expect(resets(pane)).toBe(1);
second.resolve(jsonResponse('replay-2')); second.resolve(jsonResponse('replay-2'));
await settle(); await settle();
expect(screenWrites(pane).at(-1)).toBe('replay-2'); expect(screenWrites(pane)).toEqual([RIS, 'replay-1', RIS, 'replay-2']);
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
expect(pane._bufferLoading).toBe(false); expect(pane._bufferLoading).toBe(false);
expect(pane._bufferRefreshPending).toBe(false); expect(pane._bufferRefreshPending).toBe(false);
@@ -303,9 +321,11 @@ describe('TerminalTile server-refresh single-flight', () => {
pane._refreshBuffer(); pane._refreshBuffer();
await settle(); await settle();
// Every slice queued at once, then the empty write whose callback ends the // The reset, then every slice queued at once, then the empty write whose
// replay: nothing waits for an animation frame. // callback ends the replay: nothing waits for an animation frame.
expect(pane.terminal.write.mock.calls[0][0]).toBe(RIS);
expect(pane.terminal.write.mock.calls.map((call) => call[0].length)).toEqual([ expect(pane.terminal.write.mock.calls.map((call) => call[0].length)).toEqual([
RIS.length,
TERMINAL_CHUNK_SIZE, TERMINAL_CHUNK_SIZE,
TERMINAL_CHUNK_SIZE, TERMINAL_CHUNK_SIZE,
5, 5,
@@ -314,17 +334,19 @@ describe('TerminalTile server-refresh single-flight', () => {
// xterm is still parsing: the replay, and with it the flag, is not done. // xterm is still parsing: the replay, and with it the flag, is not done.
expect(pane._bufferLoading).toBe(true); expect(pane._bufferLoading).toBe(true);
// A refresh mid-parse must not clear the terminal under the replay, nor // A refresh mid-parse must not reset the terminal under the replay, nor
// start a second fetch. // start a second fetch.
pane._refreshBuffer(); pane._refreshBuffer();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1); expect(resets(pane)).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
fetchMock.mockResolvedValueOnce(jsonResponse('after')); fetchMock.mockResolvedValueOnce(jsonResponse('after'));
xterm.parse(); xterm.parse();
await settle(); await settle();
// Parsed: the coalesced refresh runs now, once. // Parsed: the coalesced refresh runs now, once, and resets before its replay.
expect(pane.terminal.clear).toHaveBeenCalledTimes(2); expect(resets(pane)).toBe(2);
expect(screenWrites(pane).slice(-2)).toEqual([RIS, 'after']);
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
expect(pane._bufferLoading).toBe(true); expect(pane._bufferLoading).toBe(true);
@@ -344,7 +366,10 @@ describe('TerminalTile server-refresh single-flight', () => {
pane._refreshBuffer(); pane._refreshBuffer();
await settle(); await settle();
const queued = () => pane.terminal.write.mock.calls.reduce((n, call) => n + call[0].length, 0); // The replay's own bytes; the reset queued ahead of them is checked apart.
expect(pane.terminal.write.mock.calls[0][0]).toBe(RIS);
const queued = () =>
pane.terminal.write.mock.calls.slice(1).reduce((n: number, call: unknown[]) => n + (call[0] as string).length, 0);
expect(queued()).toBe(TERMINAL_TAIL_SIZE); expect(queued()).toBe(TERMINAL_TAIL_SIZE);
xterm.parse(); xterm.parse();
@@ -403,6 +428,137 @@ describe('TerminalTile server-refresh single-flight', () => {
}); });
}); });
describe('TerminalTile refresh order: fetch, then the in-stream reset, then the held frames', () => {
// The primary pane's order (_onSessionNeedsRefresh / _resetTerminalForReplay,
// CLAUDE.md "Terminal resilience"). The refresh used to wipe the pane with a
// synchronous xterm clear() before its fetch and wrote live frames straight
// through the fetch and the replay: queued bytes fused into the snapshot, the
// capture started at the old cursor column, and a failed fetch left it blank.
it.each([
['the request fails', () => fetchMock.mockRejectedValueOnce(new Error('offline'))],
['the capture is empty', () => fetchMock.mockResolvedValueOnce(jsonResponse(''))],
])('when %s, the screen is left exactly as it was', async (_label, arrange) => {
const pane = makePane();
arrange();
pane._refreshBuffer();
await settle();
expect(pane.terminal.write).not.toHaveBeenCalled();
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(pane._liveQueue).toBeNull();
expect(pane._bufferLoading).toBe(false);
});
it('a body read that fails after the headers resets nothing and writes every held frame, in order', async () => {
const pane = makePane();
const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response);
pane._refreshBuffer();
await settle(); // headers landed: the queue is open
pane._onLiveOutput('frame-A');
pane._onLiveOutput('frame-B');
expect(pane.terminal.write).not.toHaveBeenCalled();
held.fail();
await settle();
expect(screenWrites(pane)).toEqual(['frame-A', 'frame-B']);
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(pane._liveQueue).toBeNull();
expect(pane._bufferLoading).toBe(false);
});
it('writes frames from before the response straight through, holds later ones, and writes them behind the replay', async () => {
const pane = makePane();
const xterm = holdParses(pane);
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
pane._refreshBuffer();
expect(pane._liveQueue).toBeNull(); // the round trip holds nothing
clock = 1;
pane._onLiveOutput('early'); // the pane keeps painting; the capture then replaces it
clock = 2; // the response arrives: this is the cutoff
response.resolve(jsonResponse('snapshot'));
await settle();
// The replay is queued and xterm is still parsing it.
expect(pane._liveQueue).not.toBeNull();
clock = 3;
pane._onLiveOutput('late'); // news the capture cannot hold
expect(screenWrites(pane)).toEqual(['early', RIS, 'snapshot']);
xterm.parse();
await settle();
// 'early' sits before the reset (wiped by it, never repeated); 'late' lands after the snapshot.
expect(screenWrites(pane)).toEqual(['early', RIS, 'snapshot', 'late']);
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(pane._liveQueue).toBeNull();
expect(pane._bufferLoading).toBe(false);
});
it('drops a held frame that arrived before the response once the replay covers it', async () => {
// The response's arrival is the cutoff (the primary pane's `since`): a frame
// held with an earlier stamp is already in the capture.
const pane = makePane();
const held = headersOnly();
clock = 5;
fetchMock.mockResolvedValueOnce(held.response);
pane._refreshBuffer();
await settle(); // headers landed at clock 5
clock = 4; // stamped before the cutoff (a stand-in for a frame the capture holds)
pane._onLiveOutput('in-the-capture');
clock = 6;
pane._onLiveOutput('after-the-capture');
held.release('snapshot');
await settle();
expect(screenWrites(pane)).toEqual([RIS, 'snapshot', 'after-the-capture']);
});
it('a bounded window gives the body read the short budget; an unbounded capture keeps the long one', async () => {
const bounded = makePane('shell'); // a shell loads the `tail=` window
const unbounded = makePane(); // the split's Pane B: a TUI's whole history
const a = headersOnly();
const b = headersOnly();
fetchMock.mockResolvedValueOnce(a.response).mockResolvedValueOnce(b.response);
bounded._refreshBuffer();
await settle(); // headers landed
expect(deadlines.map((d) => d.ms)).toEqual([45_000, 10_000]);
expect(deadlines[0].cleared).toBe(true);
unbounded._refreshBuffer();
await settle();
// No re-arm: a body of up to 32 MB keeps the request's own budget.
expect(deadlines.map((d) => d.ms)).toEqual([45_000, 10_000, 45_000]);
expect(deadlines[2].cleared).toBe(false);
a.release('one');
b.release('two');
await settle();
expect(deadlines.every((d) => d.cleared)).toBe(true);
});
it('a close mid-refresh whose fetch fails writes exactly one marker: nothing wiped it', async () => {
const pane = makePane();
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
pane._refreshBuffer();
pane._onSocketClosed(); // owed: a load is running
expect(pane.terminal.write).not.toHaveBeenCalled();
response.resolve({ json: async () => Promise.reject(new Error('body read failed')) } as never);
await settle();
const writes = screenWrites(pane);
expect(writes.filter(isMarker)).toHaveLength(1);
expect(writes).not.toContain(RIS);
});
});
describe('TerminalTile scroll-to-top history pull', () => { describe('TerminalTile scroll-to-top history pull', () => {
it('a shell pane at the top pulls a bounded window of full history and replays it', async () => { it('a shell pane at the top pulls a bounded window of full history and replays it', async () => {
const pane = makePane('shell'); const pane = makePane('shell');
@@ -586,7 +742,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
// keeps painting during the round trip), and the replay then replaces it. // keeps painting during the round trip), and the replay then replaces it.
clock = 1; clock = 1;
pane._onLiveOutput('early'); pane._onLiveOutput('early');
expect(term.write).toHaveBeenCalledWith('early'); expect(screenWrites(pane)).toContain('early');
await settle(); await settle();
// 200 rows (more than the pane holds, so it replays) of 400 columns each: // 200 rows (more than the pane holds, so it replays) of 400 columns each:
@@ -596,12 +752,14 @@ describe('TerminalTile scroll-to-top history pull', () => {
clock = 2; // the response arrives: this is the cutoff clock = 2; // the response arrives: this is the cutoff
response.resolve(jsonResponse(bigReplay)); response.resolve(jsonResponse(bigReplay));
await settle(); await settle();
expect(xterm.held).toHaveLength(1); // Two parses pending: 'early' (a live write, counted until xterm parses it)
// and the replay's end marker.
expect(xterm.held).toHaveLength(2);
// Arrives while the snapshot is still being parsed: must not land under it. // Arrives while the snapshot is still being parsed: must not land under it.
clock = 3; clock = 3;
pane._onLiveOutput('late'); pane._onLiveOutput('late');
expect(term.write).not.toHaveBeenCalledWith('late'); expect(screenWrites(pane)).not.toContain('late');
// The replay parsed, then the pull's own settle write before it scrolls. // The replay parsed, then the pull's own settle write before it scrolls.
xterm.parse(); xterm.parse();
@@ -626,12 +784,12 @@ describe('TerminalTile scroll-to-top history pull', () => {
pane._maybeLoadMoreHistory(); pane._maybeLoadMoreHistory();
await settle(); await settle();
pane._onLiveOutput('held'); pane._onLiveOutput('held');
expect(pane.terminal.write).not.toHaveBeenCalledWith('held'); expect(screenWrites(pane)).not.toContain('held');
held.release(rowsOf(30)); // nothing to gain: no replay held.release(rowsOf(30)); // nothing to gain: no replay
await settle(); await settle();
// Nothing replaced the terminal, so the held frame is news. // Nothing replaced the terminal, so the held frame is news.
expect(pane.terminal.write).toHaveBeenCalledWith('held'); expect(screenWrites(pane)).toContain('held');
}); });
it('a failed fetch releases the flag and the queue, so live output flows again', async () => { it('a failed fetch releases the flag and the queue, so live output flows again', async () => {
@@ -647,9 +805,9 @@ describe('TerminalTile scroll-to-top history pull', () => {
expect(pane._bufferLoading).toBe(false); expect(pane._bufferLoading).toBe(false);
expect(pane._liveQueue).toBeNull(); expect(pane._liveQueue).toBeNull();
expect(pane.terminal.write).toHaveBeenCalledWith('held'); expect(screenWrites(pane)).toContain('held');
pane._onLiveOutput('after'); pane._onLiveOutput('after');
expect(pane.terminal.write).toHaveBeenLastCalledWith('after'); expect(screenWrites(pane).at(-1)).toBe('after');
}); });
it('a refresh frame during the pull runs once behind it', async () => { it('a refresh frame during the pull runs once behind it', async () => {
@@ -659,64 +817,97 @@ describe('TerminalTile scroll-to-top history pull', () => {
pane._maybeLoadMoreHistory(); pane._maybeLoadMoreHistory();
pane._refreshBuffer(); pane._refreshBuffer();
expect(pane.terminal.clear).not.toHaveBeenCalled(); expect(pane.terminal.write).not.toHaveBeenCalled();
expect(pane._bufferRefreshPending).toBe(true); expect(pane._bufferRefreshPending).toBe(true);
response.resolve(jsonResponse(rowsOf(30))); response.resolve(jsonResponse(rowsOf(30)));
await settle(); await settle();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1); // The pull had nothing to replay; the refresh behind it reset once, then replayed.
expect(screenWrites(pane)).toEqual([RIS, 'refreshed']);
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
expect(pane.terminal.write).toHaveBeenCalledWith('refreshed');
}); });
it('a clear frame during the pull is queued in order, never applied under the replay', async () => { // The server's `{t:'c'}` means "refresh after startup" (its one emitter is a
// fresh Claude pane's first prompt, session.ts), and the primary pane answers
// it with a refetch and replay (_onSessionClearTerminal). The three below
// replace two tests that pinned it as a bare xterm clear(), which kept only
// the cursor's row: a Claude session Run into the grid came up a near-empty
// tile.
it('a clear frame during the pull is coalesced into one refresh behind it, never applied under the replay', async () => {
const pane = makePane('shell'); const pane = makePane('shell');
const term = pane.terminal; const term = pane.terminal;
const order: string[] = []; const order: string[] = [];
term.write.mockImplementation((data: string, done?: () => void) => { term.write.mockImplementation((data: string, done?: () => void) => {
order.push(`write:${data}`); if (data) order.push(`write:${data}`);
done?.(); done?.();
}); });
term.clear.mockImplementation(() => order.push('clear')); term.clear.mockImplementation(() => order.push('clear'));
const held = headersOnly(); const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response); fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('after startup'));
pane._maybeLoadMoreHistory(); pane._maybeLoadMoreHistory();
await settle(); await settle();
pane._onLiveOutput('before'); pane._onLiveOutput('before');
pane._onLiveClear(); pane._onLiveClear();
pane._onLiveClear(); // a second one joins the same trailing refresh
pane._onLiveOutput('after'); pane._onLiveOutput('after');
// Held: clearing now would wipe a half-written snapshot. // Held: nothing touches the screen under the pull, and nothing fetches yet.
expect(order).toEqual([]); expect(order).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(pane._bufferRefreshPending).toBe(true);
held.release(rowsOf(30)); // nothing to gain: no replay held.release(rowsOf(30)); // nothing to gain: no replay
await settle(); await settle();
expect(order).toEqual(['write:before', 'clear', 'write:after']); // The held frames land in order, then ONE refresh fetches the pane's
// current screen and replays it last.
expect(order.slice(0, 2)).toEqual(['write:before', 'write:after']);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenLastCalledWith(
`/api/sessions/s1/terminal?tail=${TERMINAL_TAIL_SIZE}`,
expect.anything()
);
expect(order.at(-1)).toBe('write:after startup');
expect(pane._liveQueue).toBeNull(); expect(pane._liveQueue).toBeNull();
expect(pane._bufferLoading).toBe(false);
// With nothing in flight a clear frame applies straight away. expect(pane._bufferRefreshPending).toBe(false);
pane._onLiveClear();
expect(order.at(-1)).toBe('clear');
}); });
it('a clear that arrived before the capture is not replayed after it', async () => { it('a clear frame with nothing in flight refetches the capture and replays it, like a refresh frame', async () => {
const pane = makePane();
fetchMock.mockResolvedValueOnce(jsonResponse('banner\r\n❯ '));
pane._onLiveClear();
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1', expect.anything());
expect(screenWrites(pane).at(-1)).toBe('banner\r\n❯ ');
expect(pane._bufferLoading).toBe(false);
});
it('a clear frame while a pull waits for its response does not touch the screen, and refreshes behind the pull', async () => {
const pane = makePane('shell'); const pane = makePane('shell');
const term = pane.terminal; const term = pane.terminal;
const response = deferred<ReturnType<typeof jsonResponse>>(); const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise); fetchMock.mockReturnValueOnce(response.promise).mockResolvedValueOnce(jsonResponse('refreshed'));
pane._maybeLoadMoreHistory(); pane._maybeLoadMoreHistory();
clock = 1; clock = 1;
pane._onLiveClear(); // before the response: applied now, already in the capture pane._onLiveClear(); // before the response
expect(term.clear).toHaveBeenCalledTimes(1); expect(term.clear).not.toHaveBeenCalled();
expect(pane._bufferRefreshPending).toBe(true);
clock = 2; clock = 2;
response.resolve(jsonResponse(rowsOf(100))); response.resolve(jsonResponse(rowsOf(100)));
await settle(); await settle();
expect(term.write).toHaveBeenCalledWith('\x1bc'); const writes = screenWrites(pane);
expect(term.clear).toHaveBeenCalledTimes(1); // not replayed after the capture expect(writes).toContain(rowsOf(100)); // the pull still replayed
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(writes.at(-1)).toBe('refreshed');
expect(pane._bufferLoading).toBe(false);
}); });
it('destroy() mid-pull leaves nothing running and nothing written to the dead terminal', async () => { it('destroy() mid-pull leaves nothing running and nothing written to the dead terminal', async () => {
@@ -736,7 +927,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
expect(pane._liveQueue).toBeNull(); expect(pane._liveQueue).toBeNull();
expect(pane.terminal).toBeNull(); expect(pane.terminal).toBeNull();
expect(term.write).not.toHaveBeenCalledWith('\x1bc'); expect(term.write).not.toHaveBeenCalledWith('\x1bc');
expect(term.write).not.toHaveBeenCalledWith('held'); expect(term.write.mock.calls.map((call) => call[0])).not.toContain('held');
}); });
it('a pull whose request is aborted (the deadline) frees the pane', async () => { it('a pull whose request is aborted (the deadline) frees the pane', async () => {
@@ -752,7 +943,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
expect(pane._bufferLoading).toBe(false); expect(pane._bufferLoading).toBe(false);
expect(pane._liveQueue).toBeNull(); expect(pane._liveQueue).toBeNull();
expect(pane.terminal.write).toHaveBeenCalledWith('held'); expect(screenWrites(pane)).toContain('held');
}); });
it('the wheel listener is capture-phase, and only a wheel UP can trigger a pull', async () => { it('the wheel listener is capture-phase, and only a wheel UP can trigger a pull', async () => {
@@ -913,10 +1104,9 @@ describe('TerminalTile scroll-to-top history pull', () => {
it('back-to-back refreshes on a closed socket leave exactly one marker, at the end', async () => { it('back-to-back refreshes on a closed socket leave exactly one marker, at the end', async () => {
// R1's finally runs the trailing refresh R2, so R1 leaves the owed marker to // R1's finally runs the trailing refresh R2, so R1 leaves the owed marker to
// R2 instead of stamping it: in real xterm R1's write would still be queued // R2 instead of stamping it: R2 settles it, below its own replay. The write
// when R2's synchronous clear() runs, and would land above R2's replay. The // mock records every stamp, so counting marker writes pins that R1 never
// write mock records every stamp whatever clear() does, so counting marker // stamps (the async-parse test below shows the screen).
// writes pins that R1 never stamps (the async-parse test below shows why).
const pane = makePane('shell'); const pane = makePane('shell');
pane._wsClosed = true; pane._wsClosed = true;
const first = deferred<ReturnType<typeof jsonResponse>>(); const first = deferred<ReturnType<typeof jsonResponse>>();
@@ -927,8 +1117,10 @@ describe('TerminalTile scroll-to-top history pull', () => {
pane._refreshBuffer(); // coalesced into the trailing R2 pane._refreshBuffer(); // coalesced into the trailing R2
first.resolve(jsonResponse('first')); first.resolve(jsonResponse('first'));
await settle(); await settle();
// R1 settled its marker, then R2 cleared and is still fetching. // R1 replayed and left the marker to R2, which is still fetching: it has
expect(pane.terminal.clear).toHaveBeenCalledTimes(2); // reset nothing yet, so R1's replay is still on screen.
expect(screenWrites(pane)).toEqual([RIS, 'first']);
expect(fetchMock).toHaveBeenCalledTimes(2);
second.resolve(jsonResponse('second')); second.resolve(jsonResponse('second'));
await settle(); await settle();
@@ -1015,8 +1207,8 @@ describe('TerminalTile scroll-to-top history pull', () => {
}); });
it('a refresh queued behind a pull on a closed socket does not wipe the marker', async () => { it('a refresh queued behind a pull on a closed socket does not wipe the marker', async () => {
// The refresh's clear() runs after the pull's finally block has written the // The refresh's reset lands after the pull's finally block, so without a
// marker, so without a re-stamp the dead pane would look current again. // re-stamp the dead pane would look current again.
const pane = makePane('shell'); const pane = makePane('shell');
const held = headersOnly(); const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('refreshed')); fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('refreshed'));
@@ -1030,7 +1222,9 @@ describe('TerminalTile scroll-to-top history pull', () => {
await settle(); await settle();
const writes = pane.terminal.write.mock.calls.map((c) => c[0]); const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
expect(pane.terminal.clear).toHaveBeenCalledTimes(1); expect(resets(pane)).toBe(1);
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(writes.indexOf(RIS)).toBe(writes.indexOf('refreshed') - 1);
expect(writes).toContain('refreshed'); expect(writes).toContain('refreshed');
expect(isMarker(writes.at(-1))).toBe(true); expect(isMarker(writes.at(-1))).toBe(true);
expect(writes.lastIndexOf('refreshed')).toBeLessThan(writes.length - 1); expect(writes.lastIndexOf('refreshed')).toBeLessThan(writes.length - 1);
@@ -1063,9 +1257,10 @@ describe('TerminalTile scroll-to-top history pull', () => {
])('with xterm parsing writes on a later tick, %s leave one marker on screen, last', async (_label, drive) => { ])('with xterm parsing writes on a later tick, %s leave one marker on screen, last', async (_label, drive) => {
// Real xterm queues write() and parses it on a later tick (WriteBuffer's // Real xterm queues write() and parses it on a later tick (WriteBuffer's
// setTimeout), while clear() rewrites the buffer at once. The default fake // setTimeout), while clear() rewrites the buffer at once. The default fake
// applies writes synchronously and so cannot show a marker overtaken by a // applies writes synchronously and so cannot show ordering on the real
// trailing refresh's clear(): parsed after it, that marker sat above the // screen: back when a trailing refresh began with a clear(), a marker parsed
// refresh's replay as a second, stale copy. // after it sat above the refresh's replay as a second, stale copy. A
// refresh never calls clear() now (pinned below); its reset is in-stream.
const pane = makePane('shell'); const pane = makePane('shell');
const screen: string[] = []; const screen: string[] = [];
const pending: Array<{ data: string; done?: () => void }> = []; const pending: Array<{ data: string; done?: () => void }> = [];
@@ -1088,6 +1283,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
await drive(pane); await drive(pane);
for (let i = 0; i < 5; i++) await settle(); for (let i = 0; i < 5; i++) await settle();
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(pane._bufferLoading).toBe(false); expect(pane._bufferLoading).toBe(false);
expect(screen.filter(isMarker)).toHaveLength(1); expect(screen.filter(isMarker)).toHaveLength(1);
expect(screen.at(-1)).toSatisfy(isMarker); expect(screen.at(-1)).toSatisfy(isMarker);
+52 -8
View File
@@ -13,8 +13,9 @@
* for N tiles reconnecting together; * for N tiles reconnecting together;
* - the focused tile goes first, then reading order, and a history pull (the * - the focused tile goes first, then reading order, and a history pull (the
* user is waiting on it) jumps ahead of background refreshes; * user is waiting on it) jumps ahead of background refreshes;
* - `{t:'r'}` goes through the same queue, and a tile waiting its turn keeps its * - `{t:'r'}` and `{t:'c'}` go through the same queue, and a tile keeps its
* last frame (the clear happens at its turn); * last frame while it waits its turn and through its own capture's round
* trip (it is reset in-stream only once the capture is in hand);
* - a destroyed tile's queued load is dropped, and destroying the tile whose * - a destroyed tile's queued load is dropped, and destroying the tile whose
* load is running aborts its fetch so the queue moves on; * load is running aborts its fetch so the queue moves on;
* - a load that never answers is cut off by its deadline; * - a load that never answers is cut off by its deadline;
@@ -270,12 +271,54 @@ describe('refreshes', () => {
await settle(); await settle();
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a']); expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a']);
// b has not been cleared: it shows its last frame until its load runs. // b has not been reset: it shows its last frame until its load runs.
expect(b.terminal?.writes).not.toContain('<CLEAR>'); const before = [...(b.terminal?.writes ?? [])];
expect(before).not.toContain('<CLEAR>');
await drain('fresh'); await drain('fresh');
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']); expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']);
expect(b.terminal?.writes.slice(-2)).toEqual(['<CLEAR>', 'fresh']); // Its turn reset it in-stream, right before the replay, never with clear().
expect(b.terminal?.writes).toEqual([...before, '\x1bc', 'fresh']);
});
it("a server {t:'c'} (a Claude pane's first prompt) is a refresh through the same queue", async () => {
const { tiles } = makeGrid(['a', 'b']);
await connectAll(tiles);
const [a, b] = tiles;
a.ws?.receive({ t: 'r' });
b.ws?.receive({ t: 'c' });
await settle();
// b waits behind a, like any refresh: one capture in flight.
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a']);
expect(await drain('banner')).toBe(1);
expect(captures.map((c) => c.url)).toEqual([
`/api/sessions/a/terminal?full=1&tail=${TAIL}${LINES}`,
`/api/sessions/b/terminal?full=1&tail=${TAIL}${LINES}`,
]);
expect(b.terminal?.writes.at(-1)).toBe('banner');
});
it("a tile keeps its last frame through its own capture's round trip, and one cut off leaves it as it was", async () => {
vi.useFakeTimers();
const { tiles } = makeGrid(['a']);
await connectAll(tiles);
const [a] = tiles;
a.ws?.receive({ t: 'o', d: 'last frame' });
a.ws?.receive({ t: 'r' });
await settle();
// Its turn came and its capture is in flight: nothing reset yet.
expect(inFlight()).toBe(1);
expect(a.terminal?.writes.at(-1)).toBe('last frame');
// The full-capture budget runs out with no answer.
await vi.advanceTimersByTimeAsync(45_000);
await settle();
expect(captures.at(-1)?.aborted).toBe(true);
expect(a.terminal?.writes.at(-1)).toBe('last frame');
expect(a.terminal?.writes).not.toContain('\x1bc');
expect(a.terminal?.writes).not.toContain('<CLEAR>');
}); });
it('a history pull jumps ahead of background refreshes', async () => { it('a history pull jumps ahead of background refreshes', async () => {
@@ -308,10 +351,11 @@ describe('refreshes', () => {
const markers = () => (b.terminal?.writes ?? []).filter((w) => w.includes('[disconnected')).length; const markers = () => (b.terminal?.writes ?? []).filter((w) => w.includes('[disconnected')).length;
expect(markers()).toBe(1); expect(markers()).toBe(1);
await drain('fresh'); await drain('fresh');
// Its turn cleared the screen, so the marker is written again below the replay: one on screen. // Its replay reset the screen, so the marker is written again below the replay: one on screen.
const writes = b.terminal?.writes ?? []; const writes = b.terminal?.writes ?? [];
expect(writes.slice(writes.lastIndexOf('<CLEAR>'))).toEqual([ expect(writes).not.toContain('<CLEAR>');
'<CLEAR>', expect(writes.slice(writes.lastIndexOf('\x1bc'))).toEqual([
'\x1bc',
'fresh', 'fresh',
expect.stringContaining('[disconnected'), expect.stringContaining('[disconnected'),
]); ]);