diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index d4ce59e3b..2a5cb6a73 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -1087,6 +1087,64 @@ describe('createMainWindow', () => { consoleError.mockRestore() }) + it('does not notify the crash recorder when renderer teardown follows a confirmed window close', () => { + const windowHandlers: Record void> = {} + const ipcHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isCrashed: vi.fn(() => false) + } + const browserWindowInstance = { + webContents, + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn(), + close: vi.fn(() => { + windowHandlers.close({} as never) + }) + } + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(ipcMain.on).mockImplementation((channel, handler) => { + ipcHandlers[channel] = handler as (...args: any[]) => void + return ipcMain + }) + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + const onRendererProcessGone = vi.fn() + + createMainWindow(null, { onRendererProcessGone }) + + ipcHandlers['window:confirm-close']?.() + windowHandlers['render-process-gone']?.( + {} as never, + { + reason: 'killed', + exitCode: 9 + } as never + ) + + expect(onRendererProcessGone).not.toHaveBeenCalled() + + consoleError.mockRestore() + }) + it('does not persist pending bounds after bypassing close for a gone renderer', () => { vi.useFakeTimers() diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index f3de6130d..a4d4d43f5 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -623,10 +623,17 @@ export function createMainWindow( resetTerminalInputFocus() resetFloatingTerminalInputFocus() resetShortcutRecorderFocus() - if (opts?.shouldRecordRendererCrash?.(details, rendererWebContentsId) !== false) { + // Why: macOS can report BrowserWindow teardown as renderer `killed`/SIGKILL + // after a confirmed close; that is window lifecycle noise, not a crash. + if ( + !windowClosing && + opts?.shouldRecordRendererCrash?.(details, rendererWebContentsId) !== false + ) { opts?.onRendererProcessGone?.(details, rendererWebContentsId) } - console.error('[window] Renderer process gone; close confirmation will be bypassed', details) + if (!windowClosing) { + console.error('[window] Renderer process gone; close confirmation will be bypassed', details) + } scheduleRendererRecovery(details) }) mainWindow.webContents.on('destroyed', () => { diff --git a/tools/repro-macos-renderer-close-teardown.cjs b/tools/repro-macos-renderer-close-teardown.cjs new file mode 100755 index 000000000..9f041ce7d --- /dev/null +++ b/tools/repro-macos-renderer-close-teardown.cjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +const { app, BrowserWindow, ipcMain } = require('electron') + +const modeArg = process.argv.find((arg) => arg.startsWith('--mode=')) +const mode = modeArg ? modeArg.slice('--mode='.length) : 'natural-close' +const timeoutArg = process.argv.find((arg) => arg.startsWith('--timeout-ms=')) +const timeoutMs = timeoutArg ? Number(timeoutArg.slice('--timeout-ms='.length)) : 5000 + +const validModes = new Set([ + 'natural-close', + 'sigkill-after-confirmed-close', + 'sigkill-during-native-close', + 'sigkill-before-close' +]) + +if (!validModes.has(mode) || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { + console.error( + JSON.stringify({ + event: 'invalid-args', + mode, + validModes: Array.from(validModes), + timeoutMs + }) + ) + process.exit(2) +} + +let win = null +let closeConfirmed = false +let windowClosing = false +let rendererPid = 0 +let renderProcessGoneDetails = null +let crashRecorderWouldRunBeforeFix = false +let crashRecorderWouldRunAfterFix = false +let timeout = null + +function log(event, data = {}) { + console.log( + JSON.stringify({ + at: new Date().toISOString(), + event, + mode, + closeConfirmed, + windowClosing, + rendererPid, + ...data + }) + ) +} + +function finish(exitCode = 0) { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + log('summary', { + renderProcessGoneDetails, + crashRecorderWouldRunBeforeFix, + crashRecorderWouldRunAfterFix + }) + setTimeout(() => { + if (win && !win.isDestroyed()) { + win.destroy() + } + app.exit(exitCode) + }, 50) +} + +function killRenderer(reason) { + if (!rendererPid) { + log('kill-renderer-skipped', { reason, skipped: 'missing-renderer-pid' }) + return + } + try { + process.kill(rendererPid, 'SIGKILL') + log('sent-sigkill', { reason }) + } catch (error) { + log('sent-sigkill-failed', { + reason, + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error) + }) + } +} + +app.on('child-process-gone', (_event, details) => { + log('child-process-gone', { details }) +}) + +app.whenReady().then(async () => { + win = new BrowserWindow({ + width: 420, + height: 220, + show: false, + webPreferences: { + nodeIntegration: true, + contextIsolation: false + } + }) + + win.webContents.on('render-process-gone', (_event, details) => { + renderProcessGoneDetails = details + crashRecorderWouldRunBeforeFix = true + crashRecorderWouldRunAfterFix = !windowClosing + log('render-process-gone', { + details, + crashRecorderWouldRunBeforeFix, + crashRecorderWouldRunAfterFix + }) + finish() + }) + + win.webContents.on('destroyed', () => { + log('webcontents-destroyed') + }) + + win.on('close', (event) => { + log('window-close') + if (!closeConfirmed) { + event.preventDefault() + log('window-close-prevented-pending-confirm') + win.webContents.send('harness:close-requested') + return + } + + windowClosing = true + log('window-close-confirmed') + + if (mode === 'sigkill-after-confirmed-close') { + event.preventDefault() + killRenderer('after-confirmed-close') + } else if (mode === 'sigkill-during-native-close') { + killRenderer('during-native-close') + } + }) + + win.on('closed', () => { + log('window-closed') + if (!renderProcessGoneDetails) { + finish() + } + }) + + ipcMain.on('harness:confirm-close', () => { + closeConfirmed = true + log('renderer-confirmed-close') + if (mode === 'sigkill-before-close') { + killRenderer('before-close') + } else { + win.close() + } + }) + + await win.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(` + + + +
renderer close teardown repro
+ + + +`)}` + ) + + rendererPid = win.webContents.getOSProcessId() + log('loaded') + timeout = setTimeout(() => { + log('timeout') + finish(1) + }, timeoutMs) + + setTimeout(() => { + log('requesting-close') + win.close() + }, 100) +})