Compare commits

...
Author SHA1 Message Date
arkon 525f02f502 chore: version packages 2026-03-05 17:59:33 +01:00
arkonandClaude Opus 4.6 e07c59477d 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>
2026-03-05 17:52:28 +01:00
arkonandClaude Opus 4.6 2b8a522cbd fix: add server-side crash breadcrumbs and remove --drop:console from build
The --drop:console esbuild flag was silently stripping all diagnostic
console.* calls from production builds, making crash investigation
impossible. Remove it temporarily for debugging.

Add server-side crash breadcrumb collection:
- Frontend writes breadcrumbs to localStorage AND POSTs to /api/crash-diag
- Server stores latest breadcrumbs in memory, readable via GET /api/crash-diag
- Granular breadcrumbs in selectSession: CACHE_WRITE, FETCH_START,
  FETCH_DONE, REWRITE, FOCUS, SELECT_DONE
- 2s heartbeat beacon so breadcrumbs survive tab freezes
- text/plain content-type parser for navigator.sendBeacon compatibility

Initial findings from breadcrumbs:
- Crash happens during selectSession() for sessions with large buffers
- Pattern: cached buffer exists from prior visit + live SSE data arriving
- Backend is always stable (0 crashes); this is a pure frontend issue

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 16:44:17 +01:00
arkonandClaude Opus 4.6 4abe055182 feat: add frontend crash diagnostics for page unresponsive investigation
Add global error handlers, long task detection, WebGL context tracking,
and performance timing to identify root cause of intermittent Chrome
"page unresponsive" freezes during session switching and typing.

