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); }); },