fix(tiles): refresh fetches first, then resets in-stream and replays

A tile's refresh (a {t:'r'} or {t:'c'} frame, every reconnect) wiped the
pane with a synchronous xterm clear() at the load's turn, BEFORE its fetch,
and wrote live frames straight through the fetch and the replay. That is
the replay clear CLAUDE.md "Terminal resilience" forbids: bytes still
queued in xterm are parsed after a synchronous clear and fuse into the
snapshot, and clear() keeps the cursor's row, column, SGR and margins, so
the capture (raw rows, no home) started wherever the cursor sat. A failed
or empty fetch left the tile blank.

The refresh now runs in the primary pane's order (_onSessionNeedsRefresh,
_resetTerminalForReplay):
- fetch first, so the tile keeps its last frame through the round trip and
  through a grid tile's wait in the load queue;
- from the response on, live frames are held in _liveQueue with their
  arrival time, as _pullHistory already did, and the body read of a bounded
  window (grid tile, shell) gets the pull's 10 s budget, while Pane B's
  unbounded full=1 keeps the request's own budget;
- then the queued in-stream \x1bc immediately before the replay;
- then the held frames that arrived after the response (_flushLiveQueue,
  now shared with _pullHistory), then the owed marker.
A failed, aborted or empty fetch writes nothing and resets nothing.

The _stampMarkerIfOwed guard for a pending trailing refresh stays (that
refresh settles the marker itself either way); only its rationale changed.
The fake xterm now treats an in-stream RIS like clear() in its row
emulation.