Diagnostics:
- window error/unhandledrejection handlers ([CRASH-DIAG] prefix)
- PerformanceObserver for long tasks (>200ms main thread blocks)
- WebGL context loss/restore tracking on all canvases
- ?nowebgl URL param to disable WebGL renderer for testing
- Timing on flushPendingWrites, chunkedTerminalWrite, selectSession

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 12:42:32 +01:00
6 changed files with 171 additions and 14 deletions
+6
View File
@@ -1,5 +1,11 @@
# aicodeman
## 0.3.5
### Patch Changes
- Fix Chrome "page unresponsive" crashes caused by xterm.js WebGL renderer GPU stalls during heavy terminal output. Disable WebGL by default (canvas renderer used instead), gate SSE terminal writes during tab switches, and add crash diagnostics with server-side breadcrumb collection.
## 0.3.4
### Patch Changes
+1 -1
View File
@@ -52,7 +52,7 @@ When user says "COM":
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
**Version**: 0.3.4 (must match `package.json`)
**Version**: 0.3.5 (must match `package.json`)
## Project Overview
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.3.4",
"version": "0.3.5",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+1 -1
View File
@@ -56,7 +56,7 @@ appendFileSync(
);
// 4. Minify frontend assets
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify styles.css', 'npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite');
run('minify mobile.css', 'npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite');
+138 -11
View File
@@ -78,6 +78,82 @@
// MobileDetection, KeyboardHandler, SwipeHandler are in mobile-handlers.js
// DeepgramProvider, VoiceInput are in voice-input.js
// ═══════════════════════════════════════════════════════════════
// Global Error & Performance Diagnostics
// ═══════════════════════════════════════════════════════════════
// Writes breadcrumbs to localStorage so they survive tab freezes.
// After a crash, check: localStorage.getItem('codeman-crash-diag')
const _crashDiag = {
_entries: [],
_maxEntries: 50,
log(msg) {
const entry = `${new Date().toISOString().slice(11,23)} ${msg}`;
this._entries.push(entry);
if (this._entries.length > this._maxEntries) this._entries.shift();
try { localStorage.setItem('codeman-crash-diag', this._entries.join('\n')); } catch {}
}
};
// Log previous crash breadcrumbs on startup
try {
const prev = localStorage.getItem('codeman-crash-diag');
if (prev) console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
} catch {}
_crashDiag.log('PAGE LOAD');
// Heartbeat: send breadcrumbs to server every 2s so they survive tab freezes.
setInterval(() => {
try {
localStorage.setItem('codeman-crash-heartbeat', String(Date.now()));
if (_crashDiag._entries.length > 0) {
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n') }));
}
} catch {}
}, 2000);
window.addEventListener('error', (e) => {
_crashDiag.log(`ERROR: ${e.message} at ${e.filename}:${e.lineno}`);
console.error('[CRASH-DIAG] Uncaught error:', e.message, '\n File:', e.filename, ':', e.lineno, ':', e.colno, '\n Stack:', e.error?.stack);
});
window.addEventListener('unhandledrejection', (e) => {
_crashDiag.log(`UNHANDLED: ${e.reason?.message || e.reason}`);
console.error('[CRASH-DIAG] Unhandled promise rejection:', e.reason?.message || e.reason, '\n Stack:', e.reason?.stack);
});
// Detect long tasks (>50ms main thread blocks) — these cause "page unresponsive"
if (typeof PerformanceObserver !== 'undefined') {
try {
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 200) {
_crashDiag.log(`LONG_TASK: ${entry.duration.toFixed(0)}ms`);
console.warn(`[CRASH-DIAG] Long task: ${entry.duration.toFixed(0)}ms (type: ${entry.entryType}, name: ${entry.name})`);
}
}
});
longTaskObserver.observe({ type: 'longtask', buffered: true });
} catch { /* longtask not supported */ }
}
// Track WebGL context loss/restore events on all canvases
const _origGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(type, ...args) {
const ctx = _origGetContext.call(this, type, ...args);
if (type === 'webgl2' || type === 'webgl') {
this.addEventListener('webglcontextlost', (e) => {
_crashDiag.log(`WEBGL_LOST: ${this.width}x${this.height}`);
console.error('[CRASH-DIAG] WebGL context LOST on canvas', this.width, 'x', this.height, '— prevented:', e.defaultPrevented);
});
this.addEventListener('webglcontextrestored', () => {
_crashDiag.log('WEBGL_RESTORED');
console.warn('[CRASH-DIAG] WebGL context restored');
});
}
return ctx;
};
@@ -542,14 +618,23 @@ 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.
// 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;
if (typeof WebglAddon !== 'undefined') {
if (new URLSearchParams(location.search).has('webgl') && typeof WebglAddon !== 'undefined') {
try {
this._webglAddon = new WebglAddon.WebglAddon();
this._webglAddon.onContextLoss(() => { this._webglAddon.dispose(); this._webglAddon = null; });
this._webglAddon.onContextLoss(() => {
console.error('[CRASH-DIAG] WebGL context LOST — falling back to canvas renderer');
this._webglAddon.dispose();
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 */ }
}
@@ -1286,13 +1371,18 @@ class CodemanApp {
flushPendingWrites() {
if (this.pendingWrites.length === 0 || !this.terminal) return;
const _t0 = performance.now();
// Extract segments, stripping DEC 2026 markers
// This implements synchronized output for xterm.js which doesn't support DEC 2026 natively
const segments = extractSyncSegments(this.pendingWrites.join(''));
const _joinedLen = this.pendingWrites.reduce((s, w) => s + w.length, 0);
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`);
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).
@@ -1304,6 +1394,8 @@ class CodemanApp {
if (content) this.terminal.write(content);
}
}
const _dt = performance.now() - _t0;
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) {
@@ -1396,15 +1488,23 @@ class CodemanApp {
// Each 32KB chunk keeps per-frame WebGL render work under ~5ms,
// avoiding GPU stalls without needing to toggle the renderer.
let offset = 0;
const _chunkStart = performance.now();
let _chunkCount = 0;
const writeChunk = () => {
if (offset >= cleanBuffer.length) {
const _totalMs = performance.now() - _chunkStart;
console.log(`[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);
return;
}
const _ct0 = performance.now();
const chunk = cleanBuffer.slice(offset, offset + chunkSize);
this.terminal.write(chunk);
const _cdt = performance.now() - _ct0;
_chunkCount++;
if (_cdt > 50) console.warn(`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`);
offset += chunkSize;
// Schedule next chunk on next frame
@@ -1627,6 +1727,7 @@ class CodemanApp {
// Async handlers have their own internal try/catch for fetch errors.
_onInit(data) {
_crashDiag.log(`INIT: ${data.sessions?.length || 0} sessions`);
this.handleInit(data);
}
@@ -1685,6 +1786,7 @@ class CodemanApp {
_onSessionTerminal(data) {
if (data.id === this.activeSessionId) {
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
this.batchTerminalWrite(data.data);
}
}
@@ -3346,6 +3448,10 @@ class CodemanApp {
async selectSession(sessionId) {
if (this.activeSessionId === sessionId) return;
const _selStart = performance.now();
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
_crashDiag.log(`SELECT: ${_selName}`);
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
const selectGen = ++this._selectGeneration;
@@ -3474,23 +3580,33 @@ 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
// for 5+ seconds while the WebGL renderer processes ReadPixels synchronously.
const cachedBuffer = this.terminalBufferCache.get(sessionId);
if (cachedBuffer) {
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
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');
}
_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}`);
if (data.terminalBuffer) {
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
@@ -3498,6 +3614,7 @@ class CodemanApp {
// very visible, causing the terminal to flash blank then repaint.
const needsRewrite = data.terminalBuffer !== cachedBuffer;
if (needsRewrite) {
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
this.terminal.clear();
this.terminal.reset();
// Show truncation indicator if buffer was cut
@@ -3506,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();
}
@@ -3524,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
@@ -3624,9 +3747,13 @@ class CodemanApp {
}
});
_crashDiag.log('FOCUS');
this.terminal.focus();
this.terminal.scrollToBottom();
_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);
}
+24
View File
@@ -630,6 +630,30 @@ export class WebServer extends EventEmitter {
}
});
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them
let _crashBreadcrumbs = '';
this.app.addContentTypeParser('text/plain;charset=UTF-8', { parseAs: 'string' }, (_req, body, done) => {
try {
done(null, JSON.parse(body as string));
} catch {
done(null, { data: body });
}
});
this.app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => {
try {
done(null, JSON.parse(body as string));
} catch {
done(null, { data: body });
}
});
this.app.post('/api/crash-diag', (req, reply) => {
_crashBreadcrumbs = String((req.body as { data?: string })?.data || '');
reply.code(204).send();
});
this.app.get('/api/crash-diag', (_req, reply) => {
reply.code(200).send({ breadcrumbs: _crashBreadcrumbs, timestamp: Date.now() });
});
// Register all route modules
const ctx = this.createRouteContext();
registerPushRoutes(this.app, ctx);