fix(app): close timeout resource gaps

This commit is contained in:
Brennan Benson
2026-07-22 14:39:59 -07:00
parent afad49b52b
commit 6896f2ec00
4 changed files with 128 additions and 1 deletions
+40
View File
@@ -99,4 +99,44 @@ describe('registerRuntimeHandlers', () => {
_meta: { runtimeId: 'runtime-1' }
})
})
it('deduplicates retries while a terminal fit restore is still pending', async () => {
const finishRestoreByPtyId = new Map<string, (restored: boolean) => void>()
const reclaimTerminalForDesktop = vi.fn(
(ptyId: string) =>
new Promise<boolean>((resolve) => {
finishRestoreByPtyId.set(ptyId, resolve)
})
)
const runtime = {
syncWindowGraph: vi.fn(),
getStatus: vi.fn(),
reclaimTerminalForDesktop
}
registerRuntimeHandlers(runtime as never)
const restoreRegistration = handleMock.mock.calls.find(
([channel]) => channel === 'runtime:restoreTerminalFit'
)
expect(restoreRegistration).toBeTruthy()
const handler = restoreRegistration![1]
const first = handler({ sender: {} }, { ptyId: 'pty-1' })
const retry = handler({ sender: {} }, { ptyId: 'pty-1' })
const otherTerminal = handler({ sender: {} }, { ptyId: 'pty-2' })
expect(reclaimTerminalForDesktop).toHaveBeenCalledTimes(2)
expect(reclaimTerminalForDesktop).toHaveBeenNthCalledWith(1, 'pty-1')
expect(reclaimTerminalForDesktop).toHaveBeenNthCalledWith(2, 'pty-2')
finishRestoreByPtyId.get('pty-1')?.(true)
finishRestoreByPtyId.get('pty-2')?.(true)
await expect(otherTerminal).resolves.toEqual({ restored: true })
await expect(first).resolves.toEqual({ restored: true })
await expect(retry).resolves.toEqual({ restored: true })
expect(reclaimTerminalForDesktop).toHaveBeenCalledTimes(2)
const afterSettlement = handler({ sender: {} }, { ptyId: 'pty-1' })
expect(reclaimTerminalForDesktop).toHaveBeenCalledTimes(3)
finishRestoreByPtyId.get('pty-1')?.(false)
await expect(afterSettlement).resolves.toEqual({ restored: false })
})
})
+16 -1
View File
@@ -11,6 +11,7 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
const pendingTerminalFitRestores = new Map<string, Promise<boolean>>()
ipcMain.removeHandler('runtime:syncWindowGraph')
ipcMain.removeHandler('runtime:getStatus')
ipcMain.removeHandler('runtime:call')
@@ -101,11 +102,25 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
// Electron try to structured-clone a Promise — "An object could not
// be cloned" error — and the renderer's restoreTerminalFit() rejected
// with no useful info.
// Why: renderer timeouts must not turn retries into duplicate waiters behind one stuck reclaim.
let pending = pendingTerminalFitRestores.get(args.ptyId)
if (!pending) {
try {
pending = runtime.reclaimTerminalForDesktop(args.ptyId)
pendingTerminalFitRestores.set(args.ptyId, pending)
} catch {
return { restored: false }
}
}
try {
const reclaimed = await runtime.reclaimTerminalForDesktop(args.ptyId)
const reclaimed = await pending
return { restored: reclaimed }
} catch {
return { restored: false }
} finally {
if (pendingTerminalFitRestores.get(args.ptyId) === pending) {
pendingTerminalFitRestores.delete(args.ptyId)
}
}
})
@@ -189,6 +189,7 @@ describe('CloudRelayTransport', () => {
emit('close')
expect(listeners.get('error')).toHaveLength(0)
expect(listeners.get('close')).toHaveLength(0)
expect(vi.getTimerCount()).toBe(0)
expect(() => transport.setGeneration(2)).not.toThrow()
} finally {
vi.useRealTimers()
@@ -241,6 +242,74 @@ describe('CloudRelayTransport', () => {
}
})
it('releases an expired attach even when terminate never emits close', async () => {
vi.useFakeTimers()
try {
const listeners = new Map<string, ((...args: unknown[]) => void)[]>()
const addListener = (event: string, fn: (...args: unknown[]) => void): void => {
listeners.set(event, [...(listeners.get(event) ?? []), fn])
}
const removeListener = (event: string, fn: (...args: unknown[]) => void): void => {
listeners.set(
event,
(listeners.get(event) ?? []).filter((listener) => listener !== fn)
)
}
const emit = (event: string, ...args: unknown[]): void => {
const eventListeners = listeners.get(event) ?? []
if (event === 'error' && eventListeners.length === 0) {
throw args[0]
}
for (const fn of eventListeners) {
fn(...args)
}
}
const fakeSocket = {
readyState: 0,
OPEN: 1,
CLOSED: 3,
on: addListener,
once: addListener,
off: removeListener,
send: vi.fn(),
terminate: vi.fn()
}
const onConnectionClosed = vi.fn()
const transport = new CloudRelayTransport({
cellUrl: 'http://127.0.0.1:9',
relayHostId: 'AbCdEf0123_-xyZ9',
generation: 1,
createSocket: () => fakeSocket as unknown as WebSocketClient,
onConnectionClosed
})
const opening = transport.openConnection({
connId: 'conn-attach-timeout',
connTicket: 'ticket-1',
kind: 'resume',
relayDeviceId: 'device-1',
attachDeadlineMs: 1_000
})
const rejectedOpening = expect(opening).rejects.toThrow('relay_host_data_attach_timeout')
await vi.advanceTimersByTimeAsync(1_000)
await rejectedOpening
expect(fakeSocket.terminate).toHaveBeenCalledOnce()
expect(onConnectionClosed).toHaveBeenCalledWith('conn-attach-timeout')
expect(() => transport.metadataFor(fakeSocket as unknown as WebSocketClient)).toThrow(
'unknown_relay_socket'
)
expect(() => transport.setGeneration(2)).not.toThrow()
expect(() => emit('message', 'late-after-attach-timeout', false)).not.toThrow()
expect(() => emit('error', new Error('late attach socket failure'))).not.toThrow()
emit('close')
expect(listeners.get('error')).toHaveLength(0)
expect(listeners.get('close')).toHaveLength(0)
} finally {
vi.useRealTimers()
}
})
it('rejects non-origin cell URLs before opening a socket', () => {
expect(
() =>
+3
View File
@@ -150,6 +150,9 @@ export class CloudRelayTransport implements RpcTransport, MobileSocketTransport
let finalized = false
const deadline = setTimeout(() => {
socket.terminate()
// Why: attach expiry makes the socket unusable; release it even if terminate never emits close.
finalize()
this.quarantineDetachedSocket(socket)
if (!opened) {
reject(new Error('relay_host_data_attach_timeout'))
}