perf(tiles): replay a capture at xterm's own pace, not one slice a frame

A tile's replay (writeChunked) wrote its capture 32 KB per animation
frame, so a 1 MiB load took about a second of frames, and in the grid the
load queue's slot was held across all of it: tile N+1's capture waited for
tile N's last frame. xterm 6 already parses its write queue in 12 ms
slices and yields between them, so the slices now all go in at once (up
to a 1 MiB window, since xterm's queue throws past 50 MB and Pane B's
unbounded full=1 capture can reach the server's 32 MB) and the replay
resolves on the callback of an empty write queued behind them, i.e. once
xterm has parsed the last slice. The single-flight flag is still held for
the whole replay. A disposed xterm never runs that callback, so destroy()
now settles a replay in progress: a removed tile can no longer hold its
flag or the grid's one load queue. Queued up front, the capture also stays
in one piece during a refresh: live output written meanwhile lands after
it, not between two of its slices.

Measured (tileperf, 6 printing shells with 1 MiB histories, headless,
n=3 interleaved A/B against the starting file, load 8.6 to 11.8):
- grid fresh open, 6 tiles, all painted: 5.10 s -> 2.57 s (-50%);
  restore after reload: 6.49 s -> 3.86 s (-41%); per-tile replay
  669 to 734 ms -> 298 to 321 ms (median).
- Same work in half the time: frames over 20 ms 26% -> 40% of the
  (shorter) load window, about 86 -> 62 slow frames in all; longest long
  task on restore 304 -> 227 ms; server event-loop delay unchanged
  (max 111 to 122 -> 122 to 134 ms, one capture in flight throughout).
- Split Pane B (the other TerminalTile) with the main terminal on WebGL
  and its long-task guard armed: load 1.6 to 3.8 s -> 0.8 to 1.7 s over
  15 loads each; 0 long tasks of 200 ms or more either way, the guard
  never tripped. With an unbounded full=1 capture (about 21k lines):
  2.5 to 3.4 s -> 1.9 to 2.6 s, 0 long tasks of 200 ms or more.
- At checkpoint 1 (equivalent patch, n=3 to 6): fresh 6.4 -> 2.7 s,
  restore 8.9 -> 4.2 s, TUI-style reconnect 11.2 to 11.8 -> 6.2 s.

Tests: the replay queues every slice at once and holds the flag until
xterm has parsed it; a replay larger than the window goes one window at a
time; a pane destroyed mid-parse settles at once; in the grid, a tile
destroyed while xterm still parses its replay releases the queue and the
next tile loads (fake xterm whose callbacks never run). The rAF-driven
tests now hold the parse callbacks instead. All mutation-checked (no
settle in destroy, settle before the parse, no window). Browser
split-pane-terminal: same 1 failed / 2 passed as at the starting HEAD
(the failure is in the test's own setup, before connect).

Scope: PR 1 (terminal-tile.js writeChunked and destroy(); Pane B replays
the same way). Moving it onto PR 1 needs its two call sites adapted
(PR 1 has no _runLoad yet) and leaves the tile-grid-load-queue.test.ts
hunk with PR 2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 06:00:41 +02:00
parent 00440c02e1
commit 5650be5200
3 changed files with 195 additions and 63 deletions
+63 -30
View File
@@ -24,42 +24,58 @@
// How long a scroll-to-top history pull may hold Pane B's live output.
const HISTORY_PULL_TIMEOUT_MS = 10000;
// How much of a replay is queued in xterm at once: a 1 MiB load goes in one
// window, and xterm's write queue throws past 50 MB, which an unbounded
// `full=1` capture (up to the server's 32 MB) would otherwise come near.
const REPLAY_WINDOW_BYTES = 1024 * 1024;
/**
* Minimal chunked write for Pane B's own xterm instance — write() in
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
* giant synchronous write that blocks the main thread while parsing a long
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
* (terminal-ui.js): that one is wired into session-switch generation
* counters and the live-output gate this simpler, independently
* created/destroyed pane has no equivalent of.
* Replays a capture into a pane's own xterm: TERMINAL_CHUNK_SIZE slices, all
* of a window queued at once. xterm 6 parses its write queue in 12 ms slices
* and yields between them, so a long scrollback never becomes a long task,
* and it is not held to one slice per animation frame either (that pacing
* took about a second per 1 MiB, with the grid's load queue waiting behind
* it). Queued up front, the capture also stays in one piece: live output
* written during the parse lands after it, not between two of its slices.
* Deliberately NOT the primary pane's chunkedTerminalWrite (terminal-ui.js):
* that one is wired into session-switch generation counters and the
* live-output gate this simpler, independently created/destroyed pane has no
* equivalent of.
*
* Resolves once xterm has parsed the last slice (a write's callback runs once
* everything queued before it is parsed), so _loadBuffer() below holds its
* single-flight flag across the whole replay. A disposed xterm never runs its
* callbacks, so `setCancel` hands the owner a function that settles the
* replay at once: destroy() calls it, or the pane's flag and the grid's load
* queue would wait forever.
*/
function writeChunked(terminal, buffer, isDestroyed) {
if (!buffer) return Promise.resolve();
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
terminal.write(buffer);
return Promise.resolve();
}
// Resolves once the LAST chunk is written (or the pane was destroyed
// mid-replay), so _loadBuffer() below can hold its single-flight flag
// across the whole replay rather than just the fetch that precedes it.
function writeChunked(terminal, buffer, isDestroyed, setCancel) {
if (!buffer || !terminal) return Promise.resolve();
return new Promise((resolve) => {
let offset = 0;
const writeNext = () => {
if (isDestroyed() || !terminal) {
resolve();
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
setCancel?.(null);
resolve();
};
const writeWindow = () => {
if (settled) return;
if (isDestroyed()) {
settle();
return;
}
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
offset += chunk.length;
terminal.write(chunk);
if (offset < buffer.length) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
else setTimeout(writeNext, 16);
} else {
resolve();
const end = Math.min(buffer.length, offset + REPLAY_WINDOW_BYTES);
while (offset < end) {
const chunk = buffer.slice(offset, Math.min(end, offset + TERMINAL_CHUNK_SIZE));
offset += chunk.length;
terminal.write(chunk);
}
terminal.write('', offset < buffer.length ? writeWindow : settle);
};
writeNext();
setCancel?.(settle);
writeWindow();
});
}
@@ -103,6 +119,9 @@
// Aborts the running load's fetch; destroy() uses it so a removed tile
// does not hold the owner's queue for a whole deadline.
this._loadAbort = null;
// Settles a replay xterm is still parsing (writeChunked): destroy() calls
// it, because a disposed xterm never runs the callback the replay awaits.
this._cancelReplay = null;
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
// `_liveQueue` is non-null from the pull's response until its finally
// block: live frames are held there with their arrival time instead of
@@ -640,7 +659,12 @@
this._loadAbort = null;
}
if (payload.terminalBuffer && this.terminal) {
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
await writeChunked(
this.terminal,
payload.terminalBuffer,
() => this._destroyed,
(cancel) => (this._cancelReplay = cancel)
);
}
} catch {
/* Best-effort: live output still arrives once the socket connects. */
@@ -825,7 +849,12 @@
term.write('\x1bc');
replayed = true;
if (this._wsClosed) this._markerOwed = true;
await writeChunked(term, buffer, () => this._destroyed);
await writeChunked(
term,
buffer,
() => this._destroyed,
(cancel) => (this._cancelReplay = cancel)
);
if (this._destroyed || !this.terminal) return;
// xterm parses asynchronously: an empty write's callback fires only
// after everything before it, so the row count below is the settled one.
@@ -964,6 +993,10 @@
/* Already settled. */
}
this._loadAbort = null;
// Likewise a replay still parsing: the xterm is disposed below, so the
// write callback it waits for would never come.
this._cancelReplay?.();
this._cancelReplay = null;
if (this._onWheel) {
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
this._onWheel = null;