diff --git a/src/main/window/mobile-markdown-request-relay.test.ts b/src/main/window/mobile-markdown-request-relay.test.ts index ff0c30dacc7..db587f07344 100644 --- a/src/main/window/mobile-markdown-request-relay.test.ts +++ b/src/main/window/mobile-markdown-request-relay.test.ts @@ -68,14 +68,22 @@ describe('requestMobileMarkdownFromRenderer', () => { await expect(pending).resolves.toMatchObject({ content: '# ok' }) }) - it('rejects and removes the pending listener when the renderer is destroyed', async () => { + it('rejects and cleans up when the BrowserWindow closes and webContents becomes unavailable', async () => { const { requestMobileMarkdownFromRenderer } = await import('./mobile-markdown-request-relay') - const mainWebContents = Object.assign(new EventEmitter(), { + const webContents = Object.assign(new EventEmitter(), { send: vi.fn() }) + let windowClosed = false const mainWindow = Object.assign(new EventEmitter(), { - isDestroyed: () => false, - webContents: mainWebContents + isDestroyed: () => false + }) + Object.defineProperty(mainWindow, 'webContents', { + get: () => { + if (windowClosed) { + throw new Error('webContents unavailable after close') + } + return webContents + } }) const pending = requestMobileMarkdownFromRenderer(mainWindow as never, { @@ -85,9 +93,12 @@ describe('requestMobileMarkdownFromRenderer', () => { }) expect(ipcEmitter.listenerCount('ui:mobileMarkdownResponse')).toBe(1) - mainWebContents.emit('destroyed') + windowClosed = true + mainWindow.emit('closed') await expect(pending).rejects.toThrow('renderer_unavailable') expect(ipcEmitter.listenerCount('ui:mobileMarkdownResponse')).toBe(0) + expect(webContents.listenerCount('destroyed')).toBe(0) + expect(webContents.listenerCount('render-process-gone')).toBe(0) }) }) diff --git a/src/main/window/mobile-markdown-request-relay.ts b/src/main/window/mobile-markdown-request-relay.ts index 1bd4290e960..d1c80a38920 100644 --- a/src/main/window/mobile-markdown-request-relay.ts +++ b/src/main/window/mobile-markdown-request-relay.ts @@ -24,6 +24,7 @@ export async function requestMobileMarkdownFromRenderer( if (mainWindow.isDestroyed()) { throw new Error('renderer_unavailable') } + const webContents = mainWindow.webContents const id = randomUUID() return await new Promise((resolve, reject) => { let settled = false @@ -41,9 +42,9 @@ export async function requestMobileMarkdownFromRenderer( if (typeof mainWindow.removeListener === 'function') { mainWindow.removeListener('closed', onRendererUnavailable) } - if (typeof mainWindow.webContents.removeListener === 'function') { - mainWindow.webContents.removeListener('destroyed', onRendererUnavailable) - mainWindow.webContents.removeListener('render-process-gone', onRendererUnavailable) + if (typeof webContents.removeListener === 'function') { + webContents.removeListener('destroyed', onRendererUnavailable) + webContents.removeListener('render-process-gone', onRendererUnavailable) } if (error) { reject(error) @@ -61,7 +62,7 @@ export async function requestMobileMarkdownFromRenderer( event: Electron.IpcMainEvent, response: RuntimeMobileMarkdownResponse ): void => { - if (event.sender !== mainWindow.webContents) { + if (event.sender !== webContents) { return } if (response.id !== id) { @@ -77,12 +78,12 @@ export async function requestMobileMarkdownFromRenderer( if (typeof mainWindow.once === 'function') { mainWindow.once('closed', onRendererUnavailable) } - if (typeof mainWindow.webContents.once === 'function') { - mainWindow.webContents.once('destroyed', onRendererUnavailable) - mainWindow.webContents.once('render-process-gone', onRendererUnavailable) + if (typeof webContents.once === 'function') { + webContents.once('destroyed', onRendererUnavailable) + webContents.once('render-process-gone', onRendererUnavailable) } try { - mainWindow.webContents.send('ui:mobileMarkdownRequest', { id, ...request }) + webContents.send('ui:mobileMarkdownRequest', { id, ...request }) } catch { finish(new Error('renderer_unavailable')) } diff --git a/src/main/window/terminal-tab-close-request-relay.test.ts b/src/main/window/terminal-tab-close-request-relay.test.ts index 3dd9873088c..ab3e3c57c77 100644 --- a/src/main/window/terminal-tab-close-request-relay.test.ts +++ b/src/main/window/terminal-tab-close-request-relay.test.ts @@ -79,24 +79,35 @@ describe('requestTerminalTabCloseFromRenderer', () => { await expect(pending).rejects.toThrow('terminal_tab_pinned') }) - it('rejects and removes the pending listener when the renderer closes', async () => { + it('rejects and cleans up when the BrowserWindow closes and webContents becomes unavailable', async () => { const { requestTerminalTabCloseFromRenderer } = await import('./terminal-tab-close-request-relay') const webContents = Object.assign(new EventEmitter(), { isDestroyed: () => false, send: vi.fn() }) + let windowClosed = false const mainWindow = Object.assign(new EventEmitter(), { - isDestroyed: () => false, - webContents + isDestroyed: () => false + }) + Object.defineProperty(mainWindow, 'webContents', { + get: () => { + if (windowClosed) { + throw new Error('webContents unavailable after close') + } + return webContents + } }) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the EventEmitter test double implements the BrowserWindow events used by this test. const pending = requestTerminalTabCloseFromRenderer(mainWindow as never, 'tab-closed') expect(ipcEmitter.listenerCount('ui:terminalTabCloseResponse')).toBe(1) + windowClosed = true mainWindow.emit('closed') await expect(pending).rejects.toThrow('renderer_unavailable') expect(ipcEmitter.listenerCount('ui:terminalTabCloseResponse')).toBe(0) + expect(webContents.listenerCount('destroyed')).toBe(0) + expect(webContents.listenerCount('render-process-gone')).toBe(0) }) }) diff --git a/src/main/window/terminal-tab-close-request-relay.ts b/src/main/window/terminal-tab-close-request-relay.ts index 0701bbfb7e9..d70f6d2525d 100644 --- a/src/main/window/terminal-tab-close-request-relay.ts +++ b/src/main/window/terminal-tab-close-request-relay.ts @@ -14,7 +14,11 @@ export async function requestTerminalTabCloseFromRenderer( tabId: string, options: { localPtyTeardownOwnedExternally?: boolean; force?: boolean } = {} ): Promise { - if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) { + if (mainWindow.isDestroyed()) { + throw new Error('renderer_unavailable') + } + const webContents = mainWindow.webContents + if (webContents.isDestroyed()) { throw new Error('renderer_unavailable') } const requestId = randomUUID() @@ -31,9 +35,9 @@ export async function requestTerminalTabCloseFromRenderer( if (typeof mainWindow.removeListener === 'function') { mainWindow.removeListener('closed', onRendererUnavailable) } - if (typeof mainWindow.webContents.removeListener === 'function') { - mainWindow.webContents.removeListener('destroyed', onRendererUnavailable) - mainWindow.webContents.removeListener('render-process-gone', onRendererUnavailable) + if (typeof webContents.removeListener === 'function') { + webContents.removeListener('destroyed', onRendererUnavailable) + webContents.removeListener('render-process-gone', onRendererUnavailable) } if (error) { reject(error) @@ -48,7 +52,7 @@ export async function requestTerminalTabCloseFromRenderer( const onResponse = (event: Electron.IpcMainEvent, response: TerminalTabCloseResponse): void => { // Why: request IDs are visible to renderer code; only the selected main // window may commit or reject its lifecycle transaction. - if (event.sender !== mainWindow.webContents || response.requestId !== requestId) { + if (event.sender !== webContents || response.requestId !== requestId) { return } if (response.error) { @@ -61,13 +65,13 @@ export async function requestTerminalTabCloseFromRenderer( if (typeof mainWindow.once === 'function') { mainWindow.once('closed', onRendererUnavailable) } - if (typeof mainWindow.webContents.once === 'function') { - mainWindow.webContents.once('destroyed', onRendererUnavailable) - mainWindow.webContents.once('render-process-gone', onRendererUnavailable) + if (typeof webContents.once === 'function') { + webContents.once('destroyed', onRendererUnavailable) + webContents.once('render-process-gone', onRendererUnavailable) } const request: TerminalTabCloseRequest = { requestId, tabId, ...options } try { - mainWindow.webContents.send('ui:terminalTabCloseRequest', request) + webContents.send('ui:terminalTabCloseRequest', request) } catch { finish(new Error('renderer_unavailable')) }