fix(renderer): WebGL longtask auto-fallback to canvas renderer (#83)

The xterm WebGL renderer can stall the main thread for hundreds of ms
under GPU pressure (driver hiccup, integrated-GPU memory pressure,
hardware-accelerated browser layers contending for the GPU). Symptom:
the page becomes intermittently unresponsive and Chrome eventually
shows the "Page Unresponsive" dialog. Today the only mitigation is
?nowebgl, which the user has to remember and re-apply on every load.

This patch installs a PerformanceObserver after WebGL init that
watches for sustained main-thread stalls and falls back to the DOM
renderer automatically:

- Threshold: 3 long tasks of >=200ms each within a 30-second window.
- 5-second grace period after init skips the noisy initial-load
  stalls so a slow first paint does not trip the guard.
- On trigger: dispose the WebGL addon, write a sticky disable to
  localStorage with a reason and timestamp, and refresh the terminal
  so the canvas renderer takes over without a page reload.
- Subsequent loads honor the sticky disable for 7 days, then auto-
  expire so users retry after a driver/Chrome update.
- Force re-enable any time with ?webgl=force (also clears the
  sticky entry).
- Existing ?nowebgl behaviour is unchanged.
- The same disable path is reused by the existing onContextLoss
  callback so a hard context loss also persists across reloads.

Files:
- src/web/public/app.js: _initWebGL onContextLoss now persists +
  schedules the watchdog; new _installWebGLLongTaskGuard and
  _disableWebGLSticky helpers.
- src/web/public/terminal-ui.js: WebGL init checks the sticky entry
  with 7-day expiry, honors ?webgl=force, threads sticky into
  skipWebGL alongside the existing mobile + ?nowebgl gates.

PerformanceObserver longtask is widely supported (Chromium, Edge);
the try/catch around .observe() makes Firefox/Safari (which lack the
longtask entry type) silently no-op and just keep WebGL.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
aakhter
2026-05-17 05:56:30 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent edd494ec5f
commit e87b03b6c2
2 changed files with 72 additions and 3 deletions
+44 -1
View File
@@ -617,14 +617,57 @@ class CodemanApp {
this._webglAddon = new WebglAddon.WebglAddon();
this._webglAddon.onContextLoss(() => {
console.error('[CRASH-DIAG] WebGL context LOST — falling back to canvas renderer');
this._webglAddon.dispose();
_crashDiag.log('WEBGL_LOST');
this._disableWebGLSticky('context-lost');
this._webglAddon?.dispose();
this._webglAddon = null;
});
this.terminal.loadAddon(this._webglAddon);
console.log('[CRASH-DIAG] WebGL renderer enabled');
this._installWebGLLongTaskGuard();
} catch (_e) { /* WebGL2 unavailable — canvas renderer used */ }
}
/**
* Watch for sustained main-thread stalls that indicate WebGL/GPU trouble.
* After 3 long tasks (>=200ms each) within 30s, dispose the WebGL addon and
* persist a sticky disable so subsequent reloads also use the DOM renderer.
* 5s grace period skips initial-load stalls. Force-re-enable: ?webgl=force.
*/
_installWebGLLongTaskGuard() {
if (typeof PerformanceObserver === 'undefined' || this._webglLongTaskObserver) return;
const installedAt = performance.now();
const recent = [];
try {
this._webglLongTaskObserver = new PerformanceObserver((list) => {
if (!this._webglAddon) return;
const now = performance.now();
if (now - installedAt < 5000) return;
for (const entry of list.getEntries()) {
if (entry.duration >= 200) recent.push(entry.startTime);
}
while (recent.length && now - recent[0] > 30000) recent.shift();
if (recent.length >= 3) {
console.warn(`[CRASH-DIAG] WebGL long-task threshold (${recent.length} stalls/30s) — falling back to canvas renderer`);
_crashDiag.log(`WEBGL_FALLBACK: ${recent.length}`);
this._disableWebGLSticky('long-tasks');
this._webglAddon?.dispose();
this._webglAddon = null;
try { this._webglLongTaskObserver.disconnect(); } catch {}
this._webglLongTaskObserver = null;
try { this.terminal.refresh(0, this.terminal.rows - 1); } catch {}
}
});
this._webglLongTaskObserver.observe({ type: 'longtask', buffered: false });
} catch { /* longtask not supported */ }
}
_disableWebGLSticky(reason) {
try {
localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ reason, at: Date.now() }));
} catch {}
}
// ═══════════════════════════════════════════════════════════════
// Event Listeners (Keyboard Shortcuts, Resize, Beforeunload)
// ═══════════════════════════════════════════════════════════════
+28 -2
View File
@@ -174,10 +174,36 @@ Object.assign(CodemanApp.prototype, {
// but the 48KB/frame flush cap in flushPendingWrites() now prevents
// oversized terminal.write() calls that triggered the stalls.
// Disable with ?nowebgl URL param if GPU issues return.
// Auto-fallback: _initWebGL installs a long-task watchdog that disables
// WebGL sticky in localStorage after repeated GPU stalls (see app.js).
// Force re-enable after sticky disable with ?webgl=force.
// Lazy-loaded: script downloaded only on desktop (saves 244KB on mobile).
this._webglAddon = null;
const skipWebGL = MobileDetection.getDeviceType() !== 'desktop';
if (!skipWebGL && !new URLSearchParams(location.search).has('nowebgl')) {
const _params = new URLSearchParams(location.search);
if (_params.get('webgl') === 'force') {
try { localStorage.removeItem('codeman-webgl-disabled'); } catch {}
}
const _stickyDisabled = (() => {
try {
const raw = localStorage.getItem('codeman-webgl-disabled');
if (!raw) return false;
const { at } = JSON.parse(raw);
// Auto-expire after 7 days so we retry (driver may have been fixed)
if (Date.now() - at > 7 * 24 * 60 * 60 * 1000) {
localStorage.removeItem('codeman-webgl-disabled');
return false;
}
return true;
} catch { return false; }
})();
const skipWebGL =
MobileDetection.getDeviceType() !== 'desktop' ||
_params.has('nowebgl') ||
_stickyDisabled;
if (_stickyDisabled) {
console.log('[CRASH-DIAG] WebGL sticky-disabled from prior stalls — DOM renderer in use. Re-enable: ?webgl=force');
}
if (!skipWebGL) {
if (typeof WebglAddon !== 'undefined') {
this._initWebGL();
} else {