mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 00:02:35 +00:00
Clean up renderer relay listeners on teardown
This commit is contained in:
@@ -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'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user