diff --git a/apps/desktop/src/electron-main.ts b/apps/desktop/src/electron-main.ts index 261a2a390f..9c2ee7687f 100644 --- a/apps/desktop/src/electron-main.ts +++ b/apps/desktop/src/electron-main.ts @@ -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 { 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(); diff --git a/apps/desktop/src/i18n/strings/en_EN.json b/apps/desktop/src/i18n/strings/en_EN.json index 88c584a00c..fdcdf662d1 100644 --- a/apps/desktop/src/i18n/strings/en_EN.json +++ b/apps/desktop/src/i18n/strings/en_EN.json @@ -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", diff --git a/apps/desktop/src/renderer-recovery.test.ts b/apps/desktop/src/renderer-recovery.test.ts new file mode 100644 index 0000000000..b50376197e --- /dev/null +++ b/apps/desktop/src/renderer-recovery.test.ts @@ -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 void>; + reload: ReturnType; + isCrashed: ReturnType; + isDestroyed: ReturnType; +} { + const handlers: Record 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); + }); +}); diff --git a/apps/desktop/src/renderer-recovery.ts b/apps/desktop/src/renderer-recovery.ts new file mode 100644 index 0000000000..cd4fafa504 --- /dev/null +++ b/apps/desktop/src/renderer-recovery.ts @@ -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 & { + webContents: Pick; +}; + +/** 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; +}