fix(relay): retain web contents through window destruction

This commit is contained in:
Neil
2026-09-17 23:48:46 -07:00
parent c2e588e985
commit c8f4d66de4
4 changed files with 52 additions and 25 deletions
@@ -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)
})
})
@@ -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'))
}
@@ -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)
})
})
@@ -14,7 +14,11 @@ export async function requestTerminalTabCloseFromRenderer(
tabId: string,
options: { localPtyTeardownOwnedExternally?: boolean; force?: boolean } = {}
): Promise<void> {
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'))
}