Merge pull request #331 from dignfei/fix/shell-history-performance

fix(terminal): bound shell history replay
This commit is contained in:
Ark0N
2026-08-21 20:23:17 +02:00
committed by GitHub
16 changed files with 414 additions and 138 deletions
+40 -41
View File
@@ -3046,12 +3046,12 @@ Object.assign(CodemanApp.prototype, {
/**
* Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the
* buffer while scrolling up is the user reaching for history the browser does
* not have, so pull the rest of tmux's scrollback (issue #205, see
* _maybeRefetchFullHistory). Must be called AFTER scrollLines(), since the
* check is on the resulting position, and it is deliberately not folded into
* _noteTerminalUserScroll for exactly that reason. Cheap: one integer compare
* per scroll event, and the pull itself is cooldown-guarded.
* buffer while scrolling up gives the app a chance to pull the rest of tmux's
* scrollback (issue #205, see _maybeRefetchFullHistory). Shell sessions decline
* automatic pulls because their captures can be large; their banner button is
* the explicit path. Must be called AFTER scrollLines(), since the check is on
* the resulting position, and it is deliberately not folded into
* _noteTerminalUserScroll for exactly that reason.
*/
_maybeLoadMoreHistoryOnScroll(lines) {
if (lines >= 0) return;
@@ -3605,7 +3605,9 @@ Object.assign(CodemanApp.prototype, {
}
const buffer = this.terminal.buffer.active;
const totalLines = buffer.baseY + buffer.length;
// `length` already includes scrollback + viewport rows. Adding baseY scans
// every scrollback row twice, starting with thousands of out-of-range calls.
const totalLines = buffer.length;
let lastNonEmptyLine = -1;
for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex--) {
@@ -3635,8 +3637,8 @@ Object.assign(CodemanApp.prototype, {
* Uses _safeYield to spread work across frames; falls back to setTimeout
* and a tick-Worker so progress continues on occluded / idle-throttled tabs.
* @param {string} buffer - The full terminal buffer to write
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
* @returns {Promise<void>} - Resolves when all chunks written
* @param {number} chunkSize - Size of each chunk (default 32KB)
* @returns {Promise<{parsedAt: number, bufferLength: number, completed: boolean}>} Parse marker snapshot
*/
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
@@ -3645,9 +3647,14 @@ Object.assign(CodemanApp.prototype, {
const bufferLoadOwner = this._beginBufferLoad(loadOwner);
return new Promise((resolve) => {
const parseSnapshot = (completed = this._chunkedWriteGen === writeGen) => ({
parsedAt: performance.now(),
bufferLength: this.terminal?.buffer?.active?.length ?? 0,
completed,
});
if (!buffer || buffer.length === 0) {
this._finishBufferLoad(bufferLoadOwner);
resolve();
resolve(parseSnapshot());
return;
}
@@ -3655,53 +3662,45 @@ Object.assign(CodemanApp.prototype, {
// (from historical SSE data that was stored with markers)
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
const finish = () => {
// Only finish if we're still the active write — a newer write owns buffer load state
if (this._chunkedWriteGen === writeGen) {
this._finishBufferLoad(bufferLoadOwner);
}
resolve();
};
// For small buffers, write directly — single-frame render is fast enough
if (cleanBuffer.length <= chunkSize) {
this.terminal.write(cleanBuffer, finish);
this.terminal.write(cleanBuffer, () => resolve(parseSnapshot()));
// The write is now ordered in xterm's queue. Release live output before
// parsing completes; subsequent writes stay behind it without being lost.
this._finishBufferLoad(bufferLoadOwner);
return;
}
// Large buffers: write in chunks across animation frames.
// Each 32KB chunk keeps per-frame WebGL render work under ~5ms,
// avoiding GPU stalls without needing to toggle the renderer.
// Large buffers: enqueue paced chunks, then append an empty marker whose
// callback fires after xterm parses every preceding chunk. The live-output
// gate is released as soon as that marker is ordered, not after parsing, so
// new output queues behind history instead of being held or dropped.
let offset = 0;
const _chunkStart = performance.now();
let _chunkCount = 0;
const writeChunk = () => {
// Abort if a newer chunked write started (user switched tabs)
if (this._chunkedWriteGen !== writeGen) {
resolve();
resolve(parseSnapshot(false));
return;
}
if (offset >= cleanBuffer.length) {
const _totalMs = performance.now() - _chunkStart;
console.log(
`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`
);
// Wait one more frame for xterm to finish rendering before resolving
this._safeYield(finish);
return;
}
const _ct0 = performance.now();
const chunk = cleanBuffer.slice(offset, offset + chunkSize);
this.terminal.write(chunk);
const _cdt = performance.now() - _ct0;
offset += chunk.length;
_chunkCount++;
if (_cdt > 50)
console.warn(
`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`
);
offset += chunkSize;
this.terminal.write(chunk);
if (offset >= cleanBuffer.length) {
this.terminal.write('', () => {
const result = parseSnapshot();
const _totalMs = result.parsedAt - _chunkStart;
console.log(
`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms parsed`
);
resolve(result);
});
this._finishBufferLoad(bufferLoadOwner);
return;
}
// Schedule next chunk; rAF if possible, else setTimeout/Worker
// fallback so progress doesn't stall on occluded/unfocused windows.