diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index f951091c986..58e42ad5262 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -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 void>() + const reclaimTerminalForDesktop = vi.fn( + (ptyId: string) => + new Promise((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 }) + }) }) diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 3b9a8719afc..7991035ee8c 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -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>() 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) + } } }) diff --git a/src/main/runtime/rpc/relay-transport.test.ts b/src/main/runtime/rpc/relay-transport.test.ts index e1f2d7fa76f..6a88fb8df49 100644 --- a/src/main/runtime/rpc/relay-transport.test.ts +++ b/src/main/runtime/rpc/relay-transport.test.ts @@ -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 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( () => diff --git a/src/main/runtime/rpc/relay-transport.ts b/src/main/runtime/rpc/relay-transport.ts index fdb7e50a20f..2442ae1aef6 100644 --- a/src/main/runtime/rpc/relay-transport.ts +++ b/src/main/runtime/rpc/relay-transport.ts @@ -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')) }