refactor(runtime): preserve transport error codes over IPC (#12667)

Errors thrown across Electron's `ipcMain.handle` lose their structured error code — only the message survives. So the renderer classified transport failures by matching substrings of English message text. That is how a queue-overload rejection escaped classification during a remote outage and surfaced as a raw error wall: the code was stripped in transit and its message fragment was not in the recoverable list.

This converts `RemoteRuntimeClientError` and `RuntimeRpcCallQueueOverloadError` rejections from `runtimeEnvironments:call` into the existing structured `{ok:false, error:{code,message}}` response, which the preload already passes through unchanged and `unwrapRuntimeRpcResult` already reconstructs with the code intact. Classification now treats a present code as authoritative and consults message fragments only when there is no code.

The fragment list is deliberately RETAINED as a backstop, not deleted: untyped main-handler rejections, subscription-start failures, and older code-less paths still rely on it.

Proven real rather than cosmetic: a test-only patch applied to unmodified main fails (4 failed / 66 passed) because the code does not survive the boundary today, and passes on this branch.

Independent review specifically chased the risk that a present-but-unrecognized code would now short-circuit to fatal where a message fragment previously rescued it — the shape that dead-ends a pane. It enumerated all 34 reachable code/message pairs and confirmed no pair flips recoverable to fatal, that the newly-serialized code set is closed and client-local, and that host-forwarded codes preserve recoverable classification by design. A differential harness over that corpus was verified non-vacuous by injecting the bad shape.

Nothing crosses the paired-runtime wire: desktop main -> IPC -> preload -> renderer only, reusing an existing response shape, no new fields or opcodes.

The connection-level offline state with a single reconnect affordance remains as STA-3456 follow-up work.
This commit is contained in:
Jinwoo Hong 2026-08-05 00:44:05 -07:00 committed by GitHub
parent c4d5a535f2
commit 15ef69a814
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 127 additions and 15 deletions

View File

@ -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<typeof resolveEnvironment>,
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<unknown>
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

View File

@ -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<unknown>>(
'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<unknown>>(
'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({

View File

@ -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.',

View File

@ -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))