Skip to content

Commit e07c594

Browse files
Ark0Nclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 2b8a522 commit e07c594

1 file changed

Lines changed: 31 additions & 16 deletions

File tree

src/web/public/app.js

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -618,15 +618,14 @@ class CodemanApp {
618618
const container = document.getElementById('terminalContainer');
619619
this.terminal.open(container);
620620

621-
// Activate WebGL renderer for up to 900% faster rendering (fallback to canvas on failure).
622-
// Store reference so we can disable during large buffer loads to prevent GPU stalls.
623-
// Disable with ?nowebgl=1 URL param for debugging GPU freeze issues.
621+
// WebGL renderer disabled — canvas renderer used instead.
622+
// xterm.js WebGL addon causes synchronous GPU ReadPixels calls during large
623+
// terminal writes (buffer loads, heavy Ink output) that block Chrome's main
624+
// thread for 10+ seconds, triggering "page unresponsive" crashes.
625+
// Canvas renderer handles the same workloads without GPU stalls.
626+
// Re-enable with ?webgl=1 URL param for testing.
624627
this._webglAddon = null;
625-
const _noWebGL = new URLSearchParams(location.search).has('nowebgl');
626-
if (_noWebGL) {
627-
console.warn('[CRASH-DIAG] WebGL renderer DISABLED via ?nowebgl param — using canvas renderer');
628-
}
629-
if (!_noWebGL && typeof WebglAddon !== 'undefined') {
628+
if (new URLSearchParams(location.search).has('webgl') && typeof WebglAddon !== 'undefined') {
630629
try {
631630
this._webglAddon = new WebglAddon.WebglAddon();
632631
this._webglAddon.onContextLoss(() => {
@@ -635,6 +634,7 @@ class CodemanApp {
635634
this._webglAddon = null;
636635
});
637636
this.terminal.loadAddon(this._webglAddon);
637+
console.log('[CRASH-DIAG] WebGL renderer enabled via ?webgl param');
638638
} catch (_e) { /* WebGL2 unavailable — canvas renderer used */ }
639639
}
640640

@@ -1376,11 +1376,13 @@ class CodemanApp {
13761376
// This implements synchronized output for xterm.js which doesn't support DEC 2026 natively
13771377
const _joinedLen = this.pendingWrites.reduce((s, w) => s + w.length, 0);
13781378
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`);
1379-
const segments = extractSyncSegments(this.pendingWrites.join(''));
1379+
const joined = this.pendingWrites.join('');
13801380
this.pendingWrites = [];
13811381

1382-
// Write all segments in a single batch (atomic within this frame)
1383-
// xterm.js internally batches multiple write() calls within same frame
1382+
const segments = extractSyncSegments(joined);
1383+
1384+
// Write all segments in a single batch (atomic within this frame).
1385+
// xterm.js internally batches multiple write() calls within same frame.
13841386
// Never discard content from incomplete sync blocks — xterm.js doesn't support
13851387
// DEC 2026 natively anyway, so strip the marker and write content regardless.
13861388
// Discarding causes real data loss (including Ink's erase-line escapes).
@@ -1393,7 +1395,7 @@ class CodemanApp {
13931395
}
13941396
}
13951397
const _dt = performance.now() - _t0;
1396-
if (_dt > 100) console.warn(`[CRASH-DIAG] flushPendingWrites took ${_dt.toFixed(0)}ms (${_joinedLen} bytes, ${segments.length} segments)`);
1398+
if (_dt > 100) console.warn(`[CRASH-DIAG] flushPendingWrites took ${_dt.toFixed(0)}ms (${_joinedLen} bytes)`);
13971399

13981400
// Sticky scroll: if user was at bottom, keep them there after new output
13991401
if (this._wasAtBottomBeforeWrite) {
@@ -3578,6 +3580,12 @@ class CodemanApp {
35783580
// filter catches most cases, but _restoringFlushedState provides a
35793581
// belt-and-suspenders guard for any edge cases.
35803582
this._restoringFlushedState = true;
3583+
// Gate live SSE terminal writes for the ENTIRE buffer load sequence.
3584+
// Without this, SSE events arriving during the fetch() gap compete with
3585+
// the buffer write, causing 70KB+ single-frame flushes that stall WebGL.
3586+
// chunkedTerminalWrite also sets this, but we need it before the fetch too.
3587+
this._isLoadingBuffer = true;
3588+
this._loadBufferQueue = [];
35813589
try {
35823590
// Instant cache restore — show previous buffer via chunked write to avoid WebGL GPU stalls.
35833591
// Direct terminal.write() of large cached buffers (256KB+) can block the main thread
@@ -3588,15 +3596,15 @@ class CodemanApp {
35883596
this.terminal.clear();
35893597
this.terminal.reset();
35903598
await this.chunkedTerminalWrite(cachedBuffer);
3591-
if (selectGen !== this._selectGeneration) { this._restoringFlushedState = false; return; }
3599+
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
35923600
this.terminal.scrollToBottom();
35933601
_crashDiag.log('CACHE_DONE');
35943602
}
35953603

35963604
_crashDiag.log('FETCH_START');
35973605
const tailSize = 256 * 1024;
35983606
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`);
3599-
if (selectGen !== this._selectGeneration) { this._restoringFlushedState = false; return; }
3607+
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
36003608
const data = await res.json();
36013609
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
36023610

@@ -3615,7 +3623,7 @@ class CodemanApp {
36153623
}
36163624
// Use chunked write for large buffers to avoid UI jank
36173625
await this.chunkedTerminalWrite(data.terminalBuffer);
3618-
if (selectGen !== this._selectGeneration) { this._restoringFlushedState = false; return; }
3626+
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
36193627
// Ensure terminal is scrolled to bottom after buffer load
36203628
this.terminal.scrollToBottom();
36213629
}
@@ -3633,7 +3641,13 @@ class CodemanApp {
36333641
this.terminal.reset();
36343642
}
36353643

3636-
// Buffer load complete — drop the guard so user input clears state normally
3644+
// Buffer load complete — unblock live SSE writes and flush any queued events.
3645+
// chunkedTerminalWrite calls _finishBufferLoad internally, but if we skipped
3646+
// the chunked write (small buffer, cache hit, or empty), we must call it here.
3647+
if (this._isLoadingBuffer) {
3648+
this._finishBufferLoad();
3649+
}
3650+
// Drop the guard so user input clears state normally
36373651
this._restoringFlushedState = false;
36383652

36393653
// Restore flushed offset and text for this session so the overlay positions
@@ -3739,6 +3753,7 @@ class CodemanApp {
37393753
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
37403754
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
37413755
} catch (err) {
3756+
if (this._isLoadingBuffer) this._finishBufferLoad();
37423757
this._restoringFlushedState = false;
37433758
console.error('Failed to load session terminal:', err);
37443759
}

0 commit comments

Comments
 (0)