From b620511d0eba1b8c76238d7d274a3f09aef1d5f5 Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 5 Mar 2026 18:36:27 +0100 Subject: [PATCH] fix: cap terminal writes at 48KB/frame to prevent page unresponsive freezes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause was NOT WebGL — breadcrumbs showed 141KB single-frame terminal.write() calls freezing Chrome for 2+ minutes even with the canvas renderer. During heavy Ink output, multiple SSE terminal events accumulate between animation frames and flush all at once. Fix: split flushPendingWrites at DEC 2026 sync segment boundaries with a 48KB per-frame budget. Each segment is a complete Ink redraw, so splitting between them preserves atomicity (no flicker). Excess segments are deferred to the next requestAnimationFrame. Also re-enable WebGL since it was not the cause — the flush cap protects both renderers equally. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/web/public/app.js | 53 +++++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 0a1ac107..85dc0b5c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1381,21 +1381,50 @@ class CodemanApp { const segments = extractSyncSegments(joined); - // Write all segments in a single batch (atomic within this frame). - // xterm.js internally batches multiple write() calls within same frame. - // Never discard content from incomplete sync blocks — xterm.js doesn't support - // DEC 2026 natively anyway, so strip the marker and write content regardless. - // Discarding causes real data loss (including Ink's erase-line escapes). - for (const segment of segments) { - if (segment) { - const content = segment.startsWith(DEC_SYNC_START) - ? segment.slice(DEC_SYNC_START.length) - : segment; - if (content) this.terminal.write(content); + // Write segments respecting a per-frame byte budget. + // Each DEC 2026 sync segment is a complete Ink redraw — writing whole segments + // preserves atomicity (no flicker). But when total data exceeds 48KB, defer + // remaining segments to the next frame to prevent terminal.write() from blocking + // the main thread. 141KB single-frame writes have been observed to freeze Chrome + // for 2+ minutes even with the canvas renderer. + const MAX_FRAME_BYTES = 49152; // 48KB budget per frame + let bytesThisFrame = 0; + let deferred = false; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + if (!segment) continue; + const content = segment.startsWith(DEC_SYNC_START) + ? segment.slice(DEC_SYNC_START.length) + : segment; + if (!content) continue; + + // If we'd exceed the budget, defer this and all remaining segments + if (bytesThisFrame > 0 && bytesThisFrame + content.length > MAX_FRAME_BYTES) { + // Re-queue remaining segments as raw content for next flush + const remaining = segments.slice(i).map(s => { + if (!s) return ''; + return s.startsWith(DEC_SYNC_START) ? s.slice(DEC_SYNC_START.length) : s; + }).filter(Boolean).join(''); + if (remaining) { + this.pendingWrites.push(remaining); + if (!this.writeFrameScheduled) { + this.writeFrameScheduled = true; + requestAnimationFrame(() => { + this.flushPendingWrites(); + this.writeFrameScheduled = false; + }); + } + } + deferred = true; + break; } + + this.terminal.write(content); + bytesThisFrame += content.length; } const _dt = performance.now() - _t0; - if (_dt > 100) console.warn(`[CRASH-DIAG] flushPendingWrites took ${_dt.toFixed(0)}ms (${_joinedLen} bytes)`); + if (_dt > 100 || deferred) console.warn(`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame/1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen/1024).toFixed(0)}KB)`); // Sticky scroll: if user was at bottom, keep them there after new output if (this._wasAtBottomBeforeWrite) {