mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user