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
+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);