mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
fix(tiles): cap each tile's live-output backlog and recover dropped output
The server applies no WebSocket backpressure (16 KB / 8 ms batches, no bufferedAmount check), and a tile wrote every live frame straight into xterm. A flood a tile could not parse as fast (a shell tile running cat on a huge log) piled up in xterm's own write queue without bound, on a main thread up to six tiles share, until xterm's WriteBuffer threw past 50M code units; onmessage's empty catch then dropped every frame silently and nothing recaptured the screen. The primary pane caps its queues and drops then recaptures (_onSessionTerminal, _scheduleDroppedOutputRecovery). Each tile now writes live output through _writeLive: - unparsed code units are counted, each write's callback counting its own back down; frames held behind a replay (_liveQueue) count too; - the budget is TerminalTile.LIVE_BACKLOG_BUDGET, 4 MiB, deliberately not the primary pane's 128 KB: that caps its own rAF-paced queues, while xterm itself paces a tile, and a tight cap would trip on ordinary bursts and blank-and-reload the tile over and over; - past it a frame is dropped, the tile stops writing onto the hole, and one refresh is scheduled, debounced and bounded by the primary pane's own rule (CodemanDroppedOutput: 2 s, DROP_RECOVERY_MAX_ATTEMPTS, never retried after a deadline abort). It is an ordinary refresh, so single-flight, bounded by lines=/tail= and paced by the grid's TileLoadQueue. The flag clears once a capture taken after the last dropped frame has replayed; - a write that throws is the same drop, never a "malformed frame"; - past the bound the flag is released, so a tile is never left frozen; - a reconnect starts the accounting over (an epoch makes callbacks from before it count nothing) and drops a pending recovery, since its own refresh replaces the screen; destroy() cancels it. The live-queue flush after a pull or a refresh goes through the same path, so a throwing write there cannot skip the load's marker and trailing refresh either. Tests (input harness, real constants and fake timers): the default budget lets a 1 MiB unparsed burst through, parsed bytes stop counting, a trip stops writing and ONE debounced refresh recaptures, a write throw takes the same recovery, a hole in the held queue is recovered by another refresh, bounded retries then release, no retry after a deadline, a reconnect resets the count, and destroy cancels. The fake xterm can now hold and release parses and throw on a write. Live writes now carry a callback, so the unit tests match them on the data argument (a `.not.toHaveBeenCalledWith(data)` would otherwise pass for nothing). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -185,6 +185,10 @@ const isMarker = (data: unknown) => typeof data === 'string' && data.includes('[
|
||||
*/
|
||||
const screenWrites = (pane: { terminal: FakeTerminal }) =>
|
||||
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:
|
||||
@@ -738,7 +742,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
|
||||
// keeps painting during the round trip), and the replay then replaces it.
|
||||
clock = 1;
|
||||
pane._onLiveOutput('early');
|
||||
expect(term.write).toHaveBeenCalledWith('early');
|
||||
expect(screenWrites(pane)).toContain('early');
|
||||
await settle();
|
||||
|
||||
// 200 rows (more than the pane holds, so it replays) of 400 columns each:
|
||||
@@ -748,12 +752,14 @@ describe('TerminalTile scroll-to-top history pull', () => {
|
||||
clock = 2; // the response arrives: this is the cutoff
|
||||
response.resolve(jsonResponse(bigReplay));
|
||||
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.
|
||||
clock = 3;
|
||||
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.
|
||||
xterm.parse();
|
||||
@@ -778,12 +784,12 @@ describe('TerminalTile scroll-to-top history pull', () => {
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
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
|
||||
await settle();
|
||||
|
||||
// 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 () => {
|
||||
@@ -799,9 +805,9 @@ describe('TerminalTile scroll-to-top history pull', () => {
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('held');
|
||||
expect(screenWrites(pane)).toContain('held');
|
||||
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 () => {
|
||||
@@ -921,7 +927,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
expect(pane.terminal).toBeNull();
|
||||
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 () => {
|
||||
@@ -937,7 +943,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user