Clean up renderer relay listeners on teardown

This commit is contained in:
m4air
2026-09-15 16:48:06 -07:00
parent 6da72383df
commit aefa5ee5d3
6 changed files with 138 additions and 16 deletions
@@ -67,4 +67,27 @@ describe('requestMobileMarkdownFromRenderer', () => {
await expect(pending).resolves.toMatchObject({ content: '# ok' })
})
it('rejects and removes the pending listener when the renderer is destroyed', async () => {
const { requestMobileMarkdownFromRenderer } = await import('./mobile-markdown-request-relay')
const mainWebContents = Object.assign(new EventEmitter(), {
send: vi.fn()
})
const mainWindow = Object.assign(new EventEmitter(), {
isDestroyed: () => false,
webContents: mainWebContents
})
const pending = requestMobileMarkdownFromRenderer(mainWindow as never, {
operation: 'read',
worktreeId: 'wt-1',
tabId: 'tab-md'
})
expect(ipcEmitter.listenerCount('ui:mobileMarkdownResponse')).toBe(1)
mainWebContents.emit('destroyed')
await expect(pending).rejects.toThrow('renderer_unavailable')
expect(ipcEmitter.listenerCount('ui:mobileMarkdownResponse')).toBe(0)
})
})
@@ -26,10 +26,35 @@ export async function requestMobileMarkdownFromRenderer(
}
const id = randomUUID()
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
let settled = false
const onRendererUnavailable = (): void => finish(new Error('renderer_unavailable'))
const finish = (
error?: Error,
result?: RuntimeMarkdownReadTabResult | RuntimeMarkdownSaveTabResult
): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
ipcMain.removeListener('ui:mobileMarkdownResponse', onResponse)
reject(new Error('renderer_timeout'))
}, MOBILE_MARKDOWN_RENDERER_TIMEOUT_MS)
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 (error) {
reject(error)
} else {
resolve(result)
}
}
const timeout = setTimeout(
() => finish(new Error('renderer_timeout')),
MOBILE_MARKDOWN_RENDERER_TIMEOUT_MS
)
const onResponse = (
event: Electron.IpcMainEvent,
response: RuntimeMobileMarkdownResponse
@@ -40,15 +65,24 @@ export async function requestMobileMarkdownFromRenderer(
if (response.id !== id) {
return
}
clearTimeout(timeout)
ipcMain.removeListener('ui:mobileMarkdownResponse', onResponse)
if (response.ok) {
resolve(response.result)
finish(undefined, response.result)
} else {
reject(new Error(response.error))
finish(new Error(response.error))
}
}
ipcMain.on('ui:mobileMarkdownResponse', onResponse)
mainWindow.webContents.send('ui:mobileMarkdownRequest', { id, ...request })
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)
}
try {
mainWindow.webContents.send('ui:mobileMarkdownRequest', { id, ...request })
} catch {
finish(new Error('renderer_unavailable'))
}
})
}
@@ -78,4 +78,24 @@ describe('requestTerminalTabCloseFromRenderer', () => {
await expect(pending).rejects.toThrow('terminal_tab_pinned')
})
it('rejects and removes the pending listener when the renderer closes', async () => {
const { requestTerminalTabCloseFromRenderer } =
await import('./terminal-tab-close-request-relay')
const webContents = Object.assign(new EventEmitter(), {
isDestroyed: () => false,
send: vi.fn()
})
const mainWindow = Object.assign(new EventEmitter(), {
isDestroyed: () => false,
webContents
})
const pending = requestTerminalTabCloseFromRenderer(mainWindow as never, 'tab-closed')
expect(ipcEmitter.listenerCount('ui:terminalTabCloseResponse')).toBe(1)
mainWindow.emit('closed')
await expect(pending).rejects.toThrow('renderer_unavailable')
expect(ipcEmitter.listenerCount('ui:terminalTabCloseResponse')).toBe(0)
})
})
@@ -19,26 +19,57 @@ export async function requestTerminalTabCloseFromRenderer(
}
const requestId = randomUUID()
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
let settled = false
const onRendererUnavailable = (): void => finish(new Error('renderer_unavailable'))
const finish = (error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
ipcMain.removeListener('ui:terminalTabCloseResponse', onResponse)
reject(new Error('terminal_tab_close_timeout'))
}, TERMINAL_TAB_CLOSE_TIMEOUT_MS)
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 (error) {
reject(error)
} else {
resolve()
}
}
const timeout = setTimeout(
() => finish(new Error('terminal_tab_close_timeout')),
TERMINAL_TAB_CLOSE_TIMEOUT_MS
)
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) {
return
}
clearTimeout(timeout)
ipcMain.removeListener('ui:terminalTabCloseResponse', onResponse)
if (response.error) {
reject(new Error(response.error))
finish(new Error(response.error))
} else {
resolve()
finish()
}
}
ipcMain.on('ui:terminalTabCloseResponse', onResponse)
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)
}
const request: TerminalTabCloseRequest = { requestId, tabId, ...options }
mainWindow.webContents.send('ui:terminalTabCloseRequest', request)
try {
mainWindow.webContents.send('ui:terminalTabCloseRequest', request)
} catch {
finish(new Error('renderer_unavailable'))
}
})
}
+12
View File
@@ -46,6 +46,7 @@ export function getLinuxDisplayServer(): 'wayland' | 'x11' | null {
type NativeFileDropCallback = (data: NativeFileDropPayload) => void
const nativeFileDropCallbacks: NativeFileDropCallback[] = []
let nativeFileDropListenerRegistered = false
let nativeFileDropHandlersInstalled = false
const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => {
for (const callback of Array.from(nativeFileDropCallbacks)) {
@@ -89,6 +90,11 @@ function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null {
/** Installs the one preload-side listener that converts native File objects to paths. */
export function installNativeFileDropHandlers(): void {
// Preload entry points can be evaluated more than once in tests and during development reloads;
// duplicate document listeners retain every closure and process each drop repeatedly.
if (nativeFileDropHandlersInstalled) {
return
}
document.addEventListener(
'dragover',
(event) => {
@@ -155,6 +161,7 @@ export function installNativeFileDropHandlers(): void {
},
true
)
nativeFileDropHandlersInstalled = true
}
export const browserFindSubscriptions = createBrowserFindSubscriptions()
@@ -162,12 +169,17 @@ export const browserClientPageRendererRequests = createBrowserClientPageRenderer
ipc: ipcRenderer,
isTopFrame: () => window.top === window
})
let browserFindListenerInstalled = false
/** Registers browser find forwarding once for this preload context. */
export function installBrowserFindListener(): void {
if (browserFindListenerInstalled) {
return
}
ipcRenderer.on('ui:findInBrowserPage', (_event, source: unknown) => {
browserFindSubscriptions.dispatch(source)
})
browserFindListenerInstalled = true
}
export const updaterQuitAbortRelay = createUpdaterQuitAbortRelay(
@@ -146,6 +146,8 @@ describe('native chat composer drop scoping', () => {
value: { ui: { onFileDrop: subscribeNativeFileDrop }, fs: intake }
})
installNativeFileDropHandlers()
// Repeated preload setup must stay singleton or every OS drop is processed once per install.
installNativeFileDropHandlers()
})
beforeEach(() => {