Tests: the ones that counted clear() calls on the refresh path now count
the in-stream reset instead, assert it sits right before the replay and
that clear() is never called (unit single-flight block, the marker
ordering tests, the reconnect test, the grid {t:'r'} and marker tests, and
the scroll test's server-clear overflow case, which now goes through a
refresh). New: the screen is untouched on a failed or empty fetch and on a
failed body read (held frames written in order), frames before the
response are written through and later ones held behind the replay, the
cutoff drops frames the capture covers, the body budgets, and a grid tile
keeps its last frame through its own capture's round trip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 09:18:32 +02:00
parent e39a750749
commit eb5d982c38
7 changed files with 344 additions and 98 deletions
File diff suppressed because one or more lines are too long
+97 -48
View File
@@ -49,7 +49,8 @@
*/
(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;
// How much of a replay is queued in xterm at once: a 1 MiB load goes in one
@@ -666,10 +667,11 @@
// a closed socket. Called from each load's own finally, just before
// _endBufferLoad() starts any trailing refresh.
_stampMarkerIfOwed() {
// A trailing refresh is about to clear() synchronously, while xterm parses
// a write() on a later tick: a marker written here would land in the
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
// The refresh re-owes the marker on a closed socket and stamps it itself.
// A trailing refresh is about to run, and it settles the marker itself:
// a replay's queued `\x1bc` would wipe one written here (it re-owes the
// marker on a closed socket and stamps it below the replay), and a refresh
// 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;
const owed = this._markerOwed;
this._markerOwed = false;
@@ -683,11 +685,12 @@
}
// Fetches and writes the session's current scrollback. Used both by
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
// (above). The primary pane's own _onSessionNeedsRefresh (app.js) is
// scoped to `this.activeSessionId` and clears/rewrites the primary
// terminal, neither of which applies to this independent pane, so this is
// a standalone equivalent rather than a call into it.
// connect() (initial load) and by the refresh frames (`{t:'r'}`, `{t:'c'}`)
// and a reconnect (_refreshBuffer). The primary pane's own
// _onSessionNeedsRefresh (app.js) is scoped to `this.activeSessionId` and
// rewrites the primary terminal, neither of which applies to this
// 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 /
// _onSessionNeedsRefresh): a shell session can retain hundreds of
@@ -699,6 +702,21 @@
// wrapper (constants.js), which already prefixes CodemanBase, unlike the
// 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
// (writeChunked resolves after its last chunk), so two replays can never
// interleave their chunks into one terminal. A second call while one is
@@ -709,42 +727,60 @@
this._bufferLoading = true;
await this._runLoad(refresh ? 'refresh' : 'initial', async () => {
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 {
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';
let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
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;
const budget = global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000;
const timer = controller ? setTimeout(() => controller.abort(), budget) : null;
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000);
let payload;
try {
const res = await fetch(
`/api/sessions/${this.sessionId}/terminal?${query}`,
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._liveQueue = [];
if (shell || this.boundedLoad) armDeadline(HISTORY_PULL_TIMEOUT_MS);
}
payload = (await res.json())?.data ?? {};
} finally {
clearTimeout(timer);
clearTimeout(abortTimer);
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(
this.terminal,
payload.terminalBuffer,
@@ -756,7 +792,12 @@
} catch {
/* Best-effort: live output still arrives once the socket connects. */
} finally {
clearTimeout(abortTimer);
this._loadAbort = null;
this._loadRunning = false;
// Held frames before the marker, so the marker stays the last thing on
// screen (see _pullHistory()).
this._flushLiveQueue(replayed ? capturedAt : 0);
this._stampMarkerIfOwed();
this._endBufferLoad();
}
@@ -800,16 +841,29 @@
}
}
// Live terminal output. Written straight through, except while a history
// pull is replaying: a capture is current only up to the instant tmux took
// it, so a frame arriving mid-replay is held with its arrival time and
// replayed behind the snapshot by _pullHistory() (the primary pane's
// _finishBufferLoad `since` rule), never written underneath it.
// Live terminal output. Written straight through, except while a refresh or
// a history pull is replaying: a capture is current only up to the instant
// tmux took it, so a frame arriving mid-replay is held with its arrival time
// and written behind the snapshot by that load's _flushLiveQueue() (the
// primary pane's _finishBufferLoad `since` rule), never underneath it.
_onLiveOutput(data) {
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
else this.terminal?.write(data);
}
// 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.
_flushLiveQueue(cutoff) {
const queued = this._liveQueue ?? [];
this._liveQueue = null;
for (const entry of queued) {
if (entry.at < cutoff) continue;
this.terminal?.write(entry.data);
}
}
// 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
@@ -1097,15 +1151,9 @@
clearTimeout(abortTimer);
this._loadAbort = null;
this._loadRunning = false;
const queued = this._liveQueue ?? [];
this._liveQueue = null;
// 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.
const cutoff = replayed ? capturedAt : 0;
for (const entry of queued) {
if (entry.at < cutoff) continue;
this.terminal?.write(entry.data);
}
this._flushLiveQueue(replayed ? capturedAt : 0);
// Settled after the queue flush so the marker is the last thing on
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
@@ -1128,12 +1176,13 @@
return `&lines=${this.scrollback + (this.terminal?.rows || 0)}`;
}
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
// frames in a row must not start two concurrent replays, each clearing
// the terminal under the other's chunked write. A refresh that arrives
// mid-replay is COALESCED into one trailing re-run rather than ignored:
// the in-flight fetch may predate the drop the new frame is reporting,
// and no further frame is coming to correct stale content.
// The refresh path (`{t:'r'}`, `{t:'c'}`, a reconnect): fetch, then reset
// in-stream and replay (_loadBuffer). Two refresh frames in a row must not
// start two concurrent replays, each resetting the terminal under the
// other's chunked write. A refresh that arrives mid-replay is COALESCED
// into one trailing re-run rather than ignored: the in-flight fetch may
// predate the drop the new frame is reporting, and no further frame is
// coming to correct stale content.
_refreshBuffer() {
if (this._bufferLoading) {
this._bufferRefreshPending = true;
+6 -2
View File
@@ -64,7 +64,8 @@ export class FakeTerminal {
/**
* 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
* 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
* back). The viewport follows the bottom. Off, `baseY` stays where a test
* puts it.
@@ -155,7 +156,10 @@ export class FakeTerminal {
// (its callback) that everything before it has been parsed.
if (data) this.writes.push(data);
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();
}
if (!this.holdParse) cb?.();
+8 -6
View File
@@ -333,12 +333,14 @@ describe('TerminalTile reconnects after a transient drop', () => {
ws2.open();
await settle();
// The refresh cleared the pane and replayed the current screen, and nothing
// after that clear is a marker: the pane is healthy again.
const lastClear = term.writes.lastIndexOf('<CLEAR>');
expect(lastClear).toBeGreaterThan(-1);
expect(term.writes.slice(lastClear)).toContain('fresh screen');
expect(term.writes.slice(lastClear).some(isMarker)).toBe(false);
// The refresh reset the pane in-stream (never xterm's clear()) and replayed
// the current screen, and nothing after that reset is a marker: the pane is
// healthy again.
expect(term.writes).not.toContain('<CLEAR>');
const lastReset = term.writes.lastIndexOf('\x1bc');
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.ws).toBe(ws2);
});
+8 -1
View File
@@ -436,11 +436,18 @@ describe('rows the tile pushed above the screen itself are not history', () => {
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);
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' });
await vi.advanceTimersByTimeAsync(0);
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'fresh screen']);
expect(term.buffer.active.baseY).toBe(0);
ws.receive({ t: 'o', d: '\r\n'.repeat(term.rows + 1) }); // two real lines above the screen
expect(term.buffer.active.baseY).toBe(2);
+190 -32
View File
@@ -203,6 +203,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. */
const settle = () => new Promise((r) => setTimeout(r, 0));
@@ -236,17 +240,25 @@ describe('TerminalTile.destroy()', () => {
});
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();
fetchMock.mockResolvedValueOnce(jsonResponse('one'));
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();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
expect(pane.terminal.clear).not.toHaveBeenCalled();
// The second argument carries the load's deadline (an AbortSignal).
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);
});
@@ -260,35 +272,37 @@ describe('TerminalTile server-refresh single-flight', () => {
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 first = deferred<ReturnType<typeof jsonResponse>>();
const second = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
pane._refreshBuffer();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
expect(resets(pane)).toBe(0); // fetch first
expect(fetchMock).toHaveBeenCalledTimes(1);
// Two more frames while the first replay is still in flight.
pane._refreshBuffer();
pane._refreshBuffer();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
expect(resets(pane)).toBe(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(pane._bufferRefreshPending).toBe(true);
first.resolve(jsonResponse('replay-1'));
await settle();
expect(pane.terminal.write).toHaveBeenCalledWith('replay-1');
// Exactly one trailing re-run for the two coalesced frames, not two.
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
expect(screenWrites(pane)).toEqual([RIS, 'replay-1']);
// Exactly one trailing re-run for the two coalesced frames, not two; it is
// fetching, so it has not reset anything yet.
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(resets(pane)).toBe(1);
second.resolve(jsonResponse('replay-2'));
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(pane._bufferLoading).toBe(false);
expect(pane._bufferRefreshPending).toBe(false);
@@ -303,9 +317,11 @@ describe('TerminalTile server-refresh single-flight', () => {
pane._refreshBuffer();
await settle();
// Every slice queued at once, then the empty write whose callback ends the
// replay: nothing waits for an animation frame.
// The reset, then every slice queued at once, then the empty write whose
// 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([
RIS.length,
TERMINAL_CHUNK_SIZE,
TERMINAL_CHUNK_SIZE,
5,
@@ -314,17 +330,19 @@ describe('TerminalTile server-refresh single-flight', () => {
// xterm is still parsing: the replay, and with it the flag, is not done.
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.
pane._refreshBuffer();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
expect(resets(pane)).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
fetchMock.mockResolvedValueOnce(jsonResponse('after'));
xterm.parse();
await settle();
// Parsed: the coalesced refresh runs now, once.
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
// Parsed: the coalesced refresh runs now, once, and resets before its replay.
expect(resets(pane)).toBe(2);
expect(screenWrites(pane).slice(-2)).toEqual([RIS, 'after']);
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(pane._bufferLoading).toBe(true);
@@ -344,7 +362,10 @@ describe('TerminalTile server-refresh single-flight', () => {
pane._refreshBuffer();
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);
xterm.parse();
@@ -403,6 +424,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', () => {
it('a shell pane at the top pulls a bounded window of full history and replays it', async () => {
const pane = makePane('shell');
@@ -659,15 +811,16 @@ describe('TerminalTile scroll-to-top history pull', () => {
pane._maybeLoadMoreHistory();
pane._refreshBuffer();
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(pane.terminal.write).not.toHaveBeenCalled();
expect(pane._bufferRefreshPending).toBe(true);
response.resolve(jsonResponse(rowsOf(30)));
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(pane.terminal.write).toHaveBeenCalledWith('refreshed');
});
// The server's `{t:'c'}` means "refresh after startup" (its one emitter is a
@@ -945,10 +1098,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 () => {
// 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
// when R2's synchronous clear() runs, and would land above R2's replay. The
// write mock records every stamp whatever clear() does, so counting marker
// writes pins that R1 never stamps (the async-parse test below shows why).
// R2 instead of stamping it: R2 settles it, below its own replay. The write
// mock records every stamp, so counting marker writes pins that R1 never
// stamps (the async-parse test below shows the screen).
const pane = makePane('shell');
pane._wsClosed = true;
const first = deferred<ReturnType<typeof jsonResponse>>();
@@ -959,8 +1111,10 @@ describe('TerminalTile scroll-to-top history pull', () => {
pane._refreshBuffer(); // coalesced into the trailing R2
first.resolve(jsonResponse('first'));
await settle();
// R1 settled its marker, then R2 cleared and is still fetching.
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
// R1 replayed and left the marker to R2, which is still fetching: it has
// 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'));
await settle();
@@ -1047,8 +1201,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 () => {
// The refresh's clear() runs after the pull's finally block has written the
// marker, so without a re-stamp the dead pane would look current again.
// The refresh's reset lands after the pull's finally block, so without a
// re-stamp the dead pane would look current again.
const pane = makePane('shell');
const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('refreshed'));
@@ -1062,7 +1216,9 @@ describe('TerminalTile scroll-to-top history pull', () => {
await settle();
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(isMarker(writes.at(-1))).toBe(true);
expect(writes.lastIndexOf('refreshed')).toBeLessThan(writes.length - 1);
@@ -1095,9 +1251,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) => {
// Real xterm queues write() and parses it on a later tick (WriteBuffer's
// setTimeout), while clear() rewrites the buffer at once. The default fake
// applies writes synchronously and so cannot show a marker overtaken by a
// trailing refresh's clear(): parsed after it, that marker sat above the
// refresh's replay as a second, stale copy.
// applies writes synchronously and so cannot show ordering on the real
// screen: back when a trailing refresh began with a clear(), a marker parsed
// 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 screen: string[] = [];
const pending: Array<{ data: string; done?: () => void }> = [];
@@ -1120,6 +1277,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
await drive(pane);
for (let i = 0; i < 5; i++) await settle();
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(pane._bufferLoading).toBe(false);
expect(screen.filter(isMarker)).toHaveLength(1);
expect(screen.at(-1)).toSatisfy(isMarker);
+34 -8
View File
@@ -13,8 +13,9 @@
* for N tiles reconnecting together;
* - the focused tile goes first, then reading order, and a history pull (the
* 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
* last frame (the clear happens at its turn);
* - `{t:'r'}` and `{t:'c'}` go through the same queue, and a tile keeps its
* 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
* load is running aborts its fetch so the queue moves on;
* - a load that never answers is cut off by its deadline;
@@ -270,12 +271,14 @@ describe('refreshes', () => {
await settle();
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a']);
// b has not been cleared: it shows its last frame until its load runs.
expect(b.terminal?.writes).not.toContain('<CLEAR>');
// b has not been reset: it shows its last frame until its load runs.
const before = [...(b.terminal?.writes ?? [])];
expect(before).not.toContain('<CLEAR>');
await drain('fresh');
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 () => {
@@ -296,6 +299,28 @@ describe('refreshes', () => {
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 () => {
const { tiles } = makeGrid(['a', 'b', 'sh'], { modes: { sh: 'shell' } });
await connectAll(tiles);
@@ -326,10 +351,11 @@ describe('refreshes', () => {
const markers = () => (b.terminal?.writes ?? []).filter((w) => w.includes('[disconnected')).length;
expect(markers()).toBe(1);
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 ?? [];
expect(writes.slice(writes.lastIndexOf('<CLEAR>'))).toEqual([
'<CLEAR>',
expect(writes).not.toContain('<CLEAR>');
expect(writes.slice(writes.lastIndexOf('\x1bc'))).toEqual([
'\x1bc',
'fresh',
expect.stringContaining('[disconnected'),
]);