mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
fix(client): multi-primitive yield for write pacing (#85)
When the data-pacing path (chunkedTerminalWrite + deferred path of flushPendingWrites) schedules its next chunk via requestAnimationFrame alone, terminal output stalls indefinitely if rAF is starved. Three real-world scenarios reproduce this in Chromium: 1. Window is occluded (fully covered by another window, or on a monitor that has gone to sleep). rAF drops to ~0Hz. 2. Tab is idle-throttled (no user interaction for ~5 min). Chromium intensive-throttling clamps setTimeout to 1Hz too. 3. Tab is in a background window. Both rAF and setTimeout slow to a crawl. Replace the rAF-only scheduling with a _safeYield helper that races three primitives in parallel: - requestAnimationFrame (primary, fires at compositor rate). - setTimeout(50) (fallback for visible-but-occluded windows). - Worker postMessage tick (fallback for idle-throttled and background tabs; Workers are not subject to main-thread throttling — this is the React Scheduler trick). The first one to fire wins via a `done` guard; the others become no-ops. The Worker is built lazily on first call (4 lines of inline JS via Blob URL); if Worker construction throws we silently fall back to the other two primitives. Replaces 6 requestAnimationFrame callsites that participate in data pacing: - 3 flushPendingWrites scheduling sites (live + deferred paths). - 3 chunkedTerminalWrite sites (initial chunk, next-chunk loop, final finish-callback). True animation use cases (scroll loop in scrollToBottom, fit-addon reflow) stay on plain requestAnimationFrame — they are correctly throttled when the user is not looking, by design. File: src/web/public/terminal-ui.js (+70/-8). Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
00721069e1
commit
edd494ec5f
@@ -1151,7 +1151,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
if (!this.writeFrameScheduled) {
|
if (!this.writeFrameScheduled) {
|
||||||
this.writeFrameScheduled = true;
|
this.writeFrameScheduled = true;
|
||||||
requestAnimationFrame(() => {
|
this._safeYield(() => {
|
||||||
// xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers
|
// xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers
|
||||||
// content between 2026h/2026l and renders atomically. No need for
|
// content between 2026h/2026l and renders atomically. No need for
|
||||||
// client-side incomplete-block detection; just flush every frame.
|
// client-side incomplete-block detection; just flush every frame.
|
||||||
@@ -1176,7 +1176,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Trigger a normal flush
|
// Trigger a normal flush
|
||||||
if (!this.writeFrameScheduled) {
|
if (!this.writeFrameScheduled) {
|
||||||
this.writeFrameScheduled = true;
|
this.writeFrameScheduled = true;
|
||||||
requestAnimationFrame(() => {
|
this._safeYield(() => {
|
||||||
this.flushPendingWrites();
|
this.flushPendingWrites();
|
||||||
this.writeFrameScheduled = false;
|
this.writeFrameScheduled = false;
|
||||||
});
|
});
|
||||||
@@ -1264,7 +1264,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
deferred = true;
|
deferred = true;
|
||||||
if (!this.writeFrameScheduled) {
|
if (!this.writeFrameScheduled) {
|
||||||
this.writeFrameScheduled = true;
|
this.writeFrameScheduled = true;
|
||||||
requestAnimationFrame(() => {
|
this._safeYield(() => {
|
||||||
this.flushPendingWrites();
|
this.flushPendingWrites();
|
||||||
this.writeFrameScheduled = false;
|
this.writeFrameScheduled = false;
|
||||||
});
|
});
|
||||||
@@ -1336,9 +1336,70 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule cb via THREE racing primitives so data-pacing makes progress
|
||||||
|
* regardless of which scheduling primitive Chrome is throttling:
|
||||||
|
* 1. requestAnimationFrame — primary, fires at compositor rate
|
||||||
|
* (may be 0Hz when window is occluded / on backgrounded monitor).
|
||||||
|
* 2. setTimeout(50) — fallback for occluded-but-visible windows
|
||||||
|
* (clamped to 1Hz by Chrome's intensive wake-up throttling
|
||||||
|
* after ~5 min of no user interaction).
|
||||||
|
* 3. Worker postMessage — bypasses intensive throttling entirely;
|
||||||
|
* Workers are not subject to background-tab / idle-tab throttling
|
||||||
|
* (the React Scheduler trick).
|
||||||
|
* Whichever fires first wins; the others are no-ops thanks to the
|
||||||
|
* `done` guard. Without all three, chunkedTerminalWrite and the deferred
|
||||||
|
* path of flushPendingWrites stall indefinitely when the substrate is
|
||||||
|
* degraded (visible-but-occluded window, OR idle-throttled tab, OR
|
||||||
|
* background tab on a different monitor).
|
||||||
|
*/
|
||||||
|
_safeYield(cb) {
|
||||||
|
let done = false;
|
||||||
|
const wrapped = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
cb();
|
||||||
|
};
|
||||||
|
requestAnimationFrame(wrapped);
|
||||||
|
setTimeout(wrapped, 50);
|
||||||
|
this._workerYield(wrapped);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy-init a tiny "tick" worker whose only job is to postMessage back to
|
||||||
|
* us as fast as possible, escaping main-thread throttling. The worker's
|
||||||
|
* setTimeout(0) is not subject to Chrome's intensive wake-up throttling
|
||||||
|
* even when the parent tab is idle.
|
||||||
|
*/
|
||||||
|
_workerYield(cb) {
|
||||||
|
try {
|
||||||
|
if (this._yieldWorker === undefined) {
|
||||||
|
// First call: build the worker (or mark unavailable). Each
|
||||||
|
// postMessage in produces exactly one postMessage out — we count on
|
||||||
|
// FIFO 1:1 to drain queue entries.
|
||||||
|
const src = "onmessage=()=>setTimeout(()=>postMessage(0),0);";
|
||||||
|
const blob = new Blob([src], { type: 'application/javascript' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
this._yieldWorker = new Worker(url);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
this._yieldQueue = [];
|
||||||
|
this._yieldWorker.onmessage = () => {
|
||||||
|
const fn = this._yieldQueue.shift();
|
||||||
|
if (fn) fn();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!this._yieldWorker) return;
|
||||||
|
this._yieldQueue.push(cb);
|
||||||
|
this._yieldWorker.postMessage(0);
|
||||||
|
} catch {
|
||||||
|
this._yieldWorker = null; // mark unavailable, future calls skip
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write large buffer to terminal in chunks to avoid UI jank.
|
* Write large buffer to terminal in chunks to avoid UI jank.
|
||||||
* Uses requestAnimationFrame to spread work across frames.
|
* 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 {string} buffer - The full terminal buffer to write
|
||||||
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
|
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
|
||||||
* @returns {Promise<void>} - Resolves when all chunks written
|
* @returns {Promise<void>} - Resolves when all chunks written
|
||||||
@@ -1397,7 +1458,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`
|
`[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
|
// Wait one more frame for xterm to finish rendering before resolving
|
||||||
requestAnimationFrame(finish);
|
this._safeYield(finish);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1412,12 +1473,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
);
|
);
|
||||||
offset += chunkSize;
|
offset += chunkSize;
|
||||||
|
|
||||||
// Schedule next chunk on next frame
|
// Schedule next chunk; rAF if possible, else setTimeout/Worker
|
||||||
requestAnimationFrame(writeChunk);
|
// fallback so progress doesn't stall on occluded/unfocused windows.
|
||||||
|
this._safeYield(writeChunk);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start writing
|
// Start writing
|
||||||
requestAnimationFrame(writeChunk);
|
this._safeYield(writeChunk);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user