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:
parent
c4d5a535f2
commit
15ef69a814
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in New Issue