From 2295bb19cdc660ebdd390aa3c2e74ccd0506c443 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 15 May 2026 09:59:29 -0400 Subject: [PATCH] fix(client): multi-primitive yield for write pacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/web/public/terminal-ui.js | 78 +++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index f804098e..77fd6b65 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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} - 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); }); },