Automatically recover from a renderer crash instead of leaving a blank window (#33988)

* Automatically recover from a renderer crash instead of leaving a blank window

The renderer (or GPU process) could die and leave a permanent blank/white window with no
UI, escapable only by killing the whole app. Add a dedicated renderer-recovery module that
detects render-process-gone / unresponsive and reloads or recreates the renderer with capped
backoff, giving up only after repeated failures.

Route the dock-activate and window-show paths through it as well, so a crash that happened
while the window was hidden is recovered before the window is shown rather than surfacing the
white screen.

* Address review: use author copyright and drop empty beforeEach
This commit is contained in:
hayyaksi
2026-07-14 12:42:51 +00:00
committed by GitHub
parent da500d4b7d
commit 9575b236c0
4 changed files with 615 additions and 0 deletions
+20
View File
@@ -42,6 +42,7 @@ import { _t, AppLocalization } from "./language-helper.js";
import { setDisplayMediaCallback } from "./displayMediaCallback.js";
import { setupMacosTitleBar } from "./macos-titlebar.js";
import { setupMediaAuth } from "./media-auth.js";
import { type RendererRecovery, setupRendererRecovery } from "./renderer-recovery.js";
import { getBuildConfig } from "./build-config.js";
import { getAsarPath } from "./asar.js";
import { getIconPath } from "./icon.js";
@@ -56,6 +57,11 @@ const args = getArgs(protocolHandler);
app.setPath("userData", args.userDataPath);
// Renderer crash auto-recovery for the main window (element-web#32222). Held at module scope so the
// dock `activate` / `second-instance` relaunch handlers can route a crashed renderer through the same
// capped recovery rather than reloading inline (which would re-arm an already-given-up crash loop).
let rendererRecovery: RendererRecovery | undefined;
// Configure Electron Sentry and crashReporter using sentry.dsn in config.json if one is present.
async function configureSentry(): Promise<void> {
const config = await loadConfig(args.localConfigPath);
@@ -377,6 +383,11 @@ app.on("ready", async () => {
webContentsHandler(global.mainWindow.webContents);
// Auto-recover from an upstream renderer/GPU-process crash (white screen, element-web#32222). This
// is a MITIGATION of an upstream Electron/Chromium defect, not a root-cause fix — without it a dead
// renderer stays a permanent blank window the user can only escape by killing the whole app.
rendererRecovery = setupRendererRecovery(global.mainWindow);
session.defaultSession.setDisplayMediaRequestHandler(
(_, callback) => {
if (process.env.XDG_SESSION_TYPE === "wayland") {
@@ -410,6 +421,11 @@ app.on("window-all-closed", () => {
});
app.on("activate", () => {
// If the renderer crashed while the window was hidden (element-web#32222), reload it before showing
// so the user sees the UI rather than the white screen. Routed through the capped recovery (rather
// than an inline reload) so a relaunch can't re-arm a crash loop we've already given up on; it is a
// no-op when the renderer is healthy.
rendererRecovery?.recoverIfCrashed();
global.mainWindow?.show();
});
@@ -427,6 +443,10 @@ app.on("second-instance", (ev, commandLine, workingDirectory) => {
// Someone tried to run a second instance, we should focus our window.
if (global.mainWindow) {
// If the renderer crashed (element-web#32222), reload before surfacing the window so the user is
// brought to a working UI rather than a white screen. Routed through the capped recovery so a
// relaunch can't re-arm a crash loop we've already given up on; a no-op for a healthy window.
rendererRecovery?.recoverIfCrashed();
if (!global.mainWindow.isVisible()) global.mainWindow.show();
if (global.mainWindow.isMinimized()) global.mainWindow.restore();
global.mainWindow.focus();
+5
View File
@@ -53,6 +53,11 @@
"services": "Services",
"unhide": "Unhide"
},
"renderer_crash": {
"detail": "Quit and reopen the app to continue. If this keeps happening, restarting your computer or clearing the app's cache may help.",
"message": "%(brand)s recovered from a problem several times but it keeps happening, so it has stopped trying.",
"title": "%(brand)s keeps crashing"
},
"right_click_menu": {
"add_to_dictionary": "Add to dictionary",
"copy_email": "Copy email address",
+350
View File
@@ -0,0 +1,350 @@
/*
Copyright 2026 hayaksi1
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { type BrowserWindow, type RenderProcessGoneDetails } from "electron";
import {
CRASH_REASONS,
RECOVERY_ATTEMPT_CAP,
RECOVERY_WINDOW_MS,
decideRendererRecoveryAction,
RendererRecovery,
setupRendererRecovery,
} from "./renderer-recovery.js";
// `_t` is irrelevant to the recovery logic; stub it so importing the module under test (which pulls in
// language-helper transitively via the dialog copy) never touches the real i18n machinery.
vi.mock("./language-helper.js", () => ({
_t: (key: string): string => key,
}));
vi.mock("./config.js", () => ({
getConfig: (): { brand: string } => ({ brand: "Element" }),
}));
describe("decideRendererRecoveryAction", () => {
it.each(CRASH_REASONS)("returns 'reload' for the crash-class reason %s", (reason) => {
expect(decideRendererRecoveryAction({ reason, appQuitting: false, attemptsInWindow: 0 })).toBe("reload");
});
it.each(["clean-exit", "killed", "abnormal-exit", "memory-eviction"] as const)(
"returns 'ignore' for the non-crash reason %s",
(reason) => {
expect(decideRendererRecoveryAction({ reason, appQuitting: false, attemptsInWindow: 0 })).toBe("ignore");
},
);
it("returns 'ignore' when a quit is in progress even for a crash reason", () => {
expect(decideRendererRecoveryAction({ reason: "crashed", appQuitting: true, attemptsInWindow: 0 })).toBe(
"ignore",
);
});
it("returns 'reload' while still under the attempt cap", () => {
expect(
decideRendererRecoveryAction({
reason: "crashed",
appQuitting: false,
attemptsInWindow: RECOVERY_ATTEMPT_CAP - 1,
}),
).toBe("reload");
});
it("returns 'dialog' once the attempt cap is reached (crash-loop guard)", () => {
expect(
decideRendererRecoveryAction({
reason: "crashed",
appQuitting: false,
attemptsInWindow: RECOVERY_ATTEMPT_CAP,
}),
).toBe("dialog");
});
});
// A fake BrowserWindow that captures the webContents listeners into a map so tests can fire them,
// mirroring the window-state.test.ts buildWin() pattern.
function buildFakeWin(): {
win: BrowserWindow;
handlers: Record<string, (...args: unknown[]) => void>;
reload: ReturnType<typeof vi.fn>;
isCrashed: ReturnType<typeof vi.fn>;
isDestroyed: ReturnType<typeof vi.fn>;
} {
const handlers: Record<string, (...args: unknown[]) => void> = {};
const reload = vi.fn();
const isCrashed = vi.fn(() => false);
const isDestroyed = vi.fn(() => false);
const win = {
isDestroyed,
webContents: {
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
handlers[event] = cb;
}),
reload,
isCrashed,
},
} as unknown as BrowserWindow;
return { win, handlers, reload, isCrashed, isDestroyed };
}
const goneDetails = (reason: RenderProcessGoneDetails["reason"]): RenderProcessGoneDetails =>
({ reason }) as RenderProcessGoneDetails;
describe("RendererRecovery", () => {
let now = 0;
const clock = (): number => now;
const showDialog = vi.fn<() => void>();
beforeEach(() => {
now = 0;
showDialog.mockClear();
});
it("reloads the window on a 'crashed' render-process-gone event", () => {
const { win, handlers, reload } = buildFakeWin();
new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register();
handlers["render-process-gone"]({}, goneDetails("crashed"));
expect(reload).toHaveBeenCalledTimes(1);
expect(showDialog).not.toHaveBeenCalled();
});
it.each(["oom", "launch-failed", "integrity-failure"] as const)(
"reloads the window on a '%s' render-process-gone event",
(reason) => {
const { win, handlers, reload } = buildFakeWin();
new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register();
handlers["render-process-gone"]({}, goneDetails(reason));
expect(reload).toHaveBeenCalledTimes(1);
},
);
it("does NOT reload for 'clean-exit' or 'killed'", () => {
const { win, handlers, reload } = buildFakeWin();
new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register();
handlers["render-process-gone"]({}, goneDetails("clean-exit"));
handlers["render-process-gone"]({}, goneDetails("killed"));
expect(reload).not.toHaveBeenCalled();
expect(showDialog).not.toHaveBeenCalled();
});
it("does NOT reload while the app is quitting (legitimate shutdown / macOS app.hide path)", () => {
const { win, handlers, reload } = buildFakeWin();
new RendererRecovery({ win, clock, isQuitting: (): boolean => true, showDialog }).register();
handlers["render-process-gone"]({}, goneDetails("crashed"));
expect(reload).not.toHaveBeenCalled();
expect(showDialog).not.toHaveBeenCalled();
});
it("does NOT reload a window that has already been destroyed", () => {
const { win, handlers, reload, isDestroyed } = buildFakeWin();
isDestroyed.mockReturnValue(true);
new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register();
handlers["render-process-gone"]({}, goneDetails("crashed"));
expect(reload).not.toHaveBeenCalled();
});
it("stops reloading after the attempt cap and shows the error dialog instead (crash-loop guard)", () => {
const { win, handlers, reload } = buildFakeWin();
new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register();
// The first RECOVERY_ATTEMPT_CAP crashes reload; the next one trips the cap and shows the dialog.
for (let i = 0; i < RECOVERY_ATTEMPT_CAP; i++) {
handlers["render-process-gone"]({}, goneDetails("crashed"));
}
expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP);
expect(showDialog).not.toHaveBeenCalled();
handlers["render-process-gone"]({}, goneDetails("crashed"));
expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP); // no further reload
expect(showDialog).toHaveBeenCalledTimes(1);
});
it("resets the attempt counter once crashes fall outside the rolling window", () => {
const { win, handlers, reload } = buildFakeWin();
new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register();
for (let i = 0; i < RECOVERY_ATTEMPT_CAP; i++) {
handlers["render-process-gone"]({}, goneDetails("crashed"));
}
expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP);
// Advance past the rolling window: the earlier attempts no longer count, so we reload again.
now += RECOVERY_WINDOW_MS + 1;
handlers["render-process-gone"]({}, goneDetails("crashed"));
expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP + 1);
expect(showDialog).not.toHaveBeenCalled();
});
it("reloads once on the first 'unresponsive' event but not repeatedly (bounded)", () => {
const { win, handlers, reload } = buildFakeWin();
new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register();
handlers["unresponsive"]();
expect(reload).toHaveBeenCalledTimes(1);
// A second hang inside the same window must not stack reloads.
handlers["unresponsive"]();
expect(reload).toHaveBeenCalledTimes(1);
});
it("does NOT reload on 'unresponsive' while quitting", () => {
const { win, handlers, reload } = buildFakeWin();
new RendererRecovery({ win, clock, isQuitting: (): boolean => true, showDialog }).register();
handlers["unresponsive"]();
expect(reload).not.toHaveBeenCalled();
});
it("shows the error dialog (instead of reloading) when 'unresponsive' fires while already at the reload cap", () => {
const { win, handlers, reload } = buildFakeWin();
new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog }).register();
// Fill the rolling window with crash reloads up to the cap so a hang now can't reload again.
for (let i = 0; i < RECOVERY_ATTEMPT_CAP; i++) {
handlers["render-process-gone"]({}, goneDetails("crashed"));
}
expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP);
handlers["unresponsive"]();
expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP); // no further reload
expect(showDialog).toHaveBeenCalledTimes(1);
});
});
// A user-initiated relaunch (dock activate / second-instance) must reload a crashed renderer through the
// SAME crash-loop cap, so a relaunch can't silently re-arm a loop the recovery has already given up on.
describe("RendererRecovery.recoverIfCrashed", () => {
let now = 0;
const clock = (): number => now;
const showDialog = vi.fn<() => void>();
beforeEach(() => {
now = 0;
showDialog.mockClear();
});
it("reloads a crashed renderer when under the cap", () => {
const { win, reload, isCrashed } = buildFakeWin();
isCrashed.mockReturnValue(true);
const recovery = new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog });
recovery.register();
recovery.recoverIfCrashed();
expect(reload).toHaveBeenCalledTimes(1);
expect(showDialog).not.toHaveBeenCalled();
});
it("does nothing when the renderer is not crashed", () => {
const { win, reload } = buildFakeWin(); // isCrashed defaults to false
const recovery = new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog });
recovery.register();
recovery.recoverIfCrashed();
expect(reload).not.toHaveBeenCalled();
expect(showDialog).not.toHaveBeenCalled();
});
it("does NOT reload (and shows the dialog) when crashed but the crash-loop cap was already hit", () => {
const { win, handlers, reload, isCrashed } = buildFakeWin();
isCrashed.mockReturnValue(true);
const recovery = new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog });
recovery.register();
// Exhaust the cap via genuine crash events so the loop has already been given up on.
for (let i = 0; i < RECOVERY_ATTEMPT_CAP; i++) {
handlers["render-process-gone"]({}, goneDetails("crashed"));
}
showDialog.mockClear();
expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP);
// A user-initiated relaunch must not silently re-arm the loop.
recovery.recoverIfCrashed();
expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP); // no extra reload
expect(showDialog).toHaveBeenCalledTimes(1);
});
it("counts its OWN reloads toward the crash-loop cap (relaunch can't reload past the cap)", () => {
const { win, reload, isCrashed } = buildFakeWin();
isCrashed.mockReturnValue(true);
const recovery = new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog });
recovery.register();
// Mashing dock-activate / second-instance on a crashed renderer must not reload past the cap:
// recoverIfCrashed records each of its own reloads, so the shared cap is fed both ways.
for (let i = 0; i < RECOVERY_ATTEMPT_CAP; i++) {
recovery.recoverIfCrashed();
}
expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP);
expect(showDialog).not.toHaveBeenCalled();
recovery.recoverIfCrashed();
expect(reload).toHaveBeenCalledTimes(RECOVERY_ATTEMPT_CAP); // no extra reload — loop given up on
expect(showDialog).toHaveBeenCalledTimes(1);
});
it("does nothing when the window is destroyed", () => {
const { win, reload, isCrashed, isDestroyed } = buildFakeWin();
isCrashed.mockReturnValue(true);
isDestroyed.mockReturnValue(true);
const recovery = new RendererRecovery({ win, clock, isQuitting: (): boolean => false, showDialog });
recovery.register();
recovery.recoverIfCrashed();
expect(reload).not.toHaveBeenCalled();
expect(showDialog).not.toHaveBeenCalled();
});
it("does nothing while the app is quitting", () => {
const { win, reload, isCrashed } = buildFakeWin();
isCrashed.mockReturnValue(true);
const recovery = new RendererRecovery({ win, clock, isQuitting: (): boolean => true, showDialog });
recovery.register();
recovery.recoverIfCrashed();
expect(reload).not.toHaveBeenCalled();
});
});
describe("setupRendererRecovery", () => {
it("registers render-process-gone and unresponsive listeners on the window's webContents", () => {
const { win } = buildFakeWin();
setupRendererRecovery(win);
const on = vi.mocked(win.webContents.on);
const events = on.mock.calls.map((c) => c[0]);
expect(events).toContain("render-process-gone");
expect(events).toContain("unresponsive");
});
it("returns the RendererRecovery instance so callers can route relaunch recovery through the cap", () => {
const { win } = buildFakeWin();
const recovery = setupRendererRecovery(win);
expect(recovery).toBeInstanceOf(RendererRecovery);
});
});
+240
View File
@@ -0,0 +1,240 @@
/*
Copyright 2026 hayaksi1
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type BrowserWindow, type RenderProcessGoneDetails, dialog } from "electron";
import { _t } from "./language-helper.js";
import { getConfig } from "./config.js";
/**
* Auto-recovery for a dead renderer ("white screen, no UI after switching back", element-web#32222).
*
* IMPORTANT — this is a MITIGATION, not a root-cause fix. The white screen itself is an UPSTREAM
* Electron/Chromium renderer/GPU-process crash (commonly a corrupted GPUCache) that reproduces across
* Linux/Windows/macOS and which we cannot fix in this repository. What we *can* fix is the in-repo gap:
* previously there was no `render-process-gone` / `unresponsive` handler anywhere in the main process,
* so once the renderer died the window stayed permanently blank and the user had to kill the whole app.
* We turn that dead window back into a reload. The render-process-gone → reload pattern is industry
* standard (VS Code / Slack / Discord all do it).
*
* The decision logic is kept pure and the Electron wiring thin so it can be unit-tested without a GUI.
*/
/**
* The crash-class `render-process-gone` reasons we treat as recoverable and reload for.
*
* Deliberately EXCLUDED from the union of possible reasons:
* - `clean-exit` — the renderer exited normally (e.g. teardown); nothing to recover.
* - `killed` — the process was killed (often by us / the OS on purpose); don't fight it.
* - `abnormal-exit` — ambiguous; can be an intentional kill, so we stay conservative and don't reload.
* - `memory-eviction` — Chromium reclaiming a backgrounded renderer to save memory; reloading here
* would cause spurious reloads when the window is simply hidden.
*/
export const CRASH_REASONS = ["crashed", "oom", "launch-failed", "integrity-failure"] as const;
export type CrashReason = (typeof CRASH_REASONS)[number];
/** How many reloads we permit inside {@link RECOVERY_WINDOW_MS} before we give up and warn instead. */
export const RECOVERY_ATTEMPT_CAP = 3;
/** Rolling window over which {@link RECOVERY_ATTEMPT_CAP} is counted, to distinguish a one-off crash from a loop. */
export const RECOVERY_WINDOW_MS = 60 * 1000;
/** The action the recovery logic decides to take for a given `render-process-gone` event. */
export type RecoveryAction = "reload" | "dialog" | "ignore";
function isCrashReason(reason: RenderProcessGoneDetails["reason"]): reason is CrashReason {
return (CRASH_REASONS as readonly string[]).includes(reason);
}
/**
* Pure decision for what to do when the renderer is gone. No side effects, fully unit-testable.
*
* @param input.reason - the `render-process-gone` reason reported by Electron.
* @param input.appQuitting - whether a legitimate app quit is underway (so we must NOT reload).
* @param input.attemptsInWindow - reloads already performed inside the current rolling window.
* @returns `"ignore"` for benign reasons / during quit, `"dialog"` once the cap is hit (crash loop),
* otherwise `"reload"`.
*/
export function decideRendererRecoveryAction(input: {
reason: RenderProcessGoneDetails["reason"];
appQuitting: boolean;
attemptsInWindow: number;
}): RecoveryAction {
// Never resurrect a renderer that went away as part of a legitimate shutdown (including the macOS
// app.hide() path, where we deliberately tear things down) — reloading then would fight the quit.
if (input.appQuitting) return "ignore";
// Only act on genuine crash-class reasons; benign exits are left alone.
if (!isCrashReason(input.reason)) return "ignore";
// Crash-LOOP guard: once we've already reloaded the cap's worth of times in this window, stop
// reloading (it clearly isn't recovering) and surface an error dialog instead.
if (input.attemptsInWindow >= RECOVERY_ATTEMPT_CAP) return "dialog";
return "reload";
}
/** Minimal surface of `BrowserWindow` the recovery needs — kept narrow so tests can fake it. */
type RecoverableWindow = Pick<BrowserWindow, "isDestroyed"> & {
webContents: Pick<BrowserWindow["webContents"], "on" | "reload" | "isCrashed">;
};
/** Injectable dependencies so the wiring is testable without a live Electron GUI. */
export interface RendererRecoveryDeps {
win: RecoverableWindow;
/** Returns the current time in ms (injected so the rolling window can be tested deterministically). */
clock: () => number;
/** Whether a real quit is in progress (wraps `global.appQuitting`). */
isQuitting: () => boolean;
/** Shows the "couldn't recover" error dialog. Injected so tests don't pop a real dialog. */
showDialog: () => void;
}
/**
* Stateful coordinator wrapping {@link decideRendererRecoveryAction} with the attempt accounting and
* the actual Electron side effects (reload / dialog). One instance per window.
*/
export class RendererRecovery {
/** Timestamps (per {@link RendererRecoveryDeps.clock}) of the reloads still inside the rolling window. */
private attempts: number[] = [];
/** Whether we've already reloaded for an `unresponsive` hang in the current window (bounded once). */
private unresponsiveHandled = false;
public constructor(private readonly deps: RendererRecoveryDeps) {}
/** Wire the recovery handlers onto the window's webContents. */
public register(): void {
this.deps.win.webContents.on("render-process-gone", (_event, details) => {
this.onRenderProcessGone(details);
});
this.deps.win.webContents.on("unresponsive", () => {
this.onUnresponsive();
});
}
/** Drop attempt timestamps that have aged out of the rolling window. */
private pruneAttempts(): void {
const cutoff = this.deps.clock() - RECOVERY_WINDOW_MS;
this.attempts = this.attempts.filter((t) => t > cutoff);
// Once the window is quiet again, allow a future hang to be recovered once more.
if (this.attempts.length === 0) this.unresponsiveHandled = false;
}
private onRenderProcessGone(details: RenderProcessGoneDetails): void {
// MITIGATION (element-web#32222): the renderer died upstream — try to bring the UI back rather
// than leaving a permanent white screen the user can only escape by killing the whole app.
console.warn(`renderer-recovery: render-process-gone, reason=${details.reason}`);
if (this.deps.win.isDestroyed()) return;
this.pruneAttempts();
const action = decideRendererRecoveryAction({
reason: details.reason,
appQuitting: this.deps.isQuitting(),
attemptsInWindow: this.attempts.length,
});
this.performAction(action);
}
/**
* Recover a renderer that is *already* crashed, driven by a user-initiated relaunch (the dock
* `activate` / `second-instance` paths in electron-main.ts) rather than a `render-process-gone`
* event. Routed through the SAME attempt cap as {@link onRenderProcessGone} so a relaunch can't
* silently re-arm a crash loop we've already given up on (element-web#32222) — once the cap is hit
* the user gets the error dialog instead of yet another reload.
*/
public recoverIfCrashed(): void {
if (this.deps.win.isDestroyed()) return;
if (!this.deps.win.webContents.isCrashed()) return;
this.pruneAttempts();
const action = decideRendererRecoveryAction({
// The renderer is crashed (isCrashed() above); treat it as a crash-class recovery.
reason: "crashed",
appQuitting: this.deps.isQuitting(),
attemptsInWindow: this.attempts.length,
});
this.performAction(action);
}
/** Execute the decided {@link RecoveryAction}: reload (recording the attempt) / dialog / ignore. */
private performAction(action: RecoveryAction): void {
switch (action) {
case "reload":
this.attempts.push(this.deps.clock());
console.warn(
`renderer-recovery: reloading renderer (attempt ${this.attempts.length}/${RECOVERY_ATTEMPT_CAP})`,
);
this.deps.win.webContents.reload();
break;
case "dialog":
console.error("renderer-recovery: renderer crash loop detected, giving up and warning the user");
this.deps.showDialog();
break;
case "ignore":
break;
}
}
private onUnresponsive(): void {
// A hung (not crashed) renderer: conservatively reload at most once per rolling window, and never
// during a quit. We reuse the same attempt cap so a hang-loop can't reload forever either.
console.warn("renderer-recovery: renderer unresponsive");
if (this.deps.win.isDestroyed() || this.deps.isQuitting()) return;
this.pruneAttempts();
if (this.unresponsiveHandled) return;
if (this.attempts.length >= RECOVERY_ATTEMPT_CAP) {
console.error("renderer-recovery: unresponsive while already at the reload cap, warning the user");
this.deps.showDialog();
return;
}
this.unresponsiveHandled = true;
this.attempts.push(this.deps.clock());
console.warn("renderer-recovery: reloading unresponsive renderer");
this.deps.win.webContents.reload();
}
}
/**
* Show the "we couldn't recover the window" error dialog. Mirrors the dialog/i18n convention used by
* store.ts / electron-main.ts (`dialog.showMessageBox` + `_t`).
*/
function showCrashLoopDialog(win: BrowserWindow): void {
const brand = getConfig().brand;
void dialog.showMessageBox(win, {
type: "error",
title: _t("renderer_crash|title", { brand }),
message: _t("renderer_crash|message", { brand }),
detail: _t("renderer_crash|detail"),
buttons: [_t("action|close")],
});
}
/**
* Install renderer auto-recovery on the main window. Thin Electron wiring around {@link RendererRecovery};
* follows the `setupX(win)` named-export seam used by media-auth.ts / media-permissions.ts.
*
* @param win - the main BrowserWindow whose renderer we guard.
* @returns the {@link RendererRecovery} instance so the caller can route user-initiated relaunch
* recovery (dock `activate` / `second-instance`) through {@link RendererRecovery.recoverIfCrashed}.
*/
export function setupRendererRecovery(win: BrowserWindow): RendererRecovery {
const recovery = new RendererRecovery({
win,
clock: (): number => Date.now(),
isQuitting: (): boolean => global.appQuitting,
showDialog: (): void => showCrashLoopDialog(win),
});
recovery.register();
return recovery;
}