mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
fix(renderer): WebGL longtask fallback hardening (#91)
Closes #89. - _disposeWebGLObserver() called from both trip path and onContextLoss (fixes the leak) - Thresholds (200ms/3/30s/5s/7d) hoisted to WEBGL_FALLBACK in constants.js - Pure evaluateWebGLLongTaskTrip() helper + 9 Playwright tests (test/webgl-fallback.test.ts, port 3166)
This commit is contained in:
+21
-12
@@ -625,6 +625,7 @@ class CodemanApp {
|
||||
console.error('[CRASH-DIAG] WebGL context LOST — falling back to canvas renderer');
|
||||
_crashDiag.log('WEBGL_LOST');
|
||||
this._disableWebGLSticky('context-lost');
|
||||
this._disposeWebGLObserver();
|
||||
this._webglAddon?.dispose();
|
||||
this._webglAddon = null;
|
||||
});
|
||||
@@ -636,9 +637,10 @@ class CodemanApp {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* After WEBGL_FALLBACK.LONGTASK_COUNT long tasks (>=LONGTASK_MS each) within
|
||||
* WINDOW_MS, dispose the WebGL addon and persist a sticky disable so
|
||||
* subsequent reloads also use the DOM renderer. GRACE_MS skips initial-load
|
||||
* stalls. Force-re-enable: ?webgl=force.
|
||||
*/
|
||||
_installWebGLLongTaskGuard() {
|
||||
if (typeof PerformanceObserver === 'undefined' || this._webglLongTaskObserver) return;
|
||||
@@ -648,19 +650,14 @@ class CodemanApp {
|
||||
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`);
|
||||
if (now - installedAt < WEBGL_FALLBACK.GRACE_MS) return;
|
||||
if (evaluateWebGLLongTaskTrip(recent, list.getEntries(), now)) {
|
||||
console.warn(`[CRASH-DIAG] WebGL long-task threshold (${recent.length} stalls/${WEBGL_FALLBACK.WINDOW_MS}ms) — falling back to canvas renderer`);
|
||||
_crashDiag.log(`WEBGL_FALLBACK: ${recent.length}`);
|
||||
this._disableWebGLSticky('long-tasks');
|
||||
this._disposeWebGLObserver();
|
||||
this._webglAddon?.dispose();
|
||||
this._webglAddon = null;
|
||||
try { this._webglLongTaskObserver.disconnect(); } catch {}
|
||||
this._webglLongTaskObserver = null;
|
||||
try { this.terminal.refresh(0, this.terminal.rows - 1); } catch {}
|
||||
}
|
||||
});
|
||||
@@ -668,6 +665,18 @@ class CodemanApp {
|
||||
} catch { /* longtask not supported */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the WebGL longtask observer. Idempotent. Called from the trip
|
||||
* path, the onContextLoss handler, and any future terminal-teardown path —
|
||||
* the observer outlives its addon otherwise, holding a closure reference
|
||||
* over `this` for every long task the page emits.
|
||||
*/
|
||||
_disposeWebGLObserver() {
|
||||
if (!this._webglLongTaskObserver) return;
|
||||
try { this._webglLongTaskObserver.disconnect(); } catch {}
|
||||
this._webglLongTaskObserver = null;
|
||||
}
|
||||
|
||||
_disableWebGLSticky(reason) {
|
||||
try {
|
||||
localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ reason, at: Date.now() }));
|
||||
|
||||
@@ -71,6 +71,52 @@ const WINDOW_MIN_WIDTH_PX = 200;
|
||||
const WINDOW_MIN_HEIGHT_PX = 200;
|
||||
const WINDOW_DEFAULT_WIDTH_PX = 300;
|
||||
|
||||
// WebGL renderer auto-fallback thresholds.
|
||||
// _installWebGLLongTaskGuard() observes longtask entries and disables WebGL
|
||||
// after LONGTASK_COUNT stalls of >= LONGTASK_MS within WINDOW_MS. GRACE_MS
|
||||
// suppresses the noisy initial-load stalls. STICKY_EXPIRY_MS is how long
|
||||
// localStorage's webgl-disabled marker survives before we retry WebGL on a
|
||||
// fresh load (driver/Chrome may have been updated).
|
||||
const WEBGL_FALLBACK = {
|
||||
LONGTASK_MS: 200,
|
||||
LONGTASK_COUNT: 3,
|
||||
WINDOW_MS: 30000,
|
||||
GRACE_MS: 5000,
|
||||
STICKY_EXPIRY_MS: 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure rolling-window trip evaluator for the WebGL longtask guard.
|
||||
* Mutates `recent` in place (prunes entries older than `now - WINDOW_MS`)
|
||||
* and appends each new duration's startTime that meets the threshold.
|
||||
* Returns true when the count inside the window reaches `LONGTASK_COUNT`.
|
||||
*
|
||||
* Exposed on `window` for unit testing — the production guard in app.js
|
||||
* inlines this same logic in its PerformanceObserver callback. Splitting it
|
||||
* out keeps the threshold math testable without a real PerformanceObserver.
|
||||
*
|
||||
* @param {number[]} recent - mutable array of startTimes inside the window
|
||||
* @param {{startTime: number, duration: number}[]} entries - new longtask entries
|
||||
* @param {number} now - performance.now() at evaluation time
|
||||
* @param {typeof WEBGL_FALLBACK} [config=WEBGL_FALLBACK] - thresholds
|
||||
* @returns {boolean} true if the rolling window has reached the trip count
|
||||
*/
|
||||
function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK) {
|
||||
for (const entry of entries) {
|
||||
if (entry.duration >= config.LONGTASK_MS) recent.push(entry.startTime);
|
||||
}
|
||||
while (recent.length && now - recent[0] > config.WINDOW_MS) recent.shift();
|
||||
return recent.length >= config.LONGTASK_COUNT;
|
||||
}
|
||||
|
||||
// Expose for tests. `const` declarations at the top of a non-module script
|
||||
// are global lexical bindings but not `window` properties, so explicit
|
||||
// assignment is the test-visible API surface.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||
}
|
||||
|
||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||
// scheduler.postTask('background') defers non-critical work (connection lines, panel renders)
|
||||
// so the main thread stays free for terminal rendering at 60fps.
|
||||
|
||||
@@ -197,8 +197,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
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) {
|
||||
// Auto-expire after WEBGL_FALLBACK.STICKY_EXPIRY_MS so we retry
|
||||
// (driver/Chrome may have been updated).
|
||||
if (Date.now() - at > WEBGL_FALLBACK.STICKY_EXPIRY_MS) {
|
||||
localStorage.removeItem('codeman-webgl-disabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user