From e07c59477ddb827faa850c1b38ad777093887381 Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 5 Mar 2026 17:52:28 +0100 Subject: [PATCH] fix: disable WebGL renderer to prevent Chrome page unresponsive crashes Root cause: xterm.js WebGL addon performs synchronous GPU ReadPixels calls during terminal.write(). When heavy terminal output floods in (~1MB/4s from active Claude sessions), single-frame writes of 70-105KB block Chrome's main thread for 10+ seconds, triggering "page unresponsive" dialogs. This happens both during tab switches (buffer load + live SSE data competing) and during normal use (Ink redraw bursts). Fix: disable WebGL by default, use canvas renderer instead. Canvas handles the same workloads without GPU stalls. Re-enable with ?webgl URL param for testing. Also: gate live SSE terminal writes during the entire selectSession() buffer load sequence (not just during chunkedTerminalWrite), preventing live data from competing with historical buffer restoration. Crash investigation data (from server-side breadcrumb collection): - 27 flushes totaling 937KB in 4 seconds preceded crash - Two 105KB and one 104KB single-frame flushes observed - Crash occurred when user switched tabs during output flood - Tab froze for 2m54s before recovering - Backend always stable (0 crashes); pure frontend GPU issue Co-Authored-By: Claude Opus 4.6 (1M context) --- src/web/public/app.js | 47 ++++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 4f61bfdc..0a1ac107 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -618,15 +618,14 @@ class CodemanApp { const container = document.getElementById('terminalContainer'); this.terminal.open(container); - // Activate WebGL renderer for up to 900% faster rendering (fallback to canvas on failure). - // Store reference so we can disable during large buffer loads to prevent GPU stalls. - // Disable with ?nowebgl=1 URL param for debugging GPU freeze issues. + // WebGL renderer disabled — canvas renderer used instead. + // xterm.js WebGL addon causes synchronous GPU ReadPixels calls during large + // terminal writes (buffer loads, heavy Ink output) that block Chrome's main + // thread for 10+ seconds, triggering "page unresponsive" crashes. + // Canvas renderer handles the same workloads without GPU stalls. + // Re-enable with ?webgl=1 URL param for testing. this._webglAddon = null; - const _noWebGL = new URLSearchParams(location.search).has('nowebgl'); - if (_noWebGL) { - console.warn('[CRASH-DIAG] WebGL renderer DISABLED via ?nowebgl param — using canvas renderer'); - } - if (!_noWebGL && typeof WebglAddon !== 'undefined') { + if (new URLSearchParams(location.search).has('webgl') && typeof WebglAddon !== 'undefined') { try { this._webglAddon = new WebglAddon.WebglAddon(); this._webglAddon.onContextLoss(() => { @@ -635,6 +634,7 @@ class CodemanApp { this._webglAddon = null; }); this.terminal.loadAddon(this._webglAddon); + console.log('[CRASH-DIAG] WebGL renderer enabled via ?webgl param'); } catch (_e) { /* WebGL2 unavailable — canvas renderer used */ } } @@ -1376,11 +1376,13 @@ class CodemanApp { // This implements synchronized output for xterm.js which doesn't support DEC 2026 natively const _joinedLen = this.pendingWrites.reduce((s, w) => s + w.length, 0); if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`); - const segments = extractSyncSegments(this.pendingWrites.join('')); + const joined = this.pendingWrites.join(''); this.pendingWrites = []; - // Write all segments in a single batch (atomic within this frame) - // xterm.js internally batches multiple write() calls within same frame + 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). @@ -1393,7 +1395,7 @@ class CodemanApp { } } const _dt = performance.now() - _t0; - if (_dt > 100) console.warn(`[CRASH-DIAG] flushPendingWrites took ${_dt.toFixed(0)}ms (${_joinedLen} bytes, ${segments.length} segments)`); + if (_dt > 100) console.warn(`[CRASH-DIAG] flushPendingWrites took ${_dt.toFixed(0)}ms (${_joinedLen} bytes)`); // Sticky scroll: if user was at bottom, keep them there after new output if (this._wasAtBottomBeforeWrite) { @@ -3578,6 +3580,12 @@ class CodemanApp { // filter catches most cases, but _restoringFlushedState provides a // belt-and-suspenders guard for any edge cases. this._restoringFlushedState = true; + // Gate live SSE terminal writes for the ENTIRE buffer load sequence. + // Without this, SSE events arriving during the fetch() gap compete with + // the buffer write, causing 70KB+ single-frame flushes that stall WebGL. + // chunkedTerminalWrite also sets this, but we need it before the fetch too. + this._isLoadingBuffer = true; + this._loadBufferQueue = []; try { // Instant cache restore — show previous buffer via chunked write to avoid WebGL GPU stalls. // Direct terminal.write() of large cached buffers (256KB+) can block the main thread @@ -3588,7 +3596,7 @@ class CodemanApp { this.terminal.clear(); this.terminal.reset(); await this.chunkedTerminalWrite(cachedBuffer); - if (selectGen !== this._selectGeneration) { this._restoringFlushedState = false; return; } + if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; } this.terminal.scrollToBottom(); _crashDiag.log('CACHE_DONE'); } @@ -3596,7 +3604,7 @@ class CodemanApp { _crashDiag.log('FETCH_START'); const tailSize = 256 * 1024; const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`); - if (selectGen !== this._selectGeneration) { this._restoringFlushedState = false; return; } + if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; } const data = await res.json(); _crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`); @@ -3615,7 +3623,7 @@ class CodemanApp { } // Use chunked write for large buffers to avoid UI jank await this.chunkedTerminalWrite(data.terminalBuffer); - if (selectGen !== this._selectGeneration) { this._restoringFlushedState = false; return; } + if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; } // Ensure terminal is scrolled to bottom after buffer load this.terminal.scrollToBottom(); } @@ -3633,7 +3641,13 @@ class CodemanApp { this.terminal.reset(); } - // Buffer load complete — drop the guard so user input clears state normally + // Buffer load complete — unblock live SSE writes and flush any queued events. + // chunkedTerminalWrite calls _finishBufferLoad internally, but if we skipped + // the chunked write (small buffer, cache hit, or empty), we must call it here. + if (this._isLoadingBuffer) { + this._finishBufferLoad(); + } + // Drop the guard so user input clears state normally this._restoringFlushedState = false; // Restore flushed offset and text for this session so the overlay positions @@ -3739,6 +3753,7 @@ class CodemanApp { _crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`); console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`); } catch (err) { + if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; console.error('Failed to load session terminal:', err); }