Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/desktop/electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
37 changes: 33 additions & 4 deletions apps/desktop/electron/main/renderer-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,51 @@ 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 &&
!dependencies.windowCloseAccepted &&
!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;
}
121 changes: 88 additions & 33 deletions apps/desktop/test/renderer-recovery.test.mjs
Original file line number Diff line number Diff line change
@@ -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: [] };
Expand All @@ -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");
});
});
Loading