From 434eb901b956103e27cdfe92ab34d30a376c0630 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 4 Nov 2026 01:14:04 +0000 Subject: [PATCH 1/3] test: answer RPC errors whose details, message or response building break the response --- test/unit/host/rpcServer.test.ts | 74 +++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/test/unit/host/rpcServer.test.ts b/test/unit/host/rpcServer.test.ts index 57c81666..d7fa7cf3 100644 --- a/test/unit/host/rpcServer.test.ts +++ b/test/unit/host/rpcServer.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import net from 'node:net'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CliError } from '../../../src/cli/errors.js'; import { sendRpc } from '../../../src/host/rpcClient.js'; @@ -121,6 +121,19 @@ describe('RpcServer CliError responses', () => { return details; }, ], + [ + 'behind a throwing getter', + () => + Object.defineProperty({}, 'size', { + enumerable: true, + get: () => { + throw new Error('size is not readable'); + }, + }), + ], + ['a toJSON that returns undefined', () => ({ toJSON: () => undefined })], + ['a toJSON that returns a string', () => ({ toJSON: () => 'size: 1' })], + ['a toJSON that returns an array', () => ({ toJSON: () => ['size', 1] })], ])( 'keeps the code and message but drops details that are %s', async (_case, makeDetails) => { @@ -148,4 +161,63 @@ describe('RpcServer CliError responses', () => { details: {}, }); }); + + // A control character takes 6 characters in JSON (\u0001), the largest + // escape, so this message is the worst case for the response size. + const hugeMessage = '\u0001'.repeat(1_100_000); + + it('shortens a huge CliError message and keeps its code and details', async () => { + const details = { blob: 'x'.repeat(65_000) }; + const error = await rejectionFrom( + makeCliError(ERROR_CODES.STORAGE_WRITE_ERROR, { + message: hugeMessage, + details, + }), + ); + + expect(error).toMatchObject({ code: 'STORAGE_WRITE_ERROR', details }); + const { message } = error as CliError; + expect(message.length).toBeLessThan(70_000); + expect(message.startsWith('\u0001'.repeat(60_000))).toBe(true); + expect(message.endsWith('[message truncated]')).toBe(true); + }); + + it('shortens a huge message of an error that is not a CliError', async () => { + const error = await rejectionFrom(new Error(hugeMessage)); + + expect(error).toMatchObject({ code: 'RPC_ERROR' }); + const { details, message } = error as CliError; + expect(details).toEqual({}); + expect(message.length).toBeLessThan(70_000); + expect(message.startsWith('\u0001'.repeat(60_000))).toBe(true); + expect(message.endsWith('[message truncated]')).toBe(true); + }); + + it('answers RPC_ERROR when building the error response throws', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const handlerError = new Error('placeholder'); + Object.defineProperty(handlerError, 'message', { + get: () => { + throw new Error('message is not readable'); + }, + }); + + try { + const error = await rejectionFrom(handlerError); + + expect(error).toMatchObject({ + code: 'RPC_ERROR', + message: 'RPC request handling failed unexpectedly.', + details: {}, + }); + expect(consoleError).toHaveBeenCalledWith( + 'RPC request handling failed unexpectedly:', + expect.objectContaining({ message: 'message is not readable' }), + ); + } finally { + consoleError.mockRestore(); + } + }); }); From bc36097b274cca658542b6e8058ef301f858368b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 4 Nov 2026 01:14:04 +0000 Subject: [PATCH 2/3] fix: keep the host running when an RPC error response cannot be built --- src/host/rpcServer.ts | 54 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/src/host/rpcServer.ts b/src/host/rpcServer.ts index f4cc0962..8af7c18a 100644 --- a/src/host/rpcServer.ts +++ b/src/host/rpcServer.ts @@ -20,6 +20,15 @@ import { ResourceScope } from '../util/resourceScope.js'; const MAX_UNIX_SOCKET_PATH = 104; const MAX_RPC_BUFFER_BYTES = 1_048_576; const MAX_RPC_ERROR_DETAILS_CHARS = 65_536; +const MAX_RPC_ERROR_MESSAGE_CHARS = 65_536; +const TRUNCATED_MESSAGE_SUFFIX = ' [message truncated]'; +// JSON writes a control character as 6 characters (\u0001), so an error +// response with a message and details at their caps still fits in one buffer. +invariant( + 6 * MAX_RPC_ERROR_MESSAGE_CHARS + MAX_RPC_ERROR_DETAILS_CHARS + 1_024 < + MAX_RPC_BUFFER_BYTES, + 'RPC error message and details caps must fit in one RPC response.', +); const SOCKET_LIVENESS_PROBE_TIMEOUT_MS = 1_000; const UNKNOWN_REQUEST_ID = 'unknown'; @@ -121,13 +130,27 @@ function extractRequestId(value: unknown): string { return UNKNOWN_REQUEST_ID; } +function requestIdFromLine(line: string): string { + try { + return extractRequestId(JSON.parse(line) as unknown); + } catch { + return UNKNOWN_REQUEST_ID; + } +} + +function sendableMessage(message: string): string { + return message.length <= MAX_RPC_ERROR_MESSAGE_CHARS + ? message + : `${message.slice(0, MAX_RPC_ERROR_MESSAGE_CHARS - TRUNCATED_MESSAGE_SUFFIX.length)}${TRUNCATED_MESSAGE_SUFFIX}`; +} + function buildErrorResponse(id: string, message: string): RpcResponse { const response = { id, ok: false, error: { code: ERROR_CODES.RPC_ERROR, - message, + message: sendableMessage(message), }, } as const; const responseResult = RpcResponseSchema.safeParse(response); @@ -145,14 +168,22 @@ function sendableDetails( if (Object.keys(details).length === 0) { return undefined; } - let serialized: string; + let serialized: unknown; try { serialized = JSON.stringify(details); } catch { return undefined; } - return serialized.length <= MAX_RPC_ERROR_DETAILS_CHARS - ? (JSON.parse(serialized) as Record) + // A toJSON can turn the details into undefined, a string or an array. + if ( + typeof serialized !== 'string' || + serialized.length > MAX_RPC_ERROR_DETAILS_CHARS + ) { + return undefined; + } + const parsed = JSON.parse(serialized) as unknown; + return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) : undefined; } @@ -163,7 +194,7 @@ function buildCliErrorResponse(id: string, error: CliError): RpcResponse { ok: false, error: { code: error.code, - message: error.message, + message: sendableMessage(error.message), ...(details === undefined ? {} : { details }), }, } as const; @@ -336,7 +367,18 @@ export class RpcServer { handled = true; const line = buffer.slice(0, newlineIndex); - void this.processRequestLine(socket, line); + void this.processRequestLine(socket, line).catch((error: unknown) => { + // An unhandled rejection would end the host and its session, so a + // failure to build or send the response gets a plain RPC_ERROR. + console.error('RPC request handling failed unexpectedly:', error); + this.sendResponse( + socket, + buildErrorResponse( + requestIdFromLine(line), + 'RPC request handling failed unexpectedly.', + ), + ); + }); }); socket.on('end', () => { From 74a2e47ad7db21a22e4407964a5088df06240ca8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 4 Nov 2026 01:14:04 +0000 Subject: [PATCH 3/3] docs: describe the RPC error message cap and the unexpected-failure answer --- src/host/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/host/README.md b/src/host/README.md index 57679f81..98d5c09a 100644 --- a/src/host/README.md +++ b/src/host/README.md @@ -62,7 +62,7 @@ - `allocateSession` throws `INVALID_DIMENSIONS` for non-positive `cols` or `rows`, `STORAGE_READ_ERROR` when `cwd` is not a directory and `INVALID_INPUT` when the shell path is not a file. An empty `command` defaults to the shell path. It creates the session directory and calls `launch` only after these checks pass. [lifecycle.ts:allocateSession] - `buildReplayInput` validates the manifest with `SessionRecordSchema` and requires its `sessionId` to match. It validates the events with `validateEventRecords` unless `trustValidated` is set. `targetSeq` is -1 for no events, else from 0 to the last `seq`. [replay.ts:buildReplayInput] - `RpcServer.listen` removes a stale socket file (no answer within 1,000 ms, `ECONNREFUSED` or `ENOENT`) and throws when another server is listening. It asserts a socket path of at most 104 characters. [rpcServer.ts:RpcServer] -- `RpcServer` and `sendRpc` cap a message at 1,048,576 characters. The server validates the request with `RpcRequestSchema`, the params and result with `RpcMethodSchemas`, and the response with `RpcResponseSchema` (all from `src/protocol/messages.ts`). A handler that throws a `CliError` gets an error response with its code, message and, when it has any, its `details`. It leaves `details` out when they do not serialize to JSON (a `BigInt`, a circular object) or take more than 65,536 characters, so the response stays under the 1,048,576-character cap and keeps the error's code. `sendRpc` rebuilds that `CliError` with the same details. Any other thrown error becomes `RPC_ERROR` without details. Unknown methods get an error response. [rpcServer.ts:RpcServer] [rpcClient.ts:sendRpc] +- `RpcServer` and `sendRpc` cap a message at 1,048,576 characters. The server validates the request with `RpcRequestSchema`, the params and result with `RpcMethodSchemas`, and the response with `RpcResponseSchema` (all from `src/protocol/messages.ts`). A handler that throws a `CliError` gets an error response with its code, message and, when it has any, its `details`. It leaves `details` out when they do not serialize to a JSON object (a `BigInt`, a circular object, a `toJSON` that returns something else) or take more than 65,536 characters. It cuts an error message to 65,536 characters and ends it with `[message truncated]`. So an error response stays under the 1,048,576-character cap and keeps the error's code. `sendRpc` rebuilds that `CliError` with the same details. Any other thrown error becomes `RPC_ERROR` without details. If building or sending the response throws, the server logs the error and answers `RPC_ERROR` "RPC request handling failed unexpectedly.", so the host keeps running. Unknown methods get an error response. [rpcServer.ts:RpcServer] [rpcClient.ts:sendRpc] - `sendRpc` waits 5,000 ms by default. It requires the response `id` to match the request. It maps `ECONNREFUSED`, `ENOENT` and `ECONNRESET` to `HOST_UNREACHABLE`, a socket timeout to `HOST_TIMEOUT` and other transport errors to `RPC_ERROR`. [rpcClient.ts:sendRpc] - `HostRendererManager` runs backend operations one at a time. `withBackend` requires the replay input `sessionId` to match, and recreates the backend when the initial size changes. [renderer.ts:HostRendererManager] - A waited `run` calls `EventLog.startAppend`, `registerWaitedRun` and `pty.write` with no `await` in between, then awaits `written`. When `registerWaitedRun` or `pty.write` throws, it waits for `written` to settle and rethrows that error, so a failed write never becomes an unhandled rejection. So no other input reaches the PTY or the log between the command's `seq` and its write, and the marker is registered before the command can print. [hostMain.ts:runHost]