Files
NetMesh/components/terminal/runtime/webglContextLossRecovery.test.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

205 lines
6.4 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { createWebglRendererController } from "./webglRendererController";
class FakeAddon {
contextLoss: (() => void) | null = null;
disposeCalls = 0;
onContextLoss(callback: () => void) {
this.contextLoss = callback;
}
dispose() {
this.disposeCalls += 1;
}
loseContext() {
assert.ok(this.contextLoss, "addon must install a context-loss handler");
this.contextLoss();
}
}
function createHarness(options: { failLoads?: Set<number>; enabled?: boolean } = {}) {
let time = 0;
let nextTimerId = 1;
const timers = new Map<number, () => void>();
const addons: FakeAddon[] = [];
const loadedStates: boolean[] = [];
const warnings: string[] = [];
let repaintCalls = 0;
let loadCalls = 0;
const controller = createWebglRendererController({
enabled: options.enabled ?? true,
createAddon: () => {
const addon = new FakeAddon();
addons.push(addon);
return addon;
},
loadAddon: () => {
loadCalls += 1;
if (options.failLoads?.has(loadCalls)) throw new Error(`load ${loadCalls} failed`);
},
repaint: () => {
repaintCalls += 1;
},
setLoaded: (loaded) => loadedStates.push(loaded),
warn: (message) => warnings.push(message),
now: () => time,
setTimer: (callback) => {
const id = nextTimerId++;
timers.set(id, callback);
return id as unknown as ReturnType<typeof setTimeout>;
},
clearTimer: (id) => timers.delete(id as unknown as number),
});
return {
controller,
addons,
loadedStates,
warnings,
get repaintCalls() { return repaintCalls; },
get loadCalls() { return loadCalls; },
get pendingTimers() { return timers.size; },
setTime(value: number) { time = value; },
runNextTimer() {
const entry = timers.entries().next().value as [number, () => void] | undefined;
assert.ok(entry, "expected a pending timer");
timers.delete(entry[0]);
entry[1]();
},
};
}
test("context loss schedules one recovery and repaints the DOM fallback", () => {
const harness = createHarness();
harness.controller.ensure();
harness.addons[0].loseContext();
harness.addons[0].loseContext();
assert.equal(harness.pendingTimers, 1);
assert.equal(harness.addons[0].disposeCalls, 1);
assert.equal(harness.repaintCalls, 1);
assert.equal(harness.loadedStates.at(-1), false);
harness.runNextTimer();
assert.equal(harness.loadCalls, 2);
assert.equal(harness.repaintCalls, 2);
assert.equal(harness.loadedStates.at(-1), true);
});
test("burst losses trip a persistent breaker that ensure and suspend cannot rearm", () => {
const harness = createHarness();
harness.controller.ensure();
for (let loss = 0; loss < 3; loss += 1) {
harness.setTime(loss * 100);
harness.addons[loss].loseContext();
if (loss < 2) harness.runNextTimer();
}
assert.equal(harness.pendingTimers, 0);
assert.ok(harness.warnings.some((warning) => warning.includes("staying on DOM renderer")));
assert.equal(harness.loadCalls, 3);
harness.controller.ensure();
harness.controller.suspend();
harness.controller.ensure();
assert.equal(harness.loadCalls, 3);
assert.equal(harness.loadedStates.at(-1), false);
});
// Periodic GPU context loss (~every 15s on some Linux drivers) must still trip the
// breaker. A short recovery window ages each prior loss out, so WebGL keeps
// dispose -> rebuild cycling and the terminal flashes forever (#2436).
test("periodic losses spaced ~15s apart trip the breaker instead of flashing forever", () => {
const harness = createHarness();
harness.controller.ensure();
for (let loss = 0; loss < 3; loss += 1) {
harness.setTime(loss * 15_000);
harness.addons[loss].loseContext();
if (loss < 2) harness.runNextTimer();
}
assert.equal(harness.pendingTimers, 0);
assert.ok(harness.warnings.some((warning) => warning.includes("staying on DOM renderer")));
assert.equal(harness.loadCalls, 3);
assert.equal(harness.controller.getAddon(), null);
assert.equal(harness.loadedStates.at(-1), false);
harness.controller.ensure();
assert.equal(harness.loadCalls, 3);
});
test("a quiet gap longer than the recovery window allows a fresh recovery", () => {
const harness = createHarness();
harness.controller.ensure();
harness.setTime(0);
harness.addons[0].loseContext();
harness.runNextTimer();
harness.setTime(15_000);
harness.addons[1].loseContext();
harness.runNextTimer();
// After the recovery window elapses with no further losses, a later single
// loss should recover once rather than staying broken forever.
harness.setTime(15_000 + 60_001);
harness.addons[2].loseContext();
assert.equal(harness.pendingTimers, 1);
harness.runNextTimer();
assert.equal(harness.loadCalls, 4);
assert.equal(harness.loadedStates.at(-1), true);
});
test("suspend and dispose cancel pending recovery callbacks", () => {
for (const action of ["suspend", "dispose"] as const) {
const harness = createHarness();
harness.controller.ensure();
harness.addons[0].loseContext();
assert.equal(harness.pendingTimers, 1);
harness.controller[action]();
assert.equal(harness.pendingTimers, 0);
assert.equal(harness.loadCalls, 1);
}
});
test("a stale context-loss callback from a replaced addon is ignored", () => {
const harness = createHarness();
harness.controller.ensure();
const staleAddon = harness.addons[0];
staleAddon.loseContext();
harness.runNextTimer();
const repaintCalls = harness.repaintCalls;
staleAddon.loseContext();
assert.equal(harness.pendingTimers, 0);
assert.equal(harness.repaintCalls, repaintCalls);
assert.equal(staleAddon.disposeCalls, 1);
assert.equal(harness.controller.getAddon(), harness.addons[1]);
});
test("recovery load failure stays on DOM and still repaints the viewport", () => {
const harness = createHarness({ failLoads: new Set([2]) });
harness.controller.ensure();
harness.addons[0].loseContext();
harness.runNextTimer();
assert.equal(harness.controller.getAddon(), null);
assert.equal(harness.loadedStates.at(-1), false);
assert.equal(harness.addons[1].disposeCalls, 1);
assert.equal(harness.repaintCalls, 2);
assert.ok(harness.warnings.some((warning) => warning.includes("using DOM renderer")));
});
test("disabled WebGL ensure is a no-op", () => {
const harness = createHarness({ enabled: false });
harness.controller.ensure();
assert.equal(harness.loadCalls, 0);
assert.equal(harness.addons.length, 0);
});