mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
fix(app): close timeout resource gaps
This commit is contained in:
@@ -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
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user