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:
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user