diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 28d303d28..9d3732f88 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -9,7 +9,9 @@ import { redactRuntimeEnvironment, type PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments' -import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { RuntimeRpcCallQueueOverloadError } from '../../shared/runtime-rpc-call-queue' +import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../shared/runtime-types' import type { Store } from '../persistence' import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification' @@ -151,6 +153,25 @@ function registerPassiveStatusHandler(getUserDataPath: () => string): void { ) } +function runtimeEnvironmentCallFailure( + environment: ReturnType, + method: string, + error: unknown +): RuntimeRpcFailure | null { + if ( + !(error instanceof RemoteRuntimeClientError) && + !(error instanceof RuntimeRpcCallQueueOverloadError) + ) { + return null + } + return { + id: method, + ok: false, + error: { code: error.code, message: error.message }, + _meta: { runtimeId: environment.runtimeId } + } +} + function registerPassiveCallHandler(getUserDataPath: () => string): void { ipcMain.handle( 'runtimeEnvironments:call', @@ -168,14 +189,23 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void { if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { return manuallyDisconnectedResponse(environment) } - const response = await callRuntimeEnvironment( - getUserDataPath(), - environment.id, - args.method, - args.params, - args.timeoutMs, - args.expectedEnvironmentPairingRevision - ) + let response: RuntimeRpcResponse + try { + response = await callRuntimeEnvironment( + getUserDataPath(), + environment.id, + args.method, + args.params, + args.timeoutMs, + args.expectedEnvironmentPairingRevision + ) + } catch (error) { + const failure = runtimeEnvironmentCallFailure(environment, args.method, error) + if (failure) { + return failure + } + throw error + } return isRuntimeEnvironmentManuallyDisconnected(environment.id) ? manuallyDisconnectedResponse(environment) : response diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 0e3739d6c..9ca8da986 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -11,6 +11,8 @@ import { } from '../../shared/protocol-version' import * as environmentStore from '../../shared/runtime-environment-store' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { RuntimeRpcCallQueueOverloadError } from '../../shared/runtime-rpc-call-queue' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' const { handleMock, @@ -935,6 +937,67 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(sendRemoteRuntimeSharedControlRequestMock).toHaveBeenCalledTimes(1) }) + it.each([ + [ + new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Transport vanished without legacy classifier wording.' + ), + 'remote_runtime_unavailable', + 'Transport vanished without legacy classifier wording.' + ], + [ + Object.assign(new RuntimeRpcCallQueueOverloadError('selector'), { + message: 'Capacity rejected without legacy classifier wording.' + }), + 'runtime_rpc_queue_overloaded', + 'Capacity rejected without legacy classifier wording.' + ] + ])( + 'returns coded transport failure %s so the renderer restores its identity', + async (transportError, expectedCode, expectedMessage) => { + registerRuntimeEnvironmentHandlers(store as never) + sendRemoteRuntimeRequestMock.mockRejectedValue(transportError) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + const call = handler<{ selector: string; method: string }, RuntimeRpcResponse>( + 'runtimeEnvironments:call' + ) + + const response = structuredClone(await call(null, { selector: 'desk', method: 'status.get' })) + expect(response).toMatchObject({ + ok: false, + error: { code: expectedCode, message: expectedMessage } + }) + expect(response.ok).toBe(false) + if (response.ok === false) { + expect(response.error).toEqual({ code: expectedCode, message: expectedMessage }) + } + } + ) + + it('keeps uncoded call failures on the rejected IPC fallback path', async () => { + registerRuntimeEnvironmentHandlers(store as never) + sendRemoteRuntimeRequestMock.mockRejectedValue(new Error('shared down')) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + const call = handler<{ selector: string; method: string }, RuntimeRpcResponse>( + 'runtimeEnvironments:call' + ) + + await expect(call(null, { selector: 'desk', method: 'status.get' })).rejects.toThrow( + 'shared down' + ) + }) + it('does not fall back after a shared-control request fails on a supported runtime', async () => { registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockResolvedValue({ diff --git a/src/shared/remote-runtime-client-error-classification.test.ts b/src/shared/remote-runtime-client-error-classification.test.ts index 786b3287a..d871463fc 100644 --- a/src/shared/remote-runtime-client-error-classification.test.ts +++ b/src/shared/remote-runtime-client-error-classification.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { isRecoverableRemoteRuntimeConnectionError, + isRuntimeRpcQueueOverloadError, toRemoteRuntimeClientErrorLike } from './remote-runtime-client-error-classification' @@ -29,6 +30,24 @@ describe('remote runtime client error classification', () => { ).toBe(false) }) + it('trusts a structured recovery code before legacy message fragments', () => { + expect( + isRecoverableRemoteRuntimeConnectionError({ + code: 'unauthorized', + message: 'Remote Orca runtime closed the connection.' + }) + ).toBe(false) + }) + + it('trusts a structured queue code before legacy message fragments', () => { + expect( + isRuntimeRpcQueueOverloadError({ + code: 'remote_runtime_unavailable', + message: 'Remote runtime call queue is full; retry after current calls finish.' + }) + ).toBe(false) + }) + it.each([ 'Could not connect to the remote Orca runtime.', 'Remote Orca runtime closed the connection.', diff --git a/src/shared/remote-runtime-client-error-classification.ts b/src/shared/remote-runtime-client-error-classification.ts index 1732a454f..37d55f271 100644 --- a/src/shared/remote-runtime-client-error-classification.ts +++ b/src/shared/remote-runtime-client-error-classification.ts @@ -25,17 +25,17 @@ const RECOVERABLE_MESSAGE_FRAGMENTS = [ ] export function isRuntimeRpcQueueOverloadError(error: RemoteRuntimeClientErrorLike): boolean { - return ( - error.code === RUNTIME_RPC_QUEUE_OVERLOAD_CODE || - error.message.toLowerCase().includes(RUNTIME_RPC_QUEUE_OVERLOAD_MESSAGE_FRAGMENT) - ) + if (error.code) { + return error.code === RUNTIME_RPC_QUEUE_OVERLOAD_CODE + } + return error.message.toLowerCase().includes(RUNTIME_RPC_QUEUE_OVERLOAD_MESSAGE_FRAGMENT) } export function isRecoverableRemoteRuntimeConnectionError( error: RemoteRuntimeClientErrorLike ): boolean { - if (error.code && RECOVERABLE_CODES.has(error.code)) { - return true + if (error.code) { + return RECOVERABLE_CODES.has(error.code) } const message = error.message.toLowerCase() return RECOVERABLE_MESSAGE_FRAGMENTS.some((fragment) => message.includes(fragment))