mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 05:29:42 +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) {
|
||||
this.writeFrameScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._safeYield(() => {
|
||||
// xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers
|
||||
// content between 2026h/2026l and renders atomically. No need for
|
||||
// client-side incomplete-block detection; just flush every frame.
|
||||
@@ -1176,7 +1176,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Trigger a normal flush
|
||||
if (!this.writeFrameScheduled) {
|
||||
this.writeFrameScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._safeYield(() => {
|
||||
this.flushPendingWrites();
|
||||
this.writeFrameScheduled = false;
|
||||
});
|
||||
@@ -1264,7 +1264,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
deferred = true;
|
||||
if (!this.writeFrameScheduled) {
|
||||
this.writeFrameScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._safeYield(() => {
|
||||
this.flushPendingWrites();
|
||||
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.
|
||||
* 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 {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
|
||||
* @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`
|
||||
);
|
||||
// Wait one more frame for xterm to finish rendering before resolving
|
||||
requestAnimationFrame(finish);
|
||||
this._safeYield(finish);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1412,12 +1473,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
);
|
||||
offset += chunkSize;
|
||||
|
||||
// Schedule next chunk on next frame
|
||||
requestAnimationFrame(writeChunk);
|
||||
// Schedule next chunk; rAF if possible, else setTimeout/Worker
|
||||
// fallback so progress doesn't stall on occluded/unfocused windows.
|
||||
this._safeYield(writeChunk);
|
||||
};
|
||||
|
||||
// Start writing
|
||||
requestAnimationFrame(writeChunk);
|
||||
this._safeYield(writeChunk);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user