From 0a057e9b3a99f83927ec2d6907938bd429b70f46 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 15 Oct 2026 12:20:43 +0100 Subject: [PATCH 1/4] fix: Stop renderer ANR watchdogs leaking and re-map protocol renderers after reload The core watchdog has no stop, so every renderer left a 20 ms interval running for the life of the app and fired a false ANR once the renderer stopped polling. blur/focus used once, the debugger script map grew with every eval, and in protocol mode a reload gave the renderer a new id that main never mapped. Adds a stoppable watchdog with full listener cleanup on destroyed, disables it on render-process-gone, bounds the script map, and makes newProtocolRenderer resolve the id sent with the start request. --- src/main/integrations/renderer-anr.ts | 137 ++++++++++++++++---------- src/main/ipc.ts | 56 ++++++++--- src/main/watchdog.ts | 59 +++++++++++ test/unit/watchdog.test.ts | 85 ++++++++++++++++ 4 files changed, 270 insertions(+), 67 deletions(-) create mode 100644 src/main/watchdog.ts create mode 100644 test/unit/watchdog.test.ts diff --git a/src/main/integrations/renderer-anr.ts b/src/main/integrations/renderer-anr.ts index 23a9df8d..35bacc5c 100644 --- a/src/main/integrations/renderer-anr.ts +++ b/src/main/integrations/renderer-anr.ts @@ -1,6 +1,6 @@ import type { Client, Event, Integration, StackFrame } from '@sentry/core'; import { captureEvent, debug, defineIntegration, stripSentryFramesAndReverse } from '@sentry/core'; -import { callFrameToStackFrame, watchdogTimer } from '@sentry/core/server'; +import { callFrameToStackFrame } from '@sentry/core/server'; import { createGetModuleFromFilename } from '@sentry/node'; import type { WebContents } from 'electron'; import { app, powerMonitor } from 'electron'; @@ -9,11 +9,18 @@ import { addHeaderToSession } from '../header-injection.js'; import type { ElectronMainOptionsInternal } from '../sdk.js'; import { sessionAnr } from '../sessions.js'; import { captureRendererStackFrames } from '../stack-parse.js'; +import type { Watchdog } from '../watchdog.js'; +import { createWatchdog } from '../watchdog.js'; function log(message: string, ...args: unknown[]): void { debug.log(`[Renderer Event Loop Block] ${message}`, ...args); } +// Upper bound for the scriptId -> url map of one renderer. Each eval +// parses a new script, so without a bound the map grows for the life +// of the renderer. +const MAX_TRACKED_SCRIPTS = 10_000; + interface ScriptParsedEventDataType { scriptId: string; url: string; @@ -62,13 +69,25 @@ function debuggerStackTraceCapture(contents: WebContents, pausedStack: StackFram log('Connecting to debugger'); contents.debugger.attach('1.3'); - // Collect scriptId -> url map so we can look up the filenames later + // Collect scriptId -> url map so we can look up the filenames later. + // Paused call frames do not carry a url in Electron. const scripts = new Map(); const getModuleFromFilename = createGetModuleFromFilename(app.getAppPath()); + // A new document parses its own scripts, so the old ones are gone + contents.on('did-navigate', () => scripts.clear()); + contents.debugger.on('message', (_, method, params) => { if (method === 'Debugger.scriptParsed') { const param = params as ScriptParsedEventDataType; + + if (scripts.size >= MAX_TRACKED_SCRIPTS) { + const oldest = scripts.keys().next(); + if (!oldest.done) { + scripts.delete(oldest.value); + } + } + scripts.set(param.scriptId, param.url); } else if (method === 'Debugger.paused') { const param = params as PausedEventDataType; @@ -105,7 +124,9 @@ function debuggerStackTraceCapture(contents: WebContents, pausedStack: StackFram } log('Pausing debugger to capture stack trace'); - return contents.debugger.sendCommand('Debugger.pause'); + contents.debugger.sendCommand('Debugger.pause').catch(() => { + // ignore + }); }; } @@ -148,7 +169,7 @@ type RendererEventLoopBlockIntegration = Integration & { */ export const rendererEventLoopBlockIntegration: (options?: Options) => RendererEventLoopBlockIntegration = defineIntegration((options: Options = {}) => { - const rendererWatchdogTimers = new Map>(); + const rendererWatchdogTimers = new Map(); let clientOptions: ElectronMainOptionsInternal | undefined; function getRendererName(contents: WebContents): string | undefined { @@ -183,6 +204,64 @@ export const rendererEventLoopBlockIntegration: (options?: Options) => RendererE captureEvent(event); } + function startWatchdog(contents: WebContents, config: RendererStatus['config']): Watchdog { + let pauseAndCapture: (() => void) | undefined; + + if (config.captureStackTrace) { + const stackCaptureImpl = options.captureNativeStacktrace ? nativeStackTraceCapture : debuggerStackTraceCapture; + + pauseAndCapture = stackCaptureImpl(contents, (frames) => { + log('Event captured with stack frames'); + sendRendererEventLoopBlockEvent(contents, config.anrThreshold, frames); + }); + } + + const watchdog = createWatchdog(createHrTimer, 100, config.anrThreshold, () => { + log('Watchdog timeout'); + if (pauseAndCapture) { + pauseAndCapture(); + } else { + log('Capturing event'); + sendRendererEventLoopBlockEvent(contents, config.anrThreshold); + } + }); + + const disable = (): void => watchdog.enabled(false); + const enable = (): void => watchdog.enabled(true); + + // A destroyed webContents never polls again, so the watchdog must + // stop too or it reports a block that never happened + const teardown = (): void => { + watchdog.stop(); + rendererWatchdogTimers.delete(contents); + + if (!contents.isDestroyed()) { + contents.off('blur', disable); + contents.off('focus', enable); + contents.off('render-process-gone', disable); + } + + powerMonitor.off('suspend', disable); + powerMonitor.off('resume', enable); + powerMonitor.off('lock-screen', disable); + powerMonitor.off('unlock-screen', enable); + }; + + contents.on('blur', disable); + contents.on('focus', enable); + // A crashed renderer stops polling until it is reloaded. The + // reloaded page sends its visibility, which enables the watchdog + // again, so the watchdog and the debugger session are kept. + contents.on('render-process-gone', disable); + contents.once('destroyed', teardown); + powerMonitor.on('suspend', disable); + powerMonitor.on('resume', enable); + powerMonitor.on('lock-screen', disable); + powerMonitor.on('unlock-screen', enable); + + return watchdog; + } + return { name: INTEGRATION_NAME, setup: (client) => { @@ -204,57 +283,9 @@ export const rendererEventLoopBlockIntegration: (options?: Options) => RendererE return (message: RendererStatus, contents: WebContents): void => { let watchdog = rendererWatchdogTimers.get(contents); - // eslint-disable-next-line jsdoc/require-jsdoc - function disable(): void { - watchdog?.enabled(false); - } - - // eslint-disable-next-line jsdoc/require-jsdoc - function enable(): void { - watchdog?.enabled(true); - } - if (watchdog === undefined) { log('Renderer sent first status message', message.config); - let pauseAndCapture: (() => void) | undefined; - - if (message.config.captureStackTrace) { - const stackCaptureImpl = options.captureNativeStacktrace - ? nativeStackTraceCapture - : debuggerStackTraceCapture; - - pauseAndCapture = stackCaptureImpl(contents, (frames) => { - log('Event captured with stack frames'); - sendRendererEventLoopBlockEvent(contents, message.config.anrThreshold, frames); - }); - } - - watchdog = watchdogTimer(createHrTimer, 100, message.config.anrThreshold, async () => { - log('Watchdog timeout'); - if (pauseAndCapture) { - pauseAndCapture(); - } else { - log('Capturing event'); - sendRendererEventLoopBlockEvent(contents, message.config.anrThreshold); - } - }); - - contents.once('destroyed', () => { - rendererWatchdogTimers?.delete(contents); - - powerMonitor.off('suspend', disable); - powerMonitor.off('resume', enable); - powerMonitor.off('lock-screen', disable); - powerMonitor.off('unlock-screen', enable); - }); - - contents.once('blur', disable); - contents.once('focus', enable); - powerMonitor.on('suspend', disable); - powerMonitor.on('resume', enable); - powerMonitor.on('lock-screen', disable); - powerMonitor.on('unlock-screen', enable); - + watchdog = startWatchdog(contents, message.config); rendererWatchdogTimers.set(contents, watchdog); } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e0e20662..76612af2 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -50,29 +50,57 @@ export const ipcMainHooks = new EventEmitter(); let KNOWN_RENDERERS: Set | undefined; let WINDOW_ID_TO_WEB_CONTENTS: Map | undefined; -function newProtocolRenderer(): void { +/** + * Finds the webContents that sent a protocol `start` request and maps its + * window id to it. + * + * Every page load creates a new window id in the renderer, so a reload + * reaches here with an id that no webContents maps to yet. The lookup + * probes every webContents, including ones already mapped, so the stale + * mapping of a reloaded renderer is replaced. + */ +function newProtocolRenderer(startWindowId: string | undefined): void { KNOWN_RENDERERS = KNOWN_RENDERERS || new Set(); WINDOW_ID_TO_WEB_CONTENTS = WINDOW_ID_TO_WEB_CONTENTS || new Map(); + if (startWindowId && WINDOW_ID_TO_WEB_CONTENTS.has(startWindowId)) { + return; + } + for (const wc of webContents.getAllWebContents()) { const wcId = wc.id; - if (KNOWN_RENDERERS.has(wcId)) { + + if (wc.isDestroyed()) { continue; } - if (!wc.isDestroyed()) { - wc.executeJavaScript('window.__SENTRY_RENDERER_ID__').then((windowId: string | undefined) => { - if (windowId && KNOWN_RENDERERS && WINDOW_ID_TO_WEB_CONTENTS) { - KNOWN_RENDERERS.add(wcId); - WINDOW_ID_TO_WEB_CONTENTS.set(windowId, wcId); + wc.executeJavaScript('window.__SENTRY_RENDERER_ID__').then((windowId: string | undefined) => { + if (!windowId || !KNOWN_RENDERERS || !WINDOW_ID_TO_WEB_CONTENTS) { + return; + } - wc.once('destroyed', () => { - KNOWN_RENDERERS?.delete(wcId); - WINDOW_ID_TO_WEB_CONTENTS?.delete(windowId); - }); + for (const [previousWindowId, previousWcId] of WINDOW_ID_TO_WEB_CONTENTS) { + if (previousWcId === wcId && previousWindowId !== windowId) { + WINDOW_ID_TO_WEB_CONTENTS.delete(previousWindowId); } - }, debug.error); - } + } + + WINDOW_ID_TO_WEB_CONTENTS.set(windowId, wcId); + + if (!KNOWN_RENDERERS.has(wcId)) { + KNOWN_RENDERERS.add(wcId); + + wc.once('destroyed', () => { + KNOWN_RENDERERS?.delete(wcId); + + for (const [mappedWindowId, mappedWcId] of WINDOW_ID_TO_WEB_CONTENTS || []) { + if (mappedWcId === wcId) { + WINDOW_ID_TO_WEB_CONTENTS?.delete(mappedWindowId); + } + } + }); + } + }, debug.error); } } @@ -529,7 +557,7 @@ function configureProtocol(client: Client, ipcUtil: IpcUtils, options: ElectronM const data = request.body; if (ipcUtil.urlMatches(request.url, 'start')) { - newProtocolRenderer(); + newProtocolRenderer(request.windowId); } else if (ipcUtil.urlMatches(request.url, 'scope') && data) { handleScope(options, data.toString()); } else if (ipcUtil.urlMatches(request.url, 'envelope') && data) { diff --git a/src/main/watchdog.ts b/src/main/watchdog.ts new file mode 100644 index 00000000..5bc7e15d --- /dev/null +++ b/src/main/watchdog.ts @@ -0,0 +1,59 @@ +export interface Timer { + getTimeMs: () => number; + reset: () => void; +} + +export interface Watchdog { + /** Marks the watched process as alive */ + poll: () => void; + /** Turns the callback on or off. Polling continues either way. */ + enabled: (state: boolean) => void; + /** Stops the watchdog. It cannot be restarted. */ + stop: () => void; +} + +/** + * Calls `callback` once when more than `pollInterval + anrThreshold` ms + * pass without a `poll()`. It arms again once polling resumes. + */ +export function createWatchdog( + createTimer: () => Timer, + pollInterval: number, + anrThreshold: number, + callback: () => void, +): Watchdog { + const timer = createTimer(); + let triggered = false; + let enabled = true; + + const interval = setInterval(() => { + const diffMs = timer.getTimeMs(); + + if (!triggered && diffMs > pollInterval + anrThreshold) { + triggered = true; + if (enabled) { + callback(); + } + } + + if (diffMs < pollInterval + anrThreshold) { + triggered = false; + } + }, 20); + + // Not unref'd on purpose. In the Electron main process libuv only runs + // timers while a referenced handle exists, so an unref'd interval can + // stall when the app is otherwise idle. + + return { + poll: () => { + timer.reset(); + }, + enabled: (state: boolean) => { + enabled = state; + }, + stop: () => { + clearInterval(interval); + }, + }; +} diff --git a/test/unit/watchdog.test.ts b/test/unit/watchdog.test.ts new file mode 100644 index 00000000..59f964a7 --- /dev/null +++ b/test/unit/watchdog.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { Timer } from '../../src/main/watchdog'; +import { createWatchdog } from '../../src/main/watchdog'; + +const POLL_INTERVAL = 100; +const THRESHOLD = 1000; + +/** A timer driven by the fake clock */ +function createFakeTimer(): Timer { + let last = Date.now(); + + return { + getTimeMs: () => Date.now() - last, + reset: () => { + last = Date.now(); + }, + }; +} + +describe('createWatchdog', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('fires once when polling stops', () => { + const callback = vi.fn(); + const watchdog = createWatchdog(createFakeTimer, POLL_INTERVAL, THRESHOLD, callback); + + vi.advanceTimersByTime(POLL_INTERVAL + THRESHOLD); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(40); + expect(callback).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(THRESHOLD * 5); + expect(callback).toHaveBeenCalledTimes(1); + + watchdog.stop(); + }); + + test('does not fire while polled', () => { + const callback = vi.fn(); + const watchdog = createWatchdog(createFakeTimer, POLL_INTERVAL, THRESHOLD, callback); + + for (let i = 0; i < 50; i++) { + vi.advanceTimersByTime(POLL_INTERVAL); + watchdog.poll(); + } + + expect(callback).not.toHaveBeenCalled(); + watchdog.stop(); + }); + + test('does not fire while disabled and arms again after recovery', () => { + const callback = vi.fn(); + const watchdog = createWatchdog(createFakeTimer, POLL_INTERVAL, THRESHOLD, callback); + + watchdog.enabled(false); + vi.advanceTimersByTime(POLL_INTERVAL + THRESHOLD + 40); + expect(callback).not.toHaveBeenCalled(); + + watchdog.enabled(true); + watchdog.poll(); + vi.advanceTimersByTime(40); + vi.advanceTimersByTime(POLL_INTERVAL + THRESHOLD + 40); + expect(callback).toHaveBeenCalledTimes(1); + + watchdog.stop(); + }); + + test('never fires after stop', () => { + const callback = vi.fn(); + const watchdog = createWatchdog(createFakeTimer, POLL_INTERVAL, THRESHOLD, callback); + + watchdog.stop(); + vi.advanceTimersByTime(POLL_INTERVAL + THRESHOLD + 40); + + expect(callback).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); +}); From f644e1414bb3075c208405d0c00e24b7873d56f0 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 15 Oct 2026 12:34:26 +0100 Subject: [PATCH 2/4] fix: Hold the watchdog after a renderer crash instead of disabling it In protocol mode the reloaded page's visibility status can be dropped while its new id is still being mapped, and alive polls never change the enabled flag, so a disabled watchdog stayed disabled. suspend() holds the callback only until the next poll. --- src/main/integrations/renderer-anr.ts | 11 +++++++---- src/main/watchdog.ts | 13 ++++++++----- test/unit/watchdog.test.ts | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/main/integrations/renderer-anr.ts b/src/main/integrations/renderer-anr.ts index 35bacc5c..89a425e6 100644 --- a/src/main/integrations/renderer-anr.ts +++ b/src/main/integrations/renderer-anr.ts @@ -228,6 +228,7 @@ export const rendererEventLoopBlockIntegration: (options?: Options) => RendererE const disable = (): void => watchdog.enabled(false); const enable = (): void => watchdog.enabled(true); + const suspend = (): void => watchdog.suspend(); // A destroyed webContents never polls again, so the watchdog must // stop too or it reports a block that never happened @@ -238,7 +239,7 @@ export const rendererEventLoopBlockIntegration: (options?: Options) => RendererE if (!contents.isDestroyed()) { contents.off('blur', disable); contents.off('focus', enable); - contents.off('render-process-gone', disable); + contents.off('render-process-gone', suspend); } powerMonitor.off('suspend', disable); @@ -250,9 +251,11 @@ export const rendererEventLoopBlockIntegration: (options?: Options) => RendererE contents.on('blur', disable); contents.on('focus', enable); // A crashed renderer stops polling until it is reloaded. The - // reloaded page sends its visibility, which enables the watchdog - // again, so the watchdog and the debugger session are kept. - contents.on('render-process-gone', disable); + // watchdog is held until the first message from the reloaded page + // rather than disabled, because in protocol mode that page's + // visibility status can be dropped while its new id is still being + // mapped. The watchdog and the debugger session are kept. + contents.on('render-process-gone', suspend); contents.once('destroyed', teardown); powerMonitor.on('suspend', disable); powerMonitor.on('resume', enable); diff --git a/src/main/watchdog.ts b/src/main/watchdog.ts index 5bc7e15d..6d3f1791 100644 --- a/src/main/watchdog.ts +++ b/src/main/watchdog.ts @@ -8,6 +8,8 @@ export interface Watchdog { poll: () => void; /** Turns the callback on or off. Polling continues either way. */ enabled: (state: boolean) => void; + /** Holds the callback until the next `poll()`. `enabled` is kept. */ + suspend: () => void; /** Stops the watchdog. It cannot be restarted. */ stop: () => void; } @@ -25,13 +27,14 @@ export function createWatchdog( const timer = createTimer(); let triggered = false; let enabled = true; + let suspended = false; const interval = setInterval(() => { const diffMs = timer.getTimeMs(); if (!triggered && diffMs > pollInterval + anrThreshold) { triggered = true; - if (enabled) { + if (enabled && !suspended) { callback(); } } @@ -41,17 +44,17 @@ export function createWatchdog( } }, 20); - // Not unref'd on purpose. In the Electron main process libuv only runs - // timers while a referenced handle exists, so an unref'd interval can - // stall when the app is otherwise idle. - return { poll: () => { + suspended = false; timer.reset(); }, enabled: (state: boolean) => { enabled = state; }, + suspend: () => { + suspended = true; + }, stop: () => { clearInterval(interval); }, diff --git a/test/unit/watchdog.test.ts b/test/unit/watchdog.test.ts index 59f964a7..1f546979 100644 --- a/test/unit/watchdog.test.ts +++ b/test/unit/watchdog.test.ts @@ -72,6 +72,22 @@ describe('createWatchdog', () => { watchdog.stop(); }); + test('does not fire while suspended and resumes on the next poll', () => { + const callback = vi.fn(); + const watchdog = createWatchdog(createFakeTimer, POLL_INTERVAL, THRESHOLD, callback); + + watchdog.suspend(); + vi.advanceTimersByTime(POLL_INTERVAL + THRESHOLD + 40); + expect(callback).not.toHaveBeenCalled(); + + watchdog.poll(); + vi.advanceTimersByTime(40); + vi.advanceTimersByTime(POLL_INTERVAL + THRESHOLD + 40); + expect(callback).toHaveBeenCalledTimes(1); + + watchdog.stop(); + }); + test('never fires after stop', () => { const callback = vi.fn(); const watchdog = createWatchdog(createFakeTimer, POLL_INTERVAL, THRESHOLD, callback); From f31ecd8ed5291626eea741c3a725147650e4c374 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 15 Oct 2026 12:44:52 +0100 Subject: [PATCH 3/4] fix: Lift the watchdog hold on the second poll, not the first In protocol mode the request body is read asynchronously, so one status message from the crashed renderer can arrive after render-process-gone. A single poll must not lift the hold, or the watchdog fires a false ANR once that stale poll is the last one. --- src/main/integrations/renderer-anr.ts | 8 ++++---- src/main/watchdog.ts | 16 +++++++++++----- test/unit/watchdog.test.ts | 20 +++++++++++++++++++- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/main/integrations/renderer-anr.ts b/src/main/integrations/renderer-anr.ts index 89a425e6..083b643b 100644 --- a/src/main/integrations/renderer-anr.ts +++ b/src/main/integrations/renderer-anr.ts @@ -251,10 +251,10 @@ export const rendererEventLoopBlockIntegration: (options?: Options) => RendererE contents.on('blur', disable); contents.on('focus', enable); // A crashed renderer stops polling until it is reloaded. The - // watchdog is held until the first message from the reloaded page - // rather than disabled, because in protocol mode that page's - // visibility status can be dropped while its new id is still being - // mapped. The watchdog and the debugger session are kept. + // watchdog is held until the reloaded page polls rather than + // disabled, because in protocol mode that page's visibility status + // can be dropped while its new id is still being mapped. The + // watchdog and the debugger session are kept. contents.on('render-process-gone', suspend); contents.once('destroyed', teardown); powerMonitor.on('suspend', disable); diff --git a/src/main/watchdog.ts b/src/main/watchdog.ts index 6d3f1791..f031c31f 100644 --- a/src/main/watchdog.ts +++ b/src/main/watchdog.ts @@ -8,7 +8,11 @@ export interface Watchdog { poll: () => void; /** Turns the callback on or off. Polling continues either way. */ enabled: (state: boolean) => void; - /** Holds the callback until the next `poll()`. `enabled` is kept. */ + /** + * Holds the callback until two more polls arrive. `enabled` is kept. + * Two, because one message from a crashed process can still be in + * flight when the hold starts. + */ suspend: () => void; /** Stops the watchdog. It cannot be restarted. */ stop: () => void; @@ -27,14 +31,14 @@ export function createWatchdog( const timer = createTimer(); let triggered = false; let enabled = true; - let suspended = false; + let pollsUntilResume = 0; const interval = setInterval(() => { const diffMs = timer.getTimeMs(); if (!triggered && diffMs > pollInterval + anrThreshold) { triggered = true; - if (enabled && !suspended) { + if (enabled && pollsUntilResume === 0) { callback(); } } @@ -46,14 +50,16 @@ export function createWatchdog( return { poll: () => { - suspended = false; + if (pollsUntilResume > 0) { + pollsUntilResume -= 1; + } timer.reset(); }, enabled: (state: boolean) => { enabled = state; }, suspend: () => { - suspended = true; + pollsUntilResume = 2; }, stop: () => { clearInterval(interval); diff --git a/test/unit/watchdog.test.ts b/test/unit/watchdog.test.ts index 1f546979..756d2194 100644 --- a/test/unit/watchdog.test.ts +++ b/test/unit/watchdog.test.ts @@ -72,7 +72,7 @@ describe('createWatchdog', () => { watchdog.stop(); }); - test('does not fire while suspended and resumes on the next poll', () => { + test('does not fire while suspended, and one stale poll does not lift the hold', () => { const callback = vi.fn(); const watchdog = createWatchdog(createFakeTimer, POLL_INTERVAL, THRESHOLD, callback); @@ -80,6 +80,24 @@ describe('createWatchdog', () => { vi.advanceTimersByTime(POLL_INTERVAL + THRESHOLD + 40); expect(callback).not.toHaveBeenCalled(); + // A message from the crashed process that was still in flight + watchdog.poll(); + vi.advanceTimersByTime(40); + vi.advanceTimersByTime(POLL_INTERVAL + THRESHOLD + 40); + expect(callback).not.toHaveBeenCalled(); + + watchdog.stop(); + }); + + test('resumes on the second poll after suspend', () => { + const callback = vi.fn(); + const watchdog = createWatchdog(createFakeTimer, POLL_INTERVAL, THRESHOLD, callback); + + watchdog.suspend(); + vi.advanceTimersByTime(POLL_INTERVAL + THRESHOLD + 40); + + watchdog.poll(); + vi.advanceTimersByTime(POLL_INTERVAL); watchdog.poll(); vi.advanceTimersByTime(40); vi.advanceTimersByTime(POLL_INTERVAL + THRESHOLD + 40); From 48b5e17620e8f4b1b16858ff63699d012b15f809 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 15 Oct 2026 12:57:06 +0100 Subject: [PATCH 4/4] fix: Lift the watchdog hold on a visibility status from the reloaded page A reloaded page sends its visibility once at start and the next poll only after a poll interval, so a block inside that interval was not reported. A visibility status cannot be a stale message from the crashed process, so it lifts the hold at once. The second-poll rule stays for protocol mode, where that status can be dropped. --- src/main/integrations/renderer-anr.ts | 9 ++++++--- src/main/watchdog.ts | 11 ++++++++--- test/unit/watchdog.test.ts | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/main/integrations/renderer-anr.ts b/src/main/integrations/renderer-anr.ts index 083b643b..63e20e46 100644 --- a/src/main/integrations/renderer-anr.ts +++ b/src/main/integrations/renderer-anr.ts @@ -251,9 +251,9 @@ export const rendererEventLoopBlockIntegration: (options?: Options) => RendererE contents.on('blur', disable); contents.on('focus', enable); // A crashed renderer stops polling until it is reloaded. The - // watchdog is held until the reloaded page polls rather than - // disabled, because in protocol mode that page's visibility status - // can be dropped while its new id is still being mapped. The + // watchdog is held rather than disabled, because in protocol mode + // the reloaded page's visibility status can be dropped while its + // new id is still being mapped; its polls then lift the hold. The // watchdog and the debugger session are kept. contents.on('render-process-gone', suspend); contents.once('destroyed', teardown); @@ -296,6 +296,9 @@ export const rendererEventLoopBlockIntegration: (options?: Options) => RendererE if (message.status !== 'alive') { log(`Renderer visibility changed '${message.status}'`); + // A page sends its visibility when it starts, so this message + // comes from a live page and lifts any hold from a crash + watchdog.resume(); watchdog.enabled(message.status === 'visible'); } }; diff --git a/src/main/watchdog.ts b/src/main/watchdog.ts index f031c31f..7dbd5536 100644 --- a/src/main/watchdog.ts +++ b/src/main/watchdog.ts @@ -9,11 +9,13 @@ export interface Watchdog { /** Turns the callback on or off. Polling continues either way. */ enabled: (state: boolean) => void; /** - * Holds the callback until two more polls arrive. `enabled` is kept. - * Two, because one message from a crashed process can still be in - * flight when the hold starts. + * Holds the callback until `resume()` or until two more polls arrive. + * `enabled` is kept. Two polls, because one message from a crashed + * process can still be in flight when the hold starts. */ suspend: () => void; + /** Lifts a hold at once */ + resume: () => void; /** Stops the watchdog. It cannot be restarted. */ stop: () => void; } @@ -61,6 +63,9 @@ export function createWatchdog( suspend: () => { pollsUntilResume = 2; }, + resume: () => { + pollsUntilResume = 0; + }, stop: () => { clearInterval(interval); }, diff --git a/test/unit/watchdog.test.ts b/test/unit/watchdog.test.ts index 756d2194..85378f0b 100644 --- a/test/unit/watchdog.test.ts +++ b/test/unit/watchdog.test.ts @@ -89,6 +89,22 @@ describe('createWatchdog', () => { watchdog.stop(); }); + test('resume lifts the hold at once', () => { + const callback = vi.fn(); + const watchdog = createWatchdog(createFakeTimer, POLL_INTERVAL, THRESHOLD, callback); + + watchdog.suspend(); + vi.advanceTimersByTime(POLL_INTERVAL + THRESHOLD + 40); + + watchdog.poll(); + watchdog.resume(); + vi.advanceTimersByTime(40); + vi.advanceTimersByTime(POLL_INTERVAL + THRESHOLD + 40); + expect(callback).toHaveBeenCalledTimes(1); + + watchdog.stop(); + }); + test('resumes on the second poll after suspend', () => { const callback = vi.fn(); const watchdog = createWatchdog(createFakeTimer, POLL_INTERVAL, THRESHOLD, callback);