Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
54 changes: 48 additions & 6 deletions src/host/rpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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<string, unknown>)
// 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<string, unknown>)
: undefined;
}

Expand All @@ -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;
Expand Down Expand Up @@ -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', () => {
Expand Down
74 changes: 73 additions & 1 deletion test/unit/host/rpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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();
}
});
});
Loading