diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index c08ec208c4..f7c44a9d94 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -175,6 +175,15 @@ if (process.platform === "win32") { app.setAppUserModelId(APP_ID); } +// Chromium's accessibility tree serializer has a known CHECK failure in +// AXBlockFlowData::ComputeNeighborOnLine (chromium #552018997) that kills +// the renderer when an AT client reads the tree while the DOM is being +// mutated — exactly what happens during streaming agent responses. +// The switch prevents Chromium from building the in-renderer accessibility +// tree unless the user explicitly opts in via --force-renderer-accessibility. +// This is a workaround until the upstream fix lands. +app.commandLine.appendSwitch("disable-renderer-accessibility"); + // One installation, one process. The lock lives in `userData` (set just // above), so it is taken after `setName` and before anything else here // touches the data directory. A development build is its own installation; diff --git a/apps/desktop/electron/main/renderer-recovery.ts b/apps/desktop/electron/main/renderer-recovery.ts index e48bfe7d7d..b0b1a9dc5c 100644 --- a/apps/desktop/electron/main/renderer-recovery.ts +++ b/apps/desktop/electron/main/renderer-recovery.ts @@ -13,12 +13,30 @@ export type RendererRecoveryDependencies = Readonly<{ log: (details: RendererGoneDetails, reloaded: boolean) => void; }>; +/** + * Minimum milliseconds between automatic reloads. A renderer that crashes + * again within this window is still reloaded, but the reload is delayed so + * the process is not spinning in a tight crash loop. + */ +const CRASH_COOLDOWN_MS = 2_000; + +/** Tracks the last reload timestamp per recovery call site. */ +let lastReloadTimestamp = 0; + +/** + * Reset internal cooldown state. Exposed exclusively for deterministic tests; + * production code must never call this. + */ +export function _resetCooldownForTest(): void { + lastReloadTimestamp = 0; +} + /** Reload an unexpectedly exited renderer while its owning app window is live. */ export function recoverRendererAfterGone( details: RendererGoneDetails, dependencies: RendererRecoveryDependencies, ): boolean { - const reloaded = + const shouldReload = details.reason !== "clean-exit" && dependencies.isCurrentWindow && !dependencies.quitting && @@ -26,9 +44,20 @@ export function recoverRendererAfterGone( !dependencies.windowDestroyed && !dependencies.webContentsDestroyed; - dependencies.log(details, reloaded); - if (!reloaded) return false; + dependencies.log(details, shouldReload); + if (!shouldReload) return false; + + const now = Date.now(); + const elapsed = now - lastReloadTimestamp; + lastReloadTimestamp = now; + + if (elapsed < CRASH_COOLDOWN_MS) { + // Delay the reload to avoid a tight crash loop. The timer is short + // enough that the user sees a brief blank rather than a frozen app. + setTimeout(() => dependencies.reload(), CRASH_COOLDOWN_MS - elapsed); + } else { + dependencies.reload(); + } - dependencies.reload(); return true; } diff --git a/apps/desktop/test/renderer-recovery.test.mjs b/apps/desktop/test/renderer-recovery.test.mjs index 382b48a50c..58a2cf7f89 100644 --- a/apps/desktop/test/renderer-recovery.test.mjs +++ b/apps/desktop/test/renderer-recovery.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import test from "node:test"; -import { recoverRendererAfterGone } from "../electron/main/renderer-recovery.ts"; +import { afterEach, describe, it, mock } from "node:test"; +import { recoverRendererAfterGone, _resetCooldownForTest } from "../electron/main/renderer-recovery.ts"; function recovery(overrides = {}) { const calls = { reload: 0, logs: [] }; @@ -17,38 +17,93 @@ function recovery(overrides = {}) { return { calls, dependencies }; } -test("unexpected renderer exits reload the live main window and preserve diagnostics", () => { - const { calls, dependencies } = recovery(); - const details = { reason: "crashed", exitCode: 73 }; +describe("recoverRendererAfterGone", () => { + afterEach(() => { + _resetCooldownForTest(); + mock.timers.reset(); + }); - assert.equal(recoverRendererAfterGone(details, dependencies), true); - assert.equal(calls.reload, 1); - assert.deepEqual(calls.logs, [{ details, reloaded: true }]); -}); + it("unexpected renderer exits reload the live main window and preserve diagnostics", () => { + mock.timers.enable({ apis: ["Date", "setTimeout"] }); + mock.timers.tick(10_000); -test("clean exits and windows outside the live main lifecycle are not reloaded", () => { - const cases = [ - [{ reason: "clean-exit", exitCode: 0 }, {}], - [{ reason: "crashed", exitCode: 1 }, { isCurrentWindow: false }], - [{ reason: "crashed", exitCode: 1 }, { quitting: true }], - [{ reason: "crashed", exitCode: 1 }, { windowCloseAccepted: true }], - [{ reason: "crashed", exitCode: 1 }, { windowDestroyed: true }], - [{ reason: "crashed", exitCode: 1 }, { webContentsDestroyed: true }], - ]; - - for (const [details, overrides] of cases) { - const { calls, dependencies } = recovery(overrides); - assert.equal(recoverRendererAfterGone(details, dependencies), false); - assert.equal(calls.reload, 0); - assert.deepEqual(calls.logs, [{ details, reloaded: false }]); - } -}); + const { calls, dependencies } = recovery(); + const details = { reason: "crashed", exitCode: 73 }; + + assert.equal(recoverRendererAfterGone(details, dependencies), true); + assert.equal(calls.reload, 1); + assert.deepEqual(calls.logs, [{ details, reloaded: true }]); + }); + + it("clean exits and windows outside the live main lifecycle are not reloaded", () => { + mock.timers.enable({ apis: ["Date", "setTimeout"] }); + mock.timers.tick(10_000); + + const cases = [ + [{ reason: "clean-exit", exitCode: 0 }, {}], + [{ reason: "crashed", exitCode: 1 }, { isCurrentWindow: false }], + [{ reason: "crashed", exitCode: 1 }, { quitting: true }], + [{ reason: "crashed", exitCode: 1 }, { windowCloseAccepted: true }], + [{ reason: "crashed", exitCode: 1 }, { windowDestroyed: true }], + [{ reason: "crashed", exitCode: 1 }, { webContentsDestroyed: true }], + ]; + + for (const [details, overrides] of cases) { + // Advance clock so cooldown does not interfere. + mock.timers.tick(10_000); + const { calls, dependencies } = recovery(overrides); + assert.equal(recoverRendererAfterGone(details, dependencies), false); + assert.equal(calls.reload, 0); + assert.deepEqual(calls.logs, [{ details, reloaded: false }]); + } + }); + + it("new renderer exit reasons remain recoverable by default", () => { + mock.timers.enable({ apis: ["Date", "setTimeout"] }); + mock.timers.tick(10_000); + + const { calls, dependencies } = recovery(); + assert.equal( + recoverRendererAfterGone({ reason: "future-electron-reason", exitCode: 2 }, dependencies), + true, + ); + assert.equal(calls.reload, 1); + }); + + it("rapid consecutive crashes delay the reload instead of looping", () => { + mock.timers.enable({ apis: ["Date", "setTimeout"] }); + mock.timers.tick(10_000); + + const first = recovery(); + const details = { reason: "crashed", exitCode: 1 }; + + // First crash reloads immediately. + assert.equal(recoverRendererAfterGone(details, first.dependencies), true); + assert.equal(first.calls.reload, 1); + + // Second crash within cooldown window — reload is scheduled, not immediate. + mock.timers.tick(500); + const second = recovery(); + assert.equal(recoverRendererAfterGone(details, second.dependencies), true); + assert.equal(second.calls.reload, 0, "should not reload synchronously during cooldown"); + + // After the remaining cooldown elapses, the delayed reload fires. + mock.timers.tick(1_500); + assert.equal(second.calls.reload, 1, "delayed reload should have fired"); + }); + + it("crashes after the cooldown window reload immediately", () => { + mock.timers.enable({ apis: ["Date", "setTimeout"] }); + mock.timers.tick(10_000); + + const first = recovery(); + const details = { reason: "crashed", exitCode: 1 }; + recoverRendererAfterGone(details, first.dependencies); -test("new renderer exit reasons remain recoverable by default", () => { - const { calls, dependencies } = recovery(); - assert.equal( - recoverRendererAfterGone({ reason: "future-electron-reason", exitCode: 2 }, dependencies), - true, - ); - assert.equal(calls.reload, 1); + // Wait longer than cooldown. + mock.timers.tick(3_000); + const second = recovery(); + assert.equal(recoverRendererAfterGone(details, second.dependencies), true); + assert.equal(second.calls.reload, 1, "should reload immediately after cooldown"); + }); });