diff --git a/src/main/window/mobile-markdown-request-relay.test.ts b/src/main/window/mobile-markdown-request-relay.test.ts index 3f3f610498e..ff0c30dacc7 100644 --- a/src/main/window/mobile-markdown-request-relay.test.ts +++ b/src/main/window/mobile-markdown-request-relay.test.ts @@ -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) + }) }) diff --git a/src/main/window/mobile-markdown-request-relay.ts b/src/main/window/mobile-markdown-request-relay.ts index d9cd408bc54..ff4ff271c4b 100644 --- a/src/main/window/mobile-markdown-request-relay.ts +++ b/src/main/window/mobile-markdown-request-relay.ts @@ -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')) + } }) } 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 d578d581319..699bd78913c 100644 --- a/src/main/window/terminal-tab-close-request-relay.test.ts +++ b/src/main/window/terminal-tab-close-request-relay.test.ts @@ -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) + }) }) diff --git a/src/main/window/terminal-tab-close-request-relay.ts b/src/main/window/terminal-tab-close-request-relay.ts index f6bf67c7aca..0701bbfb7e9 100644 --- a/src/main/window/terminal-tab-close-request-relay.ts +++ b/src/main/window/terminal-tab-close-request-relay.ts @@ -19,26 +19,57 @@ export async function requestTerminalTabCloseFromRenderer( } const requestId = randomUUID() await new Promise((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')) + } }) } diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts index 6a9462473c1..9861583b34a 100644 --- a/src/preload/preload-runtime-support.ts +++ b/src/preload/preload-runtime-support.ts @@ -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( diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index c7c7dec050c..508a7c20e5e 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -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(() => {