diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index 4fdf2c4c23..ffaf7f5da5 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -36,14 +36,14 @@ en: 9020b491a39e4bf5 zh: bcd1b2b1a3bde7f3 /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/launch-and-control: - en: 894cf1c1fdbe0092 - zh: c1e1179391e89a3f + en: ed2b9c49cca1681c + zh: 611504d24cec9d35 /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-and-built-bootstraps: - en: e7251a2f7cb8f134 - zh: f68c08c507992969 + en: f9d796233bfb3b79 + zh: f7c5803a6ccdf9c2 /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-map: - en: 2a702f70cac410d1 - zh: f275136c6019b56d + en: 2246ec005bd30244 + zh: a8aedbdfeedf924e /deepseek-ai-dsh-ptc-runtime-node/further-exploration: en: ac2e2ae13d3f1394 zh: 5d08b9c658fcf83a diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index ebf931b141..091cf6a2e1 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -109,11 +109,13 @@ The host owns policy, deadlines, binding lookup and process cleanup. The child o For per-call execution, the host strips erasable types, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The host preserves `ELECTRON_RUN_AS_NODE` only for child startup so the Desktop executable runs the Node bootstrap; the bootstrap removes the selector before evaluating model code. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded. -Length-framed JSON travels separately from stdout/stderr. The host bounds frames and queued writes, validates call identity and declared binding names before dispatch, and refuses invalid traffic. The child flushes its terminal frame and keeps the control channel open until the host closes it. After submitting that frame, it ignores later binding replies and sends no further program control messages. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger. +The host calls the child’s `run()` method through birpc; injected bindings call the host’s `invoke()` method, and captured output uses notifications. birpc owns request correlation, remote results, and rejection delivery. Newline-delimited JSON travels over the inherited control pipe separately from stdout/stderr. The RPC adapter bounds incomplete messages and the native write queue, validates UTF-8 and exposed methods, and rejects pending calls when the pipe closes. The host validates evaluation identity, binding names and lossless JSON before dispatch. Execution deadlines remain host-owned, including calls that await approval. + +Per-call completion rejects unfinished binding waits and stops captured output notifications. The child keeps the control pipe open until the host receives its result and closes the pipe. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger. ### Source and built bootstraps -Source execution loads an erasable-only bootstrap closure without relying on sibling built exports. Built script execution uses `process.js` for per-call programs or `persistent-process.js` for retained REPL environments. Persistent mode requires a `node-script` launch. A remote script deployment supplies the matching `launch.bootstrapPath`. Embedded carriers launch their private worker through `launch.executable`. `resolveLaunch()` selects the local default during construction; an explicit remote invocation never depends on whether the client itself is packaged. A host path is never assumed to name the same remote file. +Source execution loads erasable TypeScript and its installed birpc dependency without relying on sibling built exports. Built process scripts bundle birpc so a preinstalled bootstrap needs no adjacent dependency installation. Built script execution uses `process.js` for per-call programs or `persistent-process.js` for retained REPL environments. Persistent mode requires a `node-script` launch. A remote script deployment supplies the matching `launch.bootstrapPath`. Embedded carriers launch their private worker through `launch.executable`. `resolveLaunch()` selects the local default during construction; an explicit remote invocation never depends on whether the client itself is packaged. A host path is never assumed to name the same remote file. ### Source map @@ -127,7 +129,8 @@ Source execution loads an erasable-only bootstrap closure without relying on sib | [`src/launch.ts`](src/launch.ts) | Executable/bootstrap arguments and execution-world asset mapping | | [`src/process.ts`](src/process.ts) | Child handshake, environment clearing and program lifecycle | | [`src/bootstrap.ts`](src/bootstrap.ts) | Program evaluation, binding proxies and output capture | -| [`src/channel.ts`](src/channel.ts) | Framing, bounded writes and protocol failures | +| [`src/rpc.ts`](src/rpc.ts) | Typed birpc transport, message limits and connection lifetime | +| [`src/result.ts`](src/result.ts) | Validation of untrusted program completions | | [`src/output-ledger.ts`](src/output-ledger.ts) | Host accounting for the outer result | diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index fd45eeda40..2f9e6f486e 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -109,11 +109,13 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责 Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。Host 仅在子进程启动时保留 `ELECTRON_RUN_AS_NODE`,使桌面端可执行文件运行 Node bootstrap;bootstrap 在求值模型代码前删除该选择变量。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。 -带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。子进程刷新终态帧后仍保持控制通道打开,直到 Host 关闭通道。提交终态帧后,子进程忽略后续绑定回复,不再发送程序控制消息。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。 +Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host 的 `invoke()` 方法,捕获的输出通过通知传递。birpc 负责请求关联、远程结果和异常传递。逐行 JSON 通过继承的控制管道传输,与 stdout/stderr 分离。RPC 适配层限制未完成消息和原生写入队列的大小,校验 UTF-8 与公开方法,并在管道关闭时拒绝待完成调用。Host 在分派前校验求值身份、绑定名与无损 JSON。执行截止时间仍由 Host 管理,包含等待审批的调用。 + +单次执行完成后会拒绝尚未完成的绑定等待,并停止捕获输出的通知。子进程保持控制管道打开,直到 Host 收到结果并关闭管道。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。 ### 源代码与构建后 bootstrap -源代码执行加载仅含可擦除语法的 bootstrap 依赖,不依赖同级包的构建后导出。构建后脚本通过 `process.js` 运行逐次程序,通过 `persistent-process.js` 运行保留状态的 REPL。保留模式要求 `node-script` 启动方式。远端脚本部署提供对应的 `launch.bootstrapPath`。内嵌载体通过 `launch.executable` 启动私有 worker。`resolveLaunch()` 在构造时选择本地默认值;显式的远端调用不依赖客户端自身是否打包。不会假设 Host 路径对应同一个远程文件。 +源代码执行加载可擦除的 TypeScript 及已安装的 birpc 依赖,不依赖同级包的构建后导出。构建后的进程脚本将 birpc 打包在内,因此预安装的 bootstrap 无需在旁边安装依赖。构建后脚本通过 `process.js` 运行逐次程序,通过 `persistent-process.js` 运行保留状态的 REPL。保留模式要求 `node-script` 启动方式。远端脚本部署提供对应的 `launch.bootstrapPath`。内嵌载体通过 `launch.executable` 启动私有 worker。`resolveLaunch()` 在构造时选择本地默认值;显式的远端调用不依赖客户端自身是否打包。不会假设 Host 路径对应同一个远程文件。 ### 源码索引 @@ -127,7 +129,8 @@ Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 | [`src/launch.ts`](src/launch.ts) | 可执行文件/bootstrap 参数与执行世界资源映射 | | [`src/process.ts`](src/process.ts) | 子进程握手、环境清空与程序生命周期 | | [`src/bootstrap.ts`](src/bootstrap.ts) | 程序求值、绑定代理与输出捕获 | -| [`src/channel.ts`](src/channel.ts) | 分帧、有界写入与协议失败 | +| [`src/rpc.ts`](src/rpc.ts) | 类型化 birpc 传输、消息限制与连接生命周期 | +| [`src/result.ts`](src/result.ts) | 不可信程序完成值的校验 | | [`src/output-ledger.ts`](src/output-ledger.ts) | Host 外层结果计量 | diff --git a/packages/ptc-runtime/ptc-runtime-node/package.json b/packages/ptc-runtime/ptc-runtime-node/package.json index 1b2ee3a234..9d181b7c89 100644 --- a/packages/ptc-runtime/ptc-runtime-node/package.json +++ b/packages/ptc-runtime/ptc-runtime-node/package.json @@ -45,7 +45,8 @@ }, "dependencies": { "@deepseek-ai/dsh-util-values": "workspace:*", - "@deepseek-ai/schemastery": "workspace:~" + "@deepseek-ai/schemastery": "workspace:~", + "birpc": "^4.2.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/ptc-runtime/ptc-runtime-node/src/bindings.ts b/packages/ptc-runtime/ptc-runtime-node/src/bindings.ts index 5a33c8e4a8..b487858c83 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/bindings.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/bindings.ts @@ -2,8 +2,7 @@ import { DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-ptc-runtime' import type { PtcBindingFunction, PtcBindingNamespace, PtcJsonValue, PtcRunRequest } from '@deepseek-ai/dsh-ptc-runtime' import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values' -import { encodePtcJsonWire } from './json-wire.ts' -import type { ReplyMessage } from './protocol.ts' +import { encodePtcJsonWire, type PtcJsonWire } from './json-wire.ts' const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ /** * Reject unusable namespaces before starting a process. @@ -54,19 +53,14 @@ export function validateBindings(request: PtcRunRequest): Map { - try { - if (!binding) throw new Error('binding is unavailable in this evaluation; reacquire the current tool') - const value = snapshotJsonValue(await binding(args)) - if (value === undefined) throw new Error('binding resolution must be lossless JSON') - return { type: 'reply', id, ok: true, value: encodePtcJsonWire(value) } - } catch (error: unknown) { - return { type: 'reply', id, ok: false, message: error instanceof Error ? error.message : String(error) } - } +export async function invokeBinding(binding: PtcBindingFunction | undefined, args: PtcJsonValue): Promise { + if (!binding) throw new Error('binding is unavailable in this evaluation; reacquire the current tool') + const value = snapshotJsonValue(await binding(args)) + if (value === undefined) throw new Error('binding resolution must be lossless JSON') + return encodePtcJsonWire(value) } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts index 06a2338a24..8fd45a3190 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts @@ -4,7 +4,8 @@ */ import { inspect } from 'node:util' -import type { DoneMessage, ReplyMessage, ProgramBootData, ProgramToHost } from './protocol.ts' +import type { ProgramResult, ProgramBootData } from './protocol.ts' +import type { PtcJsonWire } from './json-wire.ts' import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts' import { decodePtcJsonWire, encodePtcJsonWire, snapshotPtcJsonValue } from './json-wire.ts' @@ -20,10 +21,23 @@ function defineBindingErrorField(error: Error, key: string, value: string): void capturedObjectDefineProperty(error, key, attributes) } -/** Program messages and host replies transported by the private process channel. */ -export interface BootstrapPort { - postMessage(message: ProgramToHost): void - on(event: 'message', listener: (message: ReplyMessage) => void): void +/** Evaluation-scoped host calls used by injected bindings and output capture. */ +export interface ProgramHost { + /** + * Invoke one declared host binding. + * @param global - Namespace global. + * @param name - Function name. + * @param args - Flat lossless JSON argument. + * @returns Flat lossless result or a rejected binding failure. + */ + invoke(this: void, global: string, name: string, args: PtcJsonWire): Promise + /** + * Stream text to the host output ledger. + * @param text - Captured text. + */ + log(text: string): void + /** Report exhausted output accounting to the host. */ + outputLimit(): void } /** @@ -151,7 +165,7 @@ export function captureStreamWrites(logs: Pick, stream: Patch const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const /** - * Prepare the program's completion value for the done message. Only lossless + * Prepare the program's completion value for the run result. Only lossless * JSON crosses, and a value that does not fit the remaining combined outer * budget reports `output-limit`; the host revalidates hostile traffic and * remains authoritative for native pipe writes the program shim cannot observe. @@ -159,13 +173,13 @@ const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 * @param value - the program's completion value. * @param remainingOutputBytes - exact bytes left after captured logs. * @param maxOutputBytes - the configured cap named in an overflow diagnostic. - * @returns the done-message fragment: `{}` for `undefined`, else a flat wire `{ value }`. + * @returns the result fragment: `{}` for `undefined`, else a flat wire `{ value }`. */ export function prepareCompletion( value: unknown, remainingOutputBytes: number, maxOutputBytes: number = remainingOutputBytes, -): Omit { +): ProgramResult { if (value === undefined) return {} let snapshot: ReturnType try { @@ -188,7 +202,7 @@ export function prepareCompletion( } /** Build the fixed overflow fragment without carrying rejected variable bytes. */ -function outputLimit(maxOutputBytes: number): Omit { +function outputLimit(maxOutputBytes: number): ProgramResult { return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } } } @@ -198,7 +212,7 @@ function prepareFailure( message: string, remainingOutputBytes: number, maxOutputBytes: number, -): Omit { +): ProgramResult { if (jsonStringBytesUpTo(message, remainingOutputBytes) === undefined) return outputLimit(maxOutputBytes) return { error: { kind, message } } } @@ -215,7 +229,7 @@ export function prepareException( error: unknown, remainingOutputBytes: number, maxOutputBytes: number = remainingOutputBytes, -): Omit { +): ProgramResult { let message: string try { const detail: unknown = error instanceof CapturedError ? error.stack ?? error.message : error @@ -226,12 +240,6 @@ export function prepareException( return prepareFailure('exception', message, remainingOutputBytes, maxOutputBytes) } -/** One awaited binding call's settlement handles, keyed by call id in the pending map. */ -export interface PendingCall { - resolve(value: unknown): void - reject(error: Error): void -} - /** Constructor type for one program-visible binding rejection class. */ export type BindingErrorConstructor = new (memberName: string, message: string) => Error @@ -273,48 +281,15 @@ export function makeBindingErrorClasses( } /** - * Route host replies into the pending-call map: each reply settles its call - * at most once, and a reply for an unknown id (stray, or a duplicate answer - * to an id already settled) is ignored. Shared wiring between - * {@link runProgram} and the tests that exercise {@link makeNamespaces} - * standalone. - * @param port - the port whose `message` events carry the replies. - * @param pending - the id-keyed map of unsettled binding calls. - */ -export function wireReplies(port: BootstrapPort, pending: Map): void { - port.on('message', (message: ReplyMessage) => { - const entry = pending.get(message.id) - if (!entry) return - pending.delete(message.id) - if (message.ok) { - const value = decodePtcJsonWire(message.value) - if (value === undefined) entry.reject(new CapturedError('binding resolution must be lossless JSON')) - else entry.resolve(value) - } else { - entry.reject(new CapturedError(message.message)) - } - }) -} - -/** - * Build the binding namespace objects the program sees: one null-prototype global per - * namespace, each declared name an own enumerable async function that bridges over the port - * (`__proto__`/`constructor`/`toString` are ordinary keys, never prototype collisions). - * Lossy arguments reject before posting; clone failures and host failure - * replies reject only the corresponding call. - * - * @param data - the boot payload's namespace declarations (globals + names). - * @param port - the port binding calls are posted to. - * @param pending - the id-keyed map each posted call parks its handles in. - * @param nextId - the shared mutable id counter (program-issued correlation ids). - * @param errorClasses - Namespace constructors shared with program globals, resolved for each invocation. - * @returns one namespace object per declaration, in declaration order. + * Build null-prototype binding globals with namespace-specific rejection classes. + * @param data - Namespace declarations injected into the program. + * @param host - Evaluation-scoped remote binding calls. + * @param errorClasses - Constructors shared with program globals, resolved per invocation. + * @returns One namespace object per declaration; arguments and results must be lossless JSON. */ export function makeNamespaces( data: Pick, - port: BootstrapPort, - pending: Map, - nextId: { value: number }, + host: Pick, errorClasses: Pick, 'get'> = makeBindingErrorClasses(data), ): Record[] { return data.namespaces.map(({ global, names }) => { @@ -322,34 +297,22 @@ export function makeNamespaces( for (const name of names) { Object.defineProperty(namespace, name, { enumerable: true, - value: (args: unknown): Promise => { + value: async (args: unknown): Promise => { const errorClass = errorClasses.get(global) - let detached: ReturnType try { - detached = snapshotPtcJsonValue(args) - } catch { - detached = undefined - } - if (detached === undefined) { - return Promise.reject(bindingFailure(errorClass, name, 'binding arguments must be lossless JSON')) - } - return new Promise((resolve, reject) => { - const id = nextId.value - pending.set(id, { - resolve, - reject: (error) => { - reject(bindingFailure(errorClass, name, error.message)) - }, - }) - try { - port.postMessage({ type: 'call', id, global, name, args: encodePtcJsonWire(detached) }) - nextId.value += 1 - } catch (error: unknown) { - pending.delete(id) - const message = `binding call could not be sent: ${error instanceof CapturedError ? error.message : String(error)}` - reject(bindingFailure(errorClass, name, message)) + let detached: ReturnType + try { detached = snapshotPtcJsonValue(args) } catch (error: unknown) { + // Model-defined getters can fail while arguments are detached. + void error + detached = undefined } - }) + if (detached === undefined) throw new CapturedError('binding arguments must be lossless JSON') + const result = decodePtcJsonWire(await host.invoke(global, name, encodePtcJsonWire(detached))) + if (result === undefined) throw new CapturedError('binding resolution must be lossless JSON') + return result + } catch (error: unknown) { + throw bindingFailure(errorClass, name, error instanceof CapturedError ? error.message : String(error)) + } }, }) } @@ -358,32 +321,27 @@ export function makeNamespaces( } /** - * Run one strict async-function body, allowing top-level `await` and `return`, and post exactly - * one terminal {@link DoneMessage}; a thrown program error becomes its `error` field. - * @param port - host message port or test double. + * Run one strict async-function body with captured output and host binding proxies. + * @param host - Evaluation-scoped host methods. * @param data - the boot payload the host sent. * @param streams - stdout/stderr objects captured as program logs. - * @returns after posting the done message. + * @returns Completion or a bounded program failure. */ export async function runProgram( - port: BootstrapPort, + host: ProgramHost, data: ProgramBootData, streams: { stdout: PatchableStream; stderr: PatchableStream }, -): Promise { +): Promise { const logs = new LogBuffer( data.maxOutputBytes, - (text) => { port.postMessage({ type: 'log', text }) }, - () => { port.postMessage({ type: 'output-limit' }) }, + (text) => { host.log(text) }, + () => { host.outputLimit() }, ) captureStreamWrites(logs, streams.stdout) captureStreamWrites(logs, streams.stderr) - const pending = new Map() - wireReplies(port, pending) - - const nextId = { value: 1 } const errorClasses = makeBindingErrorClasses(data) - const namespaces = makeNamespaces(data, port, pending, nextId, errorClasses) + const namespaces = makeNamespaces(data, host, errorClasses) const errorClassParameters: string[] = [] const errorClassValues: BindingErrorConstructor[] = [] for (const namespace of data.namespaces) { @@ -396,7 +354,6 @@ export async function runProgram( } const consoleShim = makeConsoleShim(logs) - let done: DoneMessage try { // The async function constructor, reached through an instance because // `AsyncFunction` is not a global. The program body is strict-mode. @@ -409,15 +366,8 @@ export async function runProgram( `'use strict';\n${data.code}`, ) const value = await fn(...namespaces, ...errorClassValues, consoleShim) - done = { - type: 'done', - ...prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes), - } + return prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes) } catch (error: unknown) { - done = { - type: 'done', - ...prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes), - } + return prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes) } - port.postMessage(done) } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/channel.ts b/packages/ptc-runtime/ptc-runtime-node/src/channel.ts deleted file mode 100644 index 71e89e7570..0000000000 --- a/packages/ptc-runtime/ptc-runtime-node/src/channel.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** Length-prefixed JSON transport with bounded input and queued writes. */ -import { Buffer } from 'node:buffer' -import type { Duplex } from 'node:stream' -import type { PtcJsonValue } from '@deepseek-ai/dsh-ptc-runtime' -import { jsonValueBytesUpTo } from './output-json.ts' - -const stringify = JSON.stringify -const parse = JSON.parse - -interface PendingWrite { - promise: Promise - finish(error?: Error): void -} - -/** One co-shipped process channel; the consumer owns frame validation and terminal outcomes. */ -export class JsonChannel { - private readonly stream: Duplex - private readonly maxBytes: number - private readonly receive: (message: unknown, bytes: number) => void - private readonly failure: (error: Error, kind: 'io' | 'protocol') => void - private readonly header = Buffer.alloc(4) - private headerBytes = 0 - private payload: Buffer | undefined - private payloadBytes = 0 - private queuedBytes = 0 - private closed = false - private readonly writes = new Set() - - constructor( - stream: Duplex, - maxBytes: number, - receive: (message: unknown, bytes: number) => void, - failure: (error: Error, kind: 'io' | 'protocol') => void, - ) { - this.stream = stream - this.maxBytes = maxBytes - this.receive = receive - this.failure = failure - stream.on('data', this.onData) - stream.on('error', this.onError) - stream.on('end', this.onEnd) - stream.on('close', this.onClose) - } - - private readonly onError = (error: Error): void => { - this.finishWrites(error) - if (!this.closed) this.failure(error, 'io') - } - private readonly onClose = (): void => { this.finishWrites(new Error('control channel closed during a write')) } - private readonly onEnd = (): void => { if (!this.closed) this.failure(new Error('control channel ended before the program settled'), 'io') } - private readonly onData = (chunk: Buffer): void => { - if (this.closed) return - try { - let offset = 0 - // receive can synchronously close the channel while dispatching a preceding frame. - // oxlint-disable-next-line typescript/no-unnecessary-condition - while (offset < chunk.length && !this.closed) { - if (this.payload === undefined) { - const bytes = Math.min(4 - this.headerBytes, chunk.length - offset) - chunk.copy(this.header, this.headerBytes, offset, offset + bytes) - this.headerBytes += bytes - offset += bytes - if (this.headerBytes !== 4) continue - const length = this.header.readUInt32BE(0) - if (length === 0 || length > this.maxBytes) throw new Error(`control frame exceeds ${this.maxBytes} bytes or is empty`) - this.payload = Buffer.allocUnsafe(length) - this.payloadBytes = 0 - this.headerBytes = 0 - } - const bytes = Math.min(this.payload.length - this.payloadBytes, chunk.length - offset) - chunk.copy(this.payload, this.payloadBytes, offset, offset + bytes) - this.payloadBytes += bytes - offset += bytes - if (this.payloadBytes !== this.payload.length) continue - const frame = this.payload - this.payload = undefined - this.payloadBytes = 0 - this.receive(parse(new TextDecoder('utf-8', { fatal: true }).decode(frame)), frame.length) - } - } catch (error: unknown) { - this.failure(error instanceof Error ? error : new Error(String(error)), 'protocol') - } - } - - /** - * Submit a bounded frame immediately and await the stream's write receipt. - * @param message - JSON-only co-shipped protocol value. - * @returns Resolves when this frame has been written, or rejects after transport failure. - */ - send(message: unknown): Promise { - if (this.closed) return Promise.reject(new Error('control channel is closed')) - const size = jsonValueBytesUpTo(message as PtcJsonValue, this.maxBytes) - if (size === undefined || this.queuedBytes + size > this.maxBytes) { - return Promise.reject(new Error(`control output exceeds ${this.maxBytes} queued bytes`)) - } - const body = Buffer.from(stringify(message), 'utf8') - const header = Buffer.alloc(4) - header.writeUInt32BE(body.length) - this.queuedBytes += body.length - const completion = Promise.withResolvers() - const write: PendingWrite = { - promise: completion.promise, - finish: (error) => { - if (!this.writes.delete(write)) return - this.queuedBytes -= body.length - if (error) completion.reject(error) - else completion.resolve() - }, - } - this.writes.add(write) - try { - // Writable preserves frame order; a Promise queue would delay logs behind a model hot loop. - this.stream.cork() - this.stream.write(header) - this.stream.write(body, (error) => { write.finish(error ?? undefined) }) - this.stream.uncork() - } catch (error: unknown) { - write.finish(error instanceof Error ? error : new Error(String(error))) - } - return completion.promise - } - - private finishWrites(error: Error): void { - for (const write of this.writes) write.finish(error) - } - - /** Stop reads and close the owned endpoint; pending writes reject on closure. */ - close(): void { - if (this.closed) return - this.closed = true - this.payload = undefined - this.finishWrites(new Error('control channel is closed')) - this.stream.off('data', this.onData) - this.stream.off('end', this.onEnd) - this.stream.destroy() - } - - /** Wait for accepted writes to finish or fail. */ - async drain(): Promise { await Promise.allSettled([...this.writes].map(write => write.promise)) } -} diff --git a/packages/ptc-runtime/ptc-runtime-node/src/config.ts b/packages/ptc-runtime/ptc-runtime-node/src/config.ts index 75b1fe3ad8..18bebfe099 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/config.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/config.ts @@ -55,7 +55,7 @@ export function validateConfig(config: ResolvedConfig): void { if (config[key] > MAX_TIMER_DELAY_MS) throw new Error(`ptc-runtime-node: ${key} exceeds the supported timer range`) } if (!Number.isSafeInteger(config.maxOutputBytes) || config.maxOutputBytes < 4) throw new Error('ptc-runtime-node: maxOutputBytes must be an integer of at least 4') - if (!Number.isSafeInteger(config.maxMessageBytes) || config.maxMessageBytes > 0xffff_ffff) throw new Error('ptc-runtime-node: maxMessageBytes must fit an unsigned 32-bit frame length') + if (!Number.isSafeInteger(config.maxMessageBytes)) throw new Error('ptc-runtime-node: maxMessageBytes must be an integer') if (!Number.isSafeInteger(config.maxPendingCalls)) throw new Error('ptc-runtime-node: maxPendingCalls must be an integer') if (!Number.isSafeInteger(config.maxOldGenerationSizeMb)) throw new Error('ptc-runtime-node: maxOldGenerationSizeMb must be an integer') } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/index.ts b/packages/ptc-runtime/ptc-runtime-node/src/index.ts index 2d31e010c4..c2e342819c 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/index.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/index.ts @@ -4,15 +4,17 @@ import { stripTypeScriptTypes } from 'node:module' import { isAbsolute } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' -import type { PtcBindingNamespace, PtcJsonValue, PtcRunFailure, PtcRunRequest, PtcRunResult, PtcRunSandbox, PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime' +import type { PtcBindingFunction, PtcBindingNamespace, PtcJsonValue, PtcRunFailure, PtcRunRequest, PtcRunResult, PtcRunSandbox, PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime' import { clampTimeout } from '@deepseek-ai/dsh-timeout' import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-fs' -import { replyToBinding, validateBindings } from './bindings.ts' -import { JsonChannel } from './channel.ts' +import type { BirpcReturn } from 'birpc' +import { invokeBinding, validateBindings } from './bindings.ts' +import { createProcessRpc } from './rpc.ts' +import { readProgramResult } from './result.ts' import { bootstrapArgs, resolveLaunch } from './launch.ts' import { NodeConfig, validateConfig } from './config.ts' import { PolicyProcesses } from './policy-processes.ts' @@ -24,14 +26,13 @@ import { OutputLedger } from './output-ledger.ts' import { terminateProcess } from './output-stream.ts' import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts' import { decodePtcJsonWire } from './json-wire.ts' -import type { ProgramBootData } from './protocol.ts' +import type { ProgramBootData, ProgramMethods, HostMethods } from './protocol.ts' interface LiveRun { controller: AbortController; finished: Promise } const STRIP_PREFIX = 'async function __dsh_program__() {\n' const STRIP_SUFFIX = '\n}' function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } -function record(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } /** Node execution under file policy, with optional owner-scoped retained memory. */ export class NodePtcRuntime extends PtcRuntime { @@ -135,14 +136,14 @@ export class NodePtcRuntime extends PtcRuntime { bindings: Map, controller: AbortController, ): Promise { - const replyBinding = AsyncLocalStorage.bind(replyToBinding) + const callBinding = AsyncLocalStorage.bind(invokeBinding) const output = new OutputLedger(this.config.maxOutputBytes) const logs: string[] = [] const sandbox: PtcRunSandbox = { mode: policy.mode, denied: false } const result = Promise.withResolvers() const signal = spec.signal === undefined ? controller.signal : AbortSignal.any([spec.signal, controller.signal]) let handle: SubprocessHandle | undefined - let channel: JsonChannel | undefined + let channel: BirpcReturn | undefined let confined: ConfinedArgv | undefined let settled = false let timedOut = false @@ -167,7 +168,7 @@ export class NodePtcRuntime extends PtcRuntime { settled = true clearTimeout(wallTimer) signal.removeEventListener('abort', onAbort) - channel?.close() + channel?.$close() void (async () => { try { const drained = await stop() @@ -263,7 +264,6 @@ export class NodePtcRuntime extends PtcRuntime { }) handle.stderr?.on('error', (error: Error) => { finish({ kind: 'worker-exit', message: messageOf(error) }) }) let ready = false - let nextId = 1 let pending = 0 let pendingBytes = 0 const protocolFailure = (message: string): void => { finish({ kind: 'protocol', message }) } @@ -273,59 +273,53 @@ export class NodePtcRuntime extends PtcRuntime { ? { kind: 'sandbox-unavailable', message: stderr } : { kind: 'worker-exit', message: `Node process exited before completing (${String(outcome.exitCode)})${stderr ? `: ${stderr}` : ''}` }) } - const transport: JsonChannel = new JsonChannel(launched.control, this.config.maxMessageBytes, (raw, bytes) => { - if (!record(raw)) { protocolFailure('invalid control frame'); return } - if (!ready) { - if (raw.type !== 'ready') { protocolFailure('program frame arrived before bootstrap readiness'); return } + const transport = createProcessRpc(launched.control, this.config.maxMessageBytes, { + ready: () => { + if (ready) { protocolFailure('duplicate program readiness'); return } ready = true - void transport.send({ type: 'boot', data }).catch((error: unknown) => { protocolFailure(messageOf(error)) }) - return - } - switch (raw.type) { - case 'log': - if (typeof raw.text !== 'string') { protocolFailure('invalid log frame'); return } - admit(raw.text) - return - case 'output-limit': outputOverflow = true; finish(); return - case 'done': { - if (raw.error !== undefined) { - if (!record(raw.error) || typeof raw.error.message !== 'string' || (raw.error.kind !== 'exception' && raw.error.kind !== 'invalid-output' && raw.error.kind !== 'output-limit')) { protocolFailure('invalid terminal error'); return } - const failure = { kind: raw.error.kind, message: raw.error.message } as PtcRunFailure - if (confined !== undefined) { - sandbox.denied = confined.denialSignatures.some(signature => - failure.message.toLowerCase().includes(signature.toLowerCase())) - } - if (failure.kind === 'output-limit') outputOverflow = true - finish(failure) - return + void transport.run(1, spec.cwd, data).then((raw) => { + // A message delivered beside the result can select another outcome first. + if (settled) return + const { error, value } = readProgramResult(raw) + if (error !== undefined) { + if (confined !== undefined) sandbox.denied = confined.denialSignatures.some(signature => + error.message.toLowerCase().includes(signature.toLowerCase())) + if (error.kind === 'output-limit') outputOverflow = true } - const value = raw.value === undefined ? undefined : decodePtcJsonWire(raw.value) - if (raw.value !== undefined && value === undefined) finish({ kind: 'invalid-output', message: 'program completion must be lossless JSON' }) - else finish(undefined, value) - return - } - case 'call': { - if (!Number.isSafeInteger(raw.id) || raw.id !== nextId || typeof raw.global !== 'string' || typeof raw.name !== 'string') { protocolFailure('invalid binding call identity'); return } - nextId += 1 - const functions = bindings.get(raw.global)?.functions - const fn = functions !== undefined && Object.hasOwn(functions, raw.name) ? functions[raw.name] : undefined - if (typeof fn !== 'function') { protocolFailure('program requested an undeclared binding'); return } - const args = decodePtcJsonWire(raw.args) - if (args === undefined) { protocolFailure('binding arguments must be lossless JSON'); return } - if (++pending > this.config.maxPendingCalls || (pendingBytes += bytes) > this.config.maxMessageBytes) { protocolFailure('pending binding calls exceed configured limits'); return } - const id = raw.id - void (async () => { - const reply = await replyBinding(id, fn, args).finally(() => { - pending -= 1 - pendingBytes -= bytes - }) - if (!settled) await transport.send(reply) - })().catch((error: unknown) => { protocolFailure(messageOf(error)) }) - return - } - default: protocolFailure('unknown control message') - } - }, (error, kind) => { + finish(error, value) + }).catch((error: unknown) => { protocolFailure(messageOf(error)) }) + }, + log: (evaluation, text) => { + if (!ready || evaluation !== 1 || typeof text !== 'string') protocolFailure('invalid program log') + else admit(text) + }, + outputLimit: (evaluation) => { + if (!ready || evaluation !== 1) { protocolFailure('invalid program output limit'); return } + outputOverflow = true + finish() + }, + invoke: async (evaluation: unknown, global: unknown, name: unknown, wire: unknown) => { + let fn: PtcBindingFunction | undefined + let args: PtcJsonValue | undefined + let bytes = 0 + try { + if (!ready || evaluation !== 1) throw new Error('call does not belong to the active PTC evaluation') + if (typeof global !== 'string' || typeof name !== 'string') throw new Error('invalid binding call identity') + const functions = bindings.get(global)?.functions + fn = functions !== undefined && Object.hasOwn(functions, name) ? functions[name] : undefined + if (typeof fn !== 'function') throw new Error('program requested an undeclared binding') + args = decodePtcJsonWire(wire) + if (args === undefined) throw new Error('binding arguments must be lossless JSON') + bytes = Buffer.byteLength(JSON.stringify(wire)) + if (pending >= this.config.maxPendingCalls || pendingBytes + bytes > this.config.maxMessageBytes) { + throw new Error('pending binding calls exceed configured limits') + } + } catch (error: unknown) { protocolFailure(messageOf(error)); throw error } + pending += 1 + pendingBytes += bytes + try { return await callBinding(fn, args) } finally { pending -= 1; pendingBytes -= bytes } + }, + }, [], (error, kind) => { if (kind === 'protocol') protocolFailure(messageOf(error)) else if (ready) finish({ kind: 'worker-exit', message: messageOf(error) }) else void launched.done.then(processFinished, (failure: unknown) => { finish({ kind: 'worker-exit', message: messageOf(failure) }) }) diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts index 27de2e9d1d..e7c67260af 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts @@ -2,10 +2,10 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { start } from 'node:repl' import { PassThrough, Writable, type Duplex } from 'node:stream' -import { JsonChannel } from './channel.ts' -import { captureStreamWrites, LogBuffer, makeConsoleShim, makeBindingErrorClass, makeNamespaces, prepareCompletion, prepareException, wireReplies } from './bootstrap.ts' -import type { BindingErrorConstructor, PendingCall, PatchableStream } from './bootstrap.ts' -import type { DoneMessage, ProgramBootData, ProgramToHost, ReplyMessage } from './protocol.ts' +import { createProcessRpc } from './rpc.ts' +import { captureStreamWrites, LogBuffer, makeConsoleShim, makeBindingErrorClass, makeNamespaces, prepareCompletion, prepareException } from './bootstrap.ts' +import type { BindingErrorConstructor, PatchableStream, ProgramHost } from './bootstrap.ts' +import type { ProgramResult, HostMethods, ProgramMethods } from './protocol.ts' import { clearProgramEnvironment } from './environment.ts' /** Process state owned by the REPL child; tests supply instance-local streams and environment. */ @@ -21,26 +21,23 @@ interface Evaluation { logs: LogBuffer maxOutputBytes: number active: boolean - finish(result: Omit): void + finish(result: ProgramResult): void } /** * Serve sequential evaluations until the owning host closes the control channel. * @param stream - Inherited private control endpoint. - * @param maxMessageBytes - Maximum control frame and queued output bytes. + * @param maxMessageBytes - Maximum control message and queued output bytes. * @param processState - Child environment, output and directory control, bound only in the REPL context. * @returns After the channel closes and the REPL releases its streams. */ export async function runPersistentMain(stream: Duplex, maxMessageBytes: number, processState: PersistentProcess): Promise { - if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0 || maxMessageBytes > 0xffff_ffff) throw new Error('invalid control message limit') + if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0) throw new Error('invalid control message limit') clearProgramEnvironment(processState) const context = new AsyncLocalStorage() const closed = Promise.withResolvers() - const pending = new Map() - const nextId = { value: 1 } const errors = new Map() const errorLookup = { get: (name: string) => errors.get(name)?.constructor } - const replies: Array<(message: ReplyMessage) => void> = [] const input = new PassThrough() const output = new Writable({ write(chunk: Buffer, _encoding, callback) { const current = context.getStore() @@ -54,83 +51,79 @@ export async function runPersistentMain(stream: Duplex, maxMessageBytes: number, Object.defineProperty(repl.context, name, { configurable: true, enumerable: true, writable: true, value: undefined }) } let current: Evaluation | undefined - const send = (id: number, payload: ProgramToHost): void => { - void channel.send({ id, payload }).catch(() => { channel.close(); closed.resolve() }) - } - const port = { - postMessage(payload: ProgramToHost): void { + const host: Pick = { + invoke: (global, name, args) => { const evaluation = context.getStore() if (!evaluation?.active) throw new Error('PTC evaluation has ended; call this function from a new run_code evaluation') - send(evaluation.id, payload) + return connection.invoke(evaluation.id, global, name, args) }, - on(_event: 'message', listener: (message: ReplyMessage) => void): void { replies.push(listener) }, } - wireReplies(port, pending) const logSink = { push(text: string): void { const evaluation = context.getStore(); if (evaluation?.active) evaluation.logs.push(text) } } repl.context.console = makeConsoleShim(logSink) Object.defineProperty(repl.context, 'process', { configurable: true, enumerable: true, writable: true, value: processState }) captureStreamWrites(logSink, processState.stdout) captureStreamWrites(logSink, processState.stderr) - const channel = new JsonChannel(stream, maxMessageBytes, (raw) => { - // The host owns these frames. Model-written traffic is validated on the host. - const message = raw as { type: 'run'; id: number; cwd: string; data: ProgramBootData } | ReplyMessage - if (message.type === 'reply') { - for (const listener of replies) listener(message) - return - } - if (current?.active) { channel.close(); closed.resolve(); return } - const { id, data } = message - const logs = new LogBuffer(data.maxOutputBytes, (text) => { send(id, { type: 'log', text }) }, () => { send(id, { type: 'output-limit' }) }) - const evaluation: Evaluation = { - id, logs, maxOutputBytes: data.maxOutputBytes, active: true, - finish(result) { - if (!evaluation.active) return - evaluation.active = false - for (const call of pending.values()) call.reject(new Error('PTC evaluation ended before the binding completed')) - pending.clear() - send(id, { type: 'done', ...result }) - }, - } - current = evaluation - context.run(evaluation, () => { - try { - processState.chdir(message.cwd) - for (const namespace of data.namespaces) { - const descriptor = namespace.errorClass - if (!descriptor) { errors.delete(namespace.global); continue } - const previous = errors.get(namespace.global) - if (previous?.name === descriptor.name && previous.memberNameProperty === descriptor.memberNameProperty) continue - const constructor = makeBindingErrorClass(descriptor) - errors.set(namespace.global, { name: descriptor.name, memberNameProperty: descriptor.memberNameProperty, constructor }) - } - const namespaces = makeNamespaces(data, port, pending, nextId, errorLookup) - for (const [index, namespace] of data.namespaces.entries()) { - Object.defineProperty(repl.context, namespace.global, { configurable: true, value: namespaces[index] }) - if (namespace.errorClass) { - Object.defineProperty(repl.context, namespace.errorClass.name, { configurable: true, value: errorLookup.get(namespace.global) }) - } - } - // Node's REPL treats a leading brace as an object expression before transforming await. - const program = data.code.trimStart().startsWith('{') ? `;${data.code}` : data.code - repl.eval(`${program}\n`, repl.context, 'ptc-repl', (error: Error | null, value: unknown) => { - evaluation.finish(error ? prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes) - : prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes)) - }) - } catch (error: unknown) { - evaluation.finish(prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes)) + const connection = createProcessRpc(stream, maxMessageBytes, { + async run(id, cwd, data) { + if (current?.active) throw new Error('persistent PTC already has an active evaluation') + const completion = Promise.withResolvers() + const logs = new LogBuffer(data.maxOutputBytes, + (text) => { void connection.log(id, text) }, + () => { void connection.outputLimit(id) }) + const evaluation: Evaluation = { + id, logs, maxOutputBytes: data.maxOutputBytes, active: true, + finish(result) { + if (!evaluation.active) return + evaluation.active = false + void connection.$rejectPendingCalls(({ reject }) => { reject(new Error('PTC evaluation ended before the binding completed')) }) + completion.resolve(result) + }, } - }) - }, () => { channel.close(); closed.resolve() }) - stream.once('close', () => { closed.resolve() }) + current = evaluation + context.run(evaluation, () => { + try { + processState.chdir(cwd) + for (const namespace of data.namespaces) { + const descriptor = namespace.errorClass + if (!descriptor) { errors.delete(namespace.global); continue } + const previous = errors.get(namespace.global) + if (previous?.name === descriptor.name && previous.memberNameProperty === descriptor.memberNameProperty) continue + const constructor = makeBindingErrorClass(descriptor) + errors.set(namespace.global, { name: descriptor.name, memberNameProperty: descriptor.memberNameProperty, constructor }) + } + const namespaces = makeNamespaces(data, host, errorLookup) + for (const [index, namespace] of data.namespaces.entries()) { + Object.defineProperty(repl.context, namespace.global, { configurable: true, value: namespaces[index] }) + if (namespace.errorClass) { + Object.defineProperty(repl.context, namespace.errorClass.name, { + configurable: true, value: errorLookup.get(namespace.global), + }) + } + } + // Node's REPL treats a leading brace as an object expression before transforming await. + const program = data.code.trimStart().startsWith('{') ? `;${data.code}` : data.code + repl.eval(`${program}\n`, repl.context, 'ptc-repl', (error: Error | null, value: unknown) => { + evaluation.finish(error ? prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes) + : prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes)) + }) + } catch (error: unknown) { + evaluation.finish(prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes)) + } + }) + return await completion.promise + }, + }, ['ready', 'log', 'outputLimit'], () => { + // An inactive evaluation sends nothing more over the closed endpoint. + if (current) current.active = false + closed.resolve() + }) try { - await channel.send({ type: 'ready' }) + await connection.ready() await closed.promise } finally { - if (current) current.active = false repl.close() input.destroy() output.destroy() - channel.close() context.disable() } } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts index a1fb0c5fc5..e2c205b94a 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts @@ -8,19 +8,20 @@ import { clampTimeout } from '@deepseek-ai/dsh-timeout' import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' +import type { BirpcReturn } from 'birpc' import type { PolicyProcesses } from './policy-processes.ts' import type { ResolvedConfig } from './config.ts' -import { replyToBinding, validateBindings } from './bindings.ts' -import { JsonChannel } from './channel.ts' +import { invokeBinding, validateBindings } from './bindings.ts' +import { createProcessRpc } from './rpc.ts' +import { readProgramResult } from './result.ts' import { bootstrapArgs } from './launch.ts' import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts' -import { decodePtcJsonWire } from './json-wire.ts' +import { decodePtcJsonWire, type PtcJsonWire } from './json-wire.ts' import { OutputLedger } from './output-ledger.ts' import { drainOutput, terminateProcess } from './output-stream.ts' -import type { ProgramBootData } from './protocol.ts' +import type { ProgramBootData, ProgramMethods, HostMethods } from './protocol.ts' const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error) -const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) function persistentOutput(maxBytes: number): OutputLedger { return new OutputLedger(maxBytes, `retained variables were cleared; outer output exceeded ${maxBytes} bytes`) @@ -44,7 +45,7 @@ interface Evaluation { data: ProgramBootData spec: PtcRunSpec bindings: Map - reply: typeof replyToBinding + invoke: typeof invokeBinding pending: number pendingBytes: number admit(text: string): void @@ -56,14 +57,13 @@ class Kernel { private readonly unregister: (() => void) | undefined private confined: ConfinedArgv | undefined private handle?: SubprocessHandle - private channel?: JsonChannel + private channel?: BirpcReturn private active: Evaluation | undefined /** Admission remains reserved until the result includes completed process cleanup. */ private running = false private cleanupPending = false private ready = false private sequence = 0 - private nextCall = 1 private stderr = '' private stopped: Promise | undefined private starting?: Promise @@ -121,7 +121,7 @@ class Kernel { data: { code, maxOutputBytes: this.config.maxOutputBytes, namespaces: [...bindings.values()].map(binding => ({ global: binding.global, names: Object.keys(binding.functions), ...binding.errorClass ? { errorClass: binding.errorClass } : {}, })) }, - spec, bindings, reply: AsyncLocalStorage.bind(replyToBinding), pending: 0, pendingBytes: 0, + spec, bindings, invoke: AsyncLocalStorage.bind(invokeBinding), pending: 0, pendingBytes: 0, admit: (text) => { if (!output.admit(text, logs)) { logs.push(text) @@ -164,7 +164,7 @@ class Kernel { this.closed = true this.cleanupPending = true this.controller.abort() - this.channel?.close() + this.channel?.$close() this.stopped = (async () => { await this.starting?.catch(() => {}) const handle = this.handle @@ -187,8 +187,11 @@ class Kernel { } private dispatch(evaluation: Evaluation): void { - void this.channel?.send({ type: 'run', id: evaluation.id, cwd: evaluation.spec.cwd, data: evaluation.data }) - .catch((error: unknown) => { this.fail('protocol', messageOf(error)) }) + void this.channel?.run(evaluation.id, evaluation.spec.cwd, evaluation.data).then((raw) => { + const { error, value } = readProgramResult(raw) + // Raw stdio can be queued beside the RPC result on separate pipes. + setImmediate(() => { evaluation.finish(error, value) }) + }).catch((error: unknown) => { if (this.active === evaluation && !this.channel?.$closed) this.fail('protocol', messageOf(error)) }) } private exitMessage(outcome: SubprocessOutcome): string { @@ -217,11 +220,27 @@ class Kernel { }) stream.on('error', (error: Error) => { this.fail('worker-exit', error.message) }) } - this.channel = new JsonChannel(handle.control, this.config.maxMessageBytes, - (raw, bytes) => { this.receive(raw, bytes) }, (error, kind) => { - if (kind === 'protocol') this.fail('protocol', error.message) - else setImmediate(() => { if (!this.closed && this.exitOutcome === undefined) handle.terminate() }) - }) + this.channel = createProcessRpc(handle.control, this.config.maxMessageBytes, { + ready: () => { + if (this.ready || !this.active) { this.fail('protocol', 'unexpected persistent PTC readiness'); return } + this.ready = true + this.dispatch(this.active) + }, + log: (id, text) => { + const evaluation = this.evaluation(id) + if (evaluation === undefined || typeof text !== 'string') this.fail('protocol', 'invalid persistent PTC log') + else evaluation.admit(text) + }, + outputLimit: (id) => { + const evaluation = this.evaluation(id) + if (evaluation === undefined) this.fail('protocol', 'invalid persistent PTC output limit') + else evaluation.finish({ kind: 'output-limit', message: 'output limit exceeded' }, undefined, true) + }, + invoke: (id, global, name, wire) => this.invoke(id, global, name, wire), + }, [], (error, kind) => { + if (kind === 'protocol') this.fail('protocol', error.message) + else setImmediate(() => { if (!this.closed && this.exitOutcome === undefined) handle.terminate() }) + }) void handle.done.then(async (outcome) => { this.exitOutcome = outcome await Promise.all([drainOutput(handle.stdout, this.config.graceMs), drainOutput(handle.stderr, this.config.graceMs)]) @@ -232,62 +251,32 @@ class Kernel { }, (error: unknown) => { this.fail('worker-exit', messageOf(error)) }) } - private receive(raw: unknown, bytes: number): void { - if (!record(raw)) { this.fail('protocol', 'invalid persistent PTC frame'); return } - const evaluation = this.active - if (!evaluation) { this.fail('protocol', 'frame arrived without an active PTC evaluation'); return } - if (!this.ready) { - if (raw.type !== 'ready') { this.fail('protocol', 'expected persistent PTC readiness'); return } - this.ready = true - this.dispatch(evaluation) - return - } - if (raw.id !== evaluation.id || !record(raw.payload)) { this.fail('protocol', 'frame does not belong to the active PTC evaluation'); return } - const payload = raw.payload - switch (payload.type) { - case 'log': - if (typeof payload.text === 'string') evaluation.admit(payload.text) - else this.fail('protocol', 'invalid persistent PTC log') - return - case 'output-limit': evaluation.finish({ kind: 'output-limit', message: 'output limit exceeded' }, undefined, true); return - case 'done': { - if (payload.error !== undefined) { - const error = payload.error - if (!record(error) || typeof error.message !== 'string' || (error.kind !== 'exception' && error.kind !== 'invalid-output' && error.kind !== 'output-limit')) { - this.fail('protocol', 'invalid persistent PTC failure'); return - } - const failure = { kind: error.kind, message: error.message } as PtcRunFailure - setImmediate(() => { evaluation.finish(failure, undefined, failure.kind === 'output-limit') }) - } else { - const value = payload.value === undefined ? undefined : decodePtcJsonWire(payload.value) - const failure: PtcRunFailure | undefined = payload.value !== undefined && value === undefined - ? { kind: 'invalid-output', message: 'program completion must be lossless JSON' } : undefined - // Raw stdio may be queued beside the terminal control frame on separate pipes. - setImmediate(() => { evaluation.finish(failure, value) }) - } - return + private evaluation(id: unknown): Evaluation | undefined { + return this.ready && id === this.active?.id ? this.active : undefined + } + + private async invoke(id: unknown, global: unknown, name: unknown, wire: unknown): Promise { + let evaluation: Evaluation | undefined + let args: PtcJsonValue | undefined + let bytes = 0 + try { + evaluation = this.evaluation(id) + if (evaluation === undefined) throw new Error('call does not belong to the active PTC evaluation') + if (typeof global !== 'string' || typeof name !== 'string') throw new Error('invalid persistent PTC binding identity') + args = decodePtcJsonWire(wire) + if (args === undefined) throw new Error('binding arguments must be lossless JSON') + bytes = Buffer.byteLength(JSON.stringify(wire)) + if (evaluation.pending >= this.config.maxPendingCalls || evaluation.pendingBytes + bytes > this.config.maxMessageBytes) { + throw new Error('pending binding calls exceed configured limits') } - case 'call': { - if (payload.id !== this.nextCall || typeof payload.global !== 'string' || typeof payload.name !== 'string') { this.fail('protocol', 'invalid persistent PTC binding identity'); return } - this.nextCall += 1 - const args = decodePtcJsonWire(payload.args) - if (args === undefined) { this.fail('protocol', 'binding arguments must be lossless JSON'); return } - if (++evaluation.pending > this.config.maxPendingCalls || (evaluation.pendingBytes += bytes) > this.config.maxMessageBytes) { - this.fail('protocol', 'pending binding calls exceed configured limits'); return - } - const functions = evaluation.bindings.get(payload.global)?.functions - const fn = functions && Object.hasOwn(functions, payload.name) ? functions[payload.name] : undefined - const id = payload.id - void (async () => { - const reply = await evaluation.reply(id, fn, args).finally(() => { - evaluation.pending -= 1 - evaluation.pendingBytes -= bytes - }) - if (this.active === evaluation) await this.channel?.send(reply) - })().catch((error: unknown) => { this.fail('protocol', messageOf(error)) }) - return - } - default: this.fail('protocol', 'unknown persistent PTC message') + } catch (error: unknown) { this.fail('protocol', messageOf(error)); throw error } + evaluation.pending += 1 + evaluation.pendingBytes += bytes + const functions = evaluation.bindings.get(global)?.functions + const fn = functions && Object.hasOwn(functions, name) ? functions[name] : undefined + try { return await evaluation.invoke(fn, args) } finally { + evaluation.pending -= 1 + evaluation.pendingBytes -= bytes } } } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/process.ts b/packages/ptc-runtime/ptc-runtime-node/src/process.ts index 928931e6c0..727d38f1da 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/process.ts @@ -1,10 +1,10 @@ -/** Node child execution over an inherited control channel; no Harness services run here. */ +/** Node child execution through typed host and program RPC methods. */ import type { Duplex } from 'node:stream' -import { JsonChannel } from './channel.ts' +import { createProcessRpc } from './rpc.ts' import { runProgram } from './bootstrap.ts' import { clearProgramEnvironment } from './environment.ts' import type { PatchableStream } from './bootstrap.ts' -import type { ReplyMessage, ProgramBootData, ProgramToHost } from './protocol.ts' +import type { HostMethods, ProgramMethods } from './protocol.ts' /** Process-owned environment and output streams consumed by the child bootstrap. */ export interface ProgramProcess { @@ -15,72 +15,37 @@ export interface ProgramProcess { } /** - * Run one host-supplied program after the control handshake. + * Serve one run and retain the endpoint until the host receives its result and closes it. * @param stream - Inherited, already-adopted control endpoint. - * @param maxMessageBytes - Host-validated maximum frame and queued-write bytes. + * @param maxMessageBytes - Host-validated maximum message and queued-write bytes. * @param processState - Environment, output streams and exit status of this Node child. - * @returns After control output flushes and host shutdown is observed, or transport failure closes the channel. + * @returns After host shutdown or a transport failure closes the endpoint. */ export async function runNodeMain(stream: Duplex, maxMessageBytes: number, processState: ProgramProcess): Promise { - if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0 || maxMessageBytes > 0xffff_ffff) throw new Error('invalid control message limit') + if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0) throw new Error('invalid control message limit') clearProgramEnvironment(processState) - const boot = Promise.withResolvers() - const listeners: Array<(message: ReplyMessage) => void> = [] - let started = false - let failed = false - let terminalSent = false const hostClosed = Promise.withResolvers() - const onClose = (): void => { hostClosed.resolve() } - stream.once('close', onClose) - const channel = new JsonChannel(stream, maxMessageBytes, (raw) => { - if (!started) { - started = true - if (typeof raw !== 'object' || raw === null || (raw as { type?: unknown }).type !== 'boot') { - boot.reject(new Error('expected program boot frame')) - return - } - boot.resolve((raw as { data: ProgramBootData }).data) - return - } - if (terminalSent) return - for (const listener of listeners) listener(raw as ReplyMessage) - }, (error, kind) => { - if (!terminalSent || kind === 'protocol') { - failed = true - boot.reject(error) - channel.close() - processState.exitCode = 1 - } + // Set once the result is returned or the endpoint closes; the child then sends nothing more. + let ended = false + const connection = createProcessRpc(stream, maxMessageBytes, { + async run(evaluation, _cwd, data) { + const result = await runProgram({ + invoke: (global, name, args) => { + if (ended) throw new Error('PTC evaluation has ended') + return connection.invoke(evaluation, global, name, args) + }, + log: (text) => { if (!ended) void connection.log(evaluation, text) }, + outputLimit: () => { if (!ended) void connection.outputLimit(evaluation) }, + }, data, processState) + ended = true + void connection.$rejectPendingCalls(({ reject }) => { reject(new Error('PTC evaluation ended before the binding completed')) }) + return result + }, + }, ['ready', 'log', 'outputLimit'], (_error, kind) => { + if (!ended || kind === 'protocol') processState.exitCode = 1 + ended = true hostClosed.resolve() }) - const pending = new Set>() - const send = (message: ProgramToHost): void => { - if (terminalSent) return - if (message.type === 'done') terminalSent = true - const task = channel.send(message).catch(() => { - failed = true - channel.close() - processState.exitCode = 1 - hostClosed.resolve() - }).finally(() => { pending.delete(task) }) - pending.add(task) - } - try { - await channel.send({ type: 'ready' }) - const data = await boot.promise - await runProgram({ - postMessage: send, - on: (_event, listener) => { listeners.push(listener) }, - }, data, { stdout: processState.stdout, stderr: processState.stderr }) - while (pending.size > 0) await Promise.all(pending) - await channel.drain() - // Host binding replies can race the terminal frame; the host owns channel shutdown. - await hostClosed.promise - } finally { - stream.off('close', onClose) - channel.close() - // Transport callbacks can set failed while the awaited program executes. - // oxlint-disable-next-line typescript/no-unnecessary-condition - if (failed) processState.exitCode = 1 - } + await connection.ready() + await hostClosed.promise } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts b/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts index 01cd1f5bd5..f20f74afe2 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts @@ -1,67 +1,60 @@ -/** - * JSON message vocabulary carried by the bounded process channel. The host validates every - * message because model code can write directly to the inherited descriptor. - * @module @deepseek-ai/dsh-ptc-runtime-node/src/protocol - */ - +/** Typed methods shared by the host and its co-shipped Node execution process. */ import type { PtcJsonWire } from './json-wire.ts' -/** What the host hands the program at spawn, after the readiness handshake. */ +/** Inputs for one program; host functions remain in the parent process. */ export interface ProgramBootData { - /** The type-stripped (plain JS) program body. */ + /** Type-stripped JavaScript source. */ code: string - /** Binding namespaces to materialize; functions themselves stay host-side. */ + /** Globals and callable names injected into the program. */ namespaces: { global: string names: string[] errorClass?: { name: string; memberNameProperty: string } }[] - /** Hard cap for the combined serialized outer logs plus completion value or failure diagnostic. */ + /** Combined serialized log and completion budget. */ maxOutputBytes: number } -/** Program → host: one bridged binding call. */ -interface CallMessage { - type: 'call' - /** Program-issued correlation id; the host answers each id at most once and ignores duplicates. */ - id: number - /** The namespace global the call targets. */ - global: string - /** The function name within the namespace. */ - name: string - /** The single argument as a flat lossless-JSON wire value. */ - args: PtcJsonWire -} - -/** Program → host: captured text, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */ -interface LogMessage { - type: 'log' - text: string -} - -/** Program → host: program-side capture or completion measurement exceeded the outer cap. */ -interface OutputLimitMessage { - type: 'output-limit' -} - -/** - * Program → host: the program settled. `error` carries a program exception, - * invalid completion, or output overflow (budgets, aborts, and substrate death - * are observed host-side). `value` is present only on a clean completion that - * produced one, as a flat wire value already lossless and admitted against - * the remaining combined output cap. Logs are NOT carried here — they streamed - * eagerly as {@link LogMessage}s. - */ -export interface DoneMessage { - type: 'done' +/** Program completion; logs are delivered separately before this result. */ +export interface ProgramResult { value?: PtcJsonWire error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string } } -/** Every message the program sends. */ -export type ProgramToHost = CallMessage | LogMessage | OutputLimitMessage | DoneMessage +/** Host operations available only during the identified evaluation. */ +export interface HostMethods { + /** Announce that the process can accept its first evaluation. */ + ready(): void + /** + * Invoke a binding under its originating host execution context. + * @param evaluation - Active evaluation generation, independent of RPC correlation. + * @param global - Namespace global. + * @param name - Function name within the namespace. + * @param args - Flat lossless JSON arguments. + * @returns Flat lossless JSON result; binding failures reject the call. + */ + invoke(evaluation: number, global: string, name: string, args: PtcJsonWire): Promise + /** + * Admit captured output before completion or interruption. + * @param evaluation - Active evaluation generation. + * @param text - Captured text. + */ + log(evaluation: number, text: string): void + /** + * Report exhausted program-side output accounting. + * @param evaluation - Active evaluation generation. + */ + outputLimit(evaluation: number): void +} -/** Host → program: the answer to one {@link CallMessage}. */ -export type ReplyMessage = - | { type: 'reply'; id: number; ok: true; value: PtcJsonWire } - | { type: 'reply'; id: number; ok: false; message: string } +/** Operations implemented by either the per-call child or retained REPL. */ +export interface ProgramMethods { + /** + * Evaluate code with the supplied binding declarations and output budget. + * @param evaluation - Generation carried by all callbacks during this evaluation. + * @param cwd - Execution-world working directory. + * @param data - Program and injected declarations. + * @returns Completion or a bounded program failure, after captured output is sent. + */ + run(evaluation: number, cwd: string, data: ProgramBootData): Promise +} diff --git a/packages/ptc-runtime/ptc-runtime-node/src/result.ts b/packages/ptc-runtime/ptc-runtime-node/src/result.ts new file mode 100644 index 0000000000..3377b3f492 --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/src/result.ts @@ -0,0 +1,25 @@ +/** Validate program-controlled completions received through birpc. */ +import type { PtcJsonValue, PtcRunFailure } from '@deepseek-ai/dsh-ptc-runtime' +import { decodePtcJsonWire } from './json-wire.ts' + +const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) + +/** + * Decode a remote run result without trusting model-written control traffic. + * @param result - Untrusted result returned by the program process. + * @returns Decoded completion or failure; malformed envelopes throw a protocol error. + */ +export function readProgramResult(result: unknown): { value?: PtcJsonValue; error?: PtcRunFailure } { + if (!record(result)) throw new Error('invalid program result') + if (result.error !== undefined) { + const error = result.error + if (!record(error) || typeof error.message !== 'string' + || (error.kind !== 'exception' && error.kind !== 'invalid-output' && error.kind !== 'output-limit')) { + throw new Error('invalid terminal error') + } + return { error: { kind: error.kind, message: error.message } } + } + if (result.value === undefined) return {} + const value = decodePtcJsonWire(result.value) + return value === undefined ? { error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } } : { value } +} diff --git a/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts b/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts new file mode 100644 index 0000000000..b21e225e3c --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts @@ -0,0 +1,100 @@ +/** birpc over newline-delimited JSON on the bounded private process channel. */ +import { Buffer } from 'node:buffer' +import type { Duplex } from 'node:stream' +import type { PtcJsonValue } from '@deepseek-ai/dsh-ptc-runtime' +import { createBirpc, type BirpcReturn } from 'birpc' +import { jsonValueBytesUpTo } from './output-json.ts' + +const stringify = JSON.stringify +const parse = JSON.parse + +const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) +const toError = (value: unknown): Error => value instanceof Error ? value : new Error(String(value)) + +/** + * Connect typed methods over one control endpoint; execution owns all deadlines. + * @param stream - Owned control endpoint; `$close()` destroys it and rejects outstanding remote calls. + * @param maxMessageBytes - Maximum incoming JSON line and queued outgoing bytes. + * @param functions - Only these own methods may be called by the peer. + * @param eventNames - Remote methods sent as notifications without a response. + * @param failure - Receives the first I/O or malformed-message failure after it closes the endpoint. + * @returns Typed remote calls and birpc lifetime controls. + */ +export function createProcessRpc( + stream: Duplex, + maxMessageBytes: number, + functions: Local, + eventNames: (keyof Remote)[], + failure: (error: Error, kind: 'io' | 'protocol') => void, +): BirpcReturn { + const fail = (error: unknown, kind: 'io' | 'protocol'): void => { + if (rpc.$closed) return + rpc.$close() + failure(toError(error), kind) + } + let receive: ((line: string) => void) | undefined + const rpc = createBirpc(functions, { + timeout: -1, + eventNames, + on: (listener) => { receive = listener }, + off: () => { receive = undefined; stream.destroy() }, + serialize: (message: Record) => { + // JSON drops Error fields; the peer receives only the diagnostic. + const encoded = message.e instanceof Error ? { t: 's', i: message.i, e: { message: message.e.message } } : message + // Measure before materializing the line; the Writable owns already queued bytes. + if (jsonValueBytesUpTo(encoded as PtcJsonValue, maxMessageBytes - stream.writableLength - 1) !== undefined) return `${stringify(encoded)}\n` + fail(new Error(`control output exceeds ${maxMessageBytes} queued bytes`), 'protocol') + return '' + }, + // A binding can settle after the endpoint closes. + post: (line: string) => { if (!rpc.$closed) stream.write(line) }, + deserialize: (line: string) => { + const message: unknown = parse(line) + if (!record(message)) throw new Error('invalid RPC frame') + if (message.t === 'q') { + if (typeof message.m !== 'string' || !Object.hasOwn(functions, message.m) || !Array.isArray(message.a)) throw new Error('invalid RPC method call') + } else if (message.t !== 's') { + throw new Error('unknown RPC message') + } else if (message.e !== undefined) { + if (!record(message.e) || typeof message.e.message !== 'string') throw new Error('invalid RPC error') + return { ...message, e: new Error(message.e.message) } + } + return message + }, + // Every line of a chunk dispatches before an earlier handler can close the endpoint. + resolver: (_name, fn) => async (...args: unknown[]) => { + if (rpc.$closed) return undefined + // birpc treats a falsy thrown value as success. + try { return await fn.apply(functions, args) } catch (error: unknown) { throw toError(error) } + }, + }) + const decoder = new TextDecoder('utf-8', { fatal: true }) + let parts: Buffer[] = [] + let size = 0 + const buffer = (part: Buffer): void => { + size += part.length + if (size > maxMessageBytes) throw new Error(`RPC message exceeds ${maxMessageBytes} bytes`) + parts.push(part) + } + stream.on('data', (chunk: Buffer) => { + try { + let start = 0 + for (let end = chunk.indexOf(10); end !== -1; end = chunk.indexOf(10, start)) { + buffer(chunk.subarray(start, end)) + const line = decoder.decode(Buffer.concat(parts, size)) + parts = [] + size = 0 + start = end + 1 + void Promise.resolve(receive?.(line)).catch((error: unknown) => { fail(error, 'protocol') }) + } + buffer(chunk.subarray(start)) + } catch (error: unknown) { fail(error, 'protocol') } + }) + stream.on('end', () => { + if (size === 0) fail(new Error('control channel ended before the program settled'), 'io') + else fail(new Error('incomplete RPC message'), 'protocol') + }) + stream.on('error', (error: Error) => { fail(error, 'io') }) + stream.on('close', () => { fail(new Error('control channel closed'), 'io') }) + return rpc +} diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/bootstrap.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/bootstrap.spec.ts index bd8e333ef0..9e3cff5a3f 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/bootstrap.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/bootstrap.spec.ts @@ -1,71 +1,23 @@ -import { describe, expect, it } from 'vitest' -import { EventEmitter } from 'node:events' -import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runProgram, wireReplies } from '../src/bootstrap.ts' -import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts' -import type { ReplyMessage, ProgramToHost } from '../src/protocol.ts' +import { describe, expect, it, vi } from 'vitest' +import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runProgram } from '../src/bootstrap.ts' +import type { ProgramHost, PatchableStream } from '../src/bootstrap.ts' import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts' -/** - * An in-process stand-in for the worker's parentPort: the test plays the - * HOST side — inspect what the bootstrap posted, feed replies back — so - * every line of worker-side logic runs under coverage without spawning an - * isolate (real-worker behavior is pinned by runtime.spec.ts). - */ -class FakePort implements BootstrapPort { - sent: ProgramToHost[] = [] - private readonly emitter = new EventEmitter() - /** Host-scripted responder; return undefined to leave the call pending. */ - respond: (message: ProgramToHost) => ReplyMessage | undefined = () => undefined - - postMessage(message: ProgramToHost): void { - this.sent.push(message) - const reply = this.respond(message) - if (reply) queueMicrotask(() => this.emitter.emit('message', reply)) - } - - on(event: 'message', listener: (message: ReplyMessage) => void): void { - this.emitter.on(event, listener) - } - - deliver(message: ReplyMessage): void { - this.emitter.emit('message', message) - } - - logs(): string[] { - return this.sent.filter(message => message.type === 'log').map(message => message.text) - } - - done(): ProgramToHost | undefined { - return this.sent.find(message => message.type === 'done') - } - - doneValue(): unknown { - const done = this.done() - return done?.type === 'done' && done.value !== undefined ? decodePtcJsonWire(done.value) : undefined +function host(): ProgramHost & { logs: string[]; limits: number } { + const calls = { + logs: [] as string[], limits: 0, + invoke: vi.fn(async () => encodePtcJsonWire(42)), + log: (text: string) => { calls.logs.push(text) }, + outputLimit: () => { calls.limits += 1 }, } + return calls } - function fakeStreams(): { stdout: PatchableStream; stderr: PatchableStream } { return { stdout: { write: () => true }, stderr: { write: () => true } } } - -/** Capture one promise rejection without Vitest's intentionally `any` matcher channel. */ -async function rejectionOf(promise: Promise): Promise { - try { - await promise - return undefined - } catch (error: unknown) { - return error - } -} - const BOOT = { maxOutputBytes: 65_536 } const TOOL_ERROR_CLASS = { name: 'ToolCallError', memberNameProperty: 'toolName' } as const - -/** One worker declaration for the PTC mode tools namespace. */ -function toolNamespace(names: string[]) { - return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS } -} +function toolNamespace(names: string[]) { return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS } } describe('LogBuffer', () => { it('streams entries to the sink until the byte budget, then emits one fitting prefix and reports the limit once', () => { @@ -204,241 +156,98 @@ describe('prepareException', () => { }) describe('makeNamespaces', () => { - it('rejects a malformed success reply instead of resolving a lossy binding value', async () => { - const port = new FakePort() - const pending = new Map() - wireReplies(port, pending) - const result = new Promise((resolve, reject) => { pending.set(1, { resolve, reject }) }) - port.deliver({ type: 'reply', id: 1, ok: true, value: [undefined] as never }) - await expect(result).rejects.toThrow('binding resolution must be lossless JSON') - }) - - it('exposes prototype-colliding names as ordinary own properties', async () => { - const port = new FakePort() - port.respond = message => message.type === 'call' - ? { type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire(`${message.name}-ok`) } - : undefined - const pending = new Map() - wireReplies(port, pending) - const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['__proto__', 'constructor', 'toString'] }] }, port, pending, { value: 1 }) as [Record Promise>] + it('rejects malformed remote results and keeps prototype-colliding function names callable', async () => { + const remote = host() + remote.invoke = vi.fn(async (_global, name) => name === 'bad' ? [undefined] as never : encodePtcJsonWire(`${name}-ok`)) + const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['bad', '__proto__', 'constructor', 'toString'] }] }, remote) as [Record Promise>] expect(Object.getPrototypeOf(tools)).toBeNull() - await expect(tools['__proto__']?.({})).resolves.toBe('__proto__-ok') - await expect(tools['constructor']?.({})).resolves.toBe('constructor-ok') - await expect(tools['toString']?.({})).resolves.toBe('toString-ok') + for (const name of ['__proto__', 'constructor', 'toString']) await expect(tools[name]?.({})).resolves.toBe(`${name}-ok`) + await expect(tools.bad?.({})).rejects.toThrow('binding resolution must be lossless JSON') }) - it('rejects a postMessage clone failure without leaking the pending entry', async () => { - let firstCall = true - const throwingPort: BootstrapPort = { - // First call throws an Error (the real DataCloneError shape), the - // second a bare string — the rejection renders both. - postMessage: () => { - if (firstCall) { firstCall = false; throw new Error('DataCloneError-ish') } - throw 'raw-clone-failure' - }, - on: () => {}, - } - const pending = new Map() + it('uses the declared constructor for remote and argument failures', async () => { + const remote = host() const data = { namespaces: [toolNamespace(['x'])] } - const errorClasses = makeBindingErrorClasses(data) - const ToolCallError = errorClasses.get('tools') - const [tools] = makeNamespaces( - data, - throwingPort, - pending, - { value: 1 }, - errorClasses, - ) as [Record Promise>] - const first = await rejectionOf(tools.x?.({ first: true }) ?? Promise.resolve()) - const second = await rejectionOf(tools.x?.({ second: true }) ?? Promise.resolve()) - expect(first).toMatchObject({ name: 'ToolCallError', toolName: 'x' }) - expect(second).toMatchObject({ name: 'ToolCallError', toolName: 'x' }) - expect(first).toBeInstanceOf(ToolCallError) - expect(second).toBeInstanceOf(ToolCallError) - expect((first as Error).message).toMatch(/DataCloneError-ish/) - expect((second as Error).message).toMatch(/raw-clone-failure/) - expect(pending.size).toBe(0) - }) - - it('rejects lossy arguments before posting or allocating a call id', async () => { - let posts = 0 - const port: BootstrapPort = { postMessage: () => { posts += 1 }, on: () => {} } - const pending = new Map() - const nextId = { value: 1 } - const [tools] = makeNamespaces( - { namespaces: [toolNamespace(['x'])] }, port, pending, nextId, - ) as [Record Promise>] + const classes = makeBindingErrorClasses(data) + const [tools] = makeNamespaces(data, remote, classes) as [Record Promise>] const decorated = [1] Object.defineProperty(decorated, 'extra', { value: true }) - const throwing = Object.defineProperty({}, 'value', { - enumerable: true, - get: () => { throw new Error('getter exploded') }, - }) - + const throwing = Object.defineProperty({}, 'value', { enumerable: true, get() { throw new Error('getter failed') } }) for (const value of [() => 1, new Date(), decorated, throwing]) { - const failure = await rejectionOf(tools.x?.(value) ?? Promise.resolve()) - expect(failure).toMatchObject({ - name: 'ToolCallError', toolName: 'x', message: 'binding arguments must be lossless JSON', - }) + await expect(tools.x?.(value)).rejects.toMatchObject({ name: 'ToolCallError', toolName: 'x', message: 'binding arguments must be lossless JSON' }) + } + expect(vi.mocked(remote.invoke).mock.calls).toEqual([]) + for (const failure of [new Error('denied'), 'raw failure']) { + remote.invoke = async () => { throw failure } + const result = tools.x?.({}) + await expect(result).rejects.toBeInstanceOf(classes.get('tools')) + await expect(result).rejects.toMatchObject({ name: 'ToolCallError', toolName: 'x', message: failure instanceof Error ? failure.message : failure }) } - expect(posts).toBe(0) - expect(pending.size).toBe(0) - expect(nextId.value).toBe(1) }) - it('uses ordinary Error for non-tools namespace failures', async () => { - const deniedPort = new FakePort() - deniedPort.respond = message => message.type === 'call' - ? { type: 'reply', id: message.id, ok: false, message: 'helper denied' } - : undefined - const deniedPending = new Map() - wireReplies(deniedPort, deniedPending) - const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, deniedPort, deniedPending, { value: 1 }) as [Record Promise>] - const denied = await rejectionOf(helpers.x?.({}) ?? Promise.resolve()) - expect(denied).toBeInstanceOf(Error) - expect(denied).toMatchObject({ name: 'Error', message: 'helper denied' }) - expect(denied).not.toHaveProperty('toolName') - - const invalid = await rejectionOf(helpers.x?.(() => 1) ?? Promise.resolve()) - expect(invalid).toBeInstanceOf(Error) - expect((invalid as Error).message).toBe('binding arguments must be lossless JSON') - - const clonePort: BootstrapPort = { postMessage: () => { throw new Error('clone failed') }, on: () => {} } - const [cloneHelpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, clonePort, new Map(), { value: 1 }) as [Record Promise>] - const cloneFailure = await rejectionOf(cloneHelpers.x?.({}) ?? Promise.resolve()) - expect(cloneFailure).toBeInstanceOf(Error) - expect(cloneFailure).not.toHaveProperty('toolName') + it('uses ordinary Error when the namespace declares no rejection class', async () => { + const remote = host() + remote.invoke = async () => { throw new Error('helper denied') } + const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, remote) as [Record Promise>] + await expect(helpers.x?.({})).rejects.toMatchObject({ name: 'Error', message: 'helper denied' }) + await expect(helpers.x?.(() => 1)).rejects.toThrow('binding arguments must be lossless JSON') }) }) describe('runProgram', () => { - it('runs a program end-to-end: bindings, console, return value', async () => { - const port = new FakePort() - port.respond = (message) => { - if (message.type !== 'call') return undefined - const args = decodePtcJsonWire(message.args) as { n: number } - return { type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire(args.n * 2) } + it('returns the completion after streaming logs and invoking host functions', async () => { + const remote = host() + remote.invoke = async (_global, _name, wire) => { + const args = decodePtcJsonWire(wire) as { n: number } + return encodePtcJsonWire(args.n * 2) } - await runProgram(port, { + const result = await runProgram(remote, { ...BOOT, code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };', namespaces: [{ global: 'tools', names: ['double'] }] }, fakeStreams()) + expect(remote.logs).toEqual(['got 42']) + expect(decodePtcJsonWire(result.value)).toEqual({ doubled: 42 }) + }) + + it('reports captured output overflow before completing', async () => { + const remote = host() + const result = await runProgram(remote, { maxOutputBytes: 4, code: 'console.log("12345"); return null', namespaces: [] }, fakeStreams()) + expect(remote.logs).toEqual([]) + expect(remote.limits).toBe(1) + expect(result).toEqual({ error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' } }) + }) + + it.each([ + ['throw new Error("boom")', 'boom'], + ['throw "raw-throw"', 'raw-throw'], + ['const e = new Error("bare"); e.stack = undefined; throw e', 'bare'], + ])('returns bounded program exceptions for %s', async (code, message) => { + const result = await runProgram(host(), { ...BOOT, code, namespaces: [] }, fakeStreams()) + expect(result.error?.kind).toBe('exception') + expect(result.error?.message).toContain(message) + expect(result.value).toBeUndefined() + }) + + it.each(['throw "x".repeat(1_000_000)', 'throw new Error("x".repeat(1_000_000))'])('bounds large diagnostics for %s', async (code) => { + expect(await runProgram(host(), { maxOutputBytes: 64, code, namespaces: [] }, fakeStreams())).toEqual({ error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' } }) + }) + + it.each([ + ['tools', 'ToolCallError', 'toolName'], + ['helpers', 'HelperCallError', 'helperName'], + ])('injects the declared rejection class for %s', async (global, name, memberNameProperty) => { + const remote = host() + remote.invoke = async () => { throw new Error('denied by host') } + const result = await runProgram(remote, { ...BOOT, - code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };', - namespaces: [{ global: 'tools', names: ['double'] }], + code: `try { await ${global}.x({}) } catch (error) { return { caught: error instanceof ${name}, name: error.name, member: error.${memberNameProperty}, message: error.message } }`, + namespaces: [{ global, names: ['x'], errorClass: { name, memberNameProperty } }], }, fakeStreams()) - expect(port.logs()).toEqual(['got 42']) - expect(port.doneValue()).toEqual({ doubled: 42 }) + expect(decodePtcJsonWire(result.value)).toEqual({ caught: true, name, member: 'x', message: 'denied by host' }) }) - it('reports worker-side log capture overflow before completing', async () => { - const port = new FakePort() - await runProgram(port, { - maxOutputBytes: 4, - code: 'console.log("12345"); return null', - namespaces: [], - }, fakeStreams()) - expect(port.logs()).toEqual([]) - expect(port.sent).toContainEqual({ type: 'output-limit' }) - expect(port.done()).toEqual({ - type: 'done', - error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' }, - }) - }) - - it('reports a thrown program error on the done message', async () => { - const port = new FakePort() - await runProgram(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams()) - const done = port.done() - expect(done?.type).toBe('done') - expect(done?.type === 'done' ? done.error?.kind : undefined).toBe('exception') - expect(done?.type === 'done' ? done.error?.message : undefined).toContain('boom') - expect(done?.type === 'done' ? done.value : undefined).toBeUndefined() - }) - - it('renders non-Error throws and stack-less Errors on the done message', async () => { - const rawPort = new FakePort() - await runProgram(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams()) - expect(rawPort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'raw-throw' } }) - - const barePort = new FakePort() - await runProgram(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams()) - expect(barePort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'bare' } }) - }) - - it('replaces giant thrown strings and Error stacks before posting the done message', async () => { - const rawPort = new FakePort() - await runProgram(rawPort, { - maxOutputBytes: 64, - code: 'throw "x".repeat(1_000_000)', - namespaces: [], - }, fakeStreams()) - expect(rawPort.done()).toEqual({ - type: 'done', - error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' }, - }) - - const stackPort = new FakePort() - await runProgram(stackPort, { - maxOutputBytes: 64, - code: 'throw new Error("x".repeat(1_000_000))', - namespaces: [], - }, fakeStreams()) - expect(stackPort.done()).toEqual({ - type: 'done', - error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' }, - }) - }) - - it('surfaces a host failure reply as a program-side rejection it can catch', async () => { - const port = new FakePort() - port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined - await runProgram(port, { - ...BOOT, - code: 'try { await tools.x({}) } catch (error) { return { caught: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }', - namespaces: [toolNamespace(['x'])], - }, fakeStreams()) - expect(port.doneValue()).toEqual({ caught: true, name: 'ToolCallError', toolName: 'x', message: 'denied by host' }) - }) - - it('materializes a consumer-declared rejection class without knowing the namespace', async () => { - const port = new FakePort() - port.respond = message => message.type === 'call' - ? { type: 'reply', id: message.id, ok: false, message: 'helper denied' } - : undefined - await runProgram(port, { - ...BOOT, - code: 'try { await helpers.x({}) } catch (error) { return { caught: error instanceof HelperCallError, name: error.name, helperName: error.helperName, message: error.message } }', - namespaces: [{ - global: 'helpers', - names: ['x'], - errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' }, - }], - }, fakeStreams()) - expect(port.doneValue()).toEqual({ caught: true, name: 'HelperCallError', helperName: 'x', message: 'helper denied' }) - }) - - it('ignores replies for unknown pending ids', async () => { - const port = new FakePort() - port.respond = (message) => { - if (message.type !== 'call') return undefined - // Deliver a stray reply first; the real one follows. - port.deliver({ type: 'reply', id: 9_999, ok: true, value: encodePtcJsonWire('stray') }) - return { type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire('real') } - } - await runProgram(port, { - ...BOOT, - code: 'return await tools.x({})', - namespaces: [{ global: 'tools', names: ['x'] }], - }, fakeStreams()) - expect(port.doneValue()).toBe('real') - }) - - it('captures raw stream writes through the patched process streams', async () => { - const port = new FakePort() + it('leaves the owned output streams connected to their capture sink', async () => { + const remote = host() const streams = fakeStreams() - await runProgram(port, { ...BOOT, code: 'return 1', namespaces: [] }, streams) - streams.stdout.write('never seen — already restored? no: patch persists in worker') - // The patch stays installed for the worker's lifetime; writes during the - // program landed in order. Here the program wrote nothing via streams, so - // only the post-run write above went through the patched slot. - expect(port.logs().at(-1)).toBe('never seen — already restored? no: patch persists in worker') + await runProgram(remote, { ...BOOT, code: 'return 1', namespaces: [] }, streams) + streams.stdout.write('captured') + expect(remote.logs).toEqual(['captured']) }) }) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/channel.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/channel.spec.ts deleted file mode 100644 index 76a31a9358..0000000000 --- a/packages/ptc-runtime/ptc-runtime-node/tests/channel.spec.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Duplex, PassThrough } from 'node:stream' -import { describe, expect, it, onTestFinished } from 'vitest' -import { JsonChannel } from '../src/channel.ts' - -function pair() { - const left = new PassThrough() - const right = new PassThrough() - const a = Duplex.from({ readable: left, writable: right }) - const b = Duplex.from({ readable: right, writable: left }) - // Duplex.from forwards peer destruction as ABORT_ERR; channel owners observe their own failures. - a.on('error', () => {}) - b.on('error', () => {}) - onTestFinished(() => { a.destroy(); b.destroy() }) - return { a, b } -} - -function frame(value: unknown): Buffer { - const body = Buffer.from(JSON.stringify(value)) - const header = Buffer.alloc(4) - header.writeUInt32BE(body.length) - return Buffer.concat([header, body]) -} - -describe('bounded process frames', () => { - it('reassembles split binary headers and UTF-8 bodies', async () => { - const { a, b } = pair() - const received = Promise.withResolvers() - const channel = new JsonChannel(a, 1000, (value) => { received.resolve(value) }, (error) => { received.reject(error) }) - onTestFinished(() => { channel.close() }) - const bytes = frame({ text: '你好🙂' }) - for (const byte of bytes) b.write(Buffer.from([byte])) - expect(await received.promise).toEqual({ text: '你好🙂' }) - }) - - it('accepts consecutive frames and writes complete responses', async () => { - const { a, b } = pair() - const received: unknown[] = [] - const done = Promise.withResolvers() - const channel = new JsonChannel(a, 1000, (value) => { - received.push(value) - if (received.length === 2) done.resolve(undefined) - }, (error) => { done.reject(error) }) - const peer = new JsonChannel(b, 1000, () => {}, (error) => { done.reject(error) }) - onTestFinished(() => { channel.close(); peer.close() }) - await Promise.all([peer.send({ n: 1 }), peer.send({ n: 2 })]) - await done.promise - expect(received).toEqual([{ n: 1 }, { n: 2 }]) - await peer.drain() - }) - - it.each([0, 65])('rejects a declared %i-byte frame before accepting its body', async (length) => { - const { a, b } = pair() - const failure = Promise.withResolvers() - const channel = new JsonChannel(a, 64, () => { throw new Error('must not dispatch') }, (error) => { failure.resolve(error) }) - onTestFinished(() => { channel.close() }) - const header = Buffer.alloc(4) - header.writeUInt32BE(length) - b.write(header) - expect((await failure.promise).message).toContain('control frame') - }) - - it('contains malformed JSON, invalid UTF-8 and receiver failures', async () => { - for (const payload of [Buffer.from('{'), Buffer.from([0xff]), Buffer.from('{}')]) { - const { a, b } = pair() - const failure = Promise.withResolvers() - const channel = new JsonChannel(a, 64, () => { throw new Error('receiver failed') }, (error) => { failure.resolve(error) }) - const header = Buffer.alloc(4) - header.writeUInt32BE(payload.length) - b.write(Buffer.concat([header, payload])) - expect(await failure.promise).toBeInstanceOf(Error) - channel.close() - } - }) - - it('rejects oversized writes and closed channels', async () => { - const { a } = pair() - const channel = new JsonChannel(a, 20, () => {}, () => {}) - await expect(channel.send({ text: 'x'.repeat(30) })).rejects.toThrow('queued bytes') - channel.close() - channel.close() - await expect(channel.send({})).rejects.toThrow('closed') - }) - - it('reports peer EOF while a program is active', async () => { - const { a, b } = pair() - const failure = Promise.withResolvers() - const channel = new JsonChannel(a, 64, () => {}, (error) => { failure.resolve(error) }) - onTestFinished(() => { channel.close() }) - b.end() - expect((await failure.promise).message).toContain('ended') - }) -}) - -it('retains a partial header and partial payload across distinct stream deliveries', () => { - const { a } = pair() - const values: unknown[] = [] - const channel = new JsonChannel(a, 64, (value) => { values.push(value) }, (error) => { throw error }) - const bytes = frame({ value: 1 }) - a.emit('data', bytes.subarray(0, 2)) - a.emit('data', bytes.subarray(2, 6)) - a.emit('data', bytes.subarray(6)) - expect(values).toEqual([{ value: 1 }]) - channel.close() -}) - -it('contains a receiver throwing a non-Error value', () => { - const { a } = pair() - const failures: string[] = [] - const channel = new JsonChannel(a, 64, () => { throw 'receiver failure' }, (error) => { failures.push(error.message) }) - a.emit('data', frame({})) - expect(failures).toEqual(['receiver failure']) - channel.close() -}) - -it('ignores callbacks already captured by an emission when an earlier listener closes the channel', () => { - for (const event of ['data', 'end']) { - const { a } = pair() - let failures = 0 - const channel = new JsonChannel(a, 64, () => { throw new Error('closed channel dispatched') }, () => { failures += 1 }) - a.prependOnceListener(event, () => { channel.close() }) - a.emit(event, frame({})) - a.emit('error', new Error('late closed stream error')) - expect(failures).toBe(0) - } -}) - -it('rejects pending write receipts when the owner closes', async () => { - const { a } = pair() - const channel = new JsonChannel(a, 64, () => {}, () => {}) - const pending = channel.send({ n: 1 }) - channel.close() - await expect(pending).rejects.toThrow('closed') - await channel.drain() -}) - -it('bounds queued frames while a receiver is not reading', async () => { - const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) {} }) - const channel = new JsonChannel(stream, 32, () => {}, () => {}) - const pending = channel.send({ value: '1234567890' }) - await expect(channel.send({ value: '1234567890' })).rejects.toThrow('queued bytes') - const draining = channel.drain() - channel.close() - await expect(pending).rejects.toThrow('closed') - await draining -}) - -it.each(['error', 'close'])('settles a blocked write when the stream emits %s', async (event) => { - const entered = Promise.withResolvers() - const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) { entered.resolve(undefined) } }) - let failure: Error | undefined - const channel = new JsonChannel(stream, 64, () => {}, (error) => { failure = error }) - const pending = channel.send({ value: 1 }) - await entered.promise - stream.emit(event, new Error('transport failed')) - await expect(pending).rejects.toThrow(event === 'error' ? 'transport failed' : 'closed') - if (event === 'error') expect(failure?.message).toBe('transport failed') - channel.close() -}) - - -it.each([new Error('write failed'), 'write failed'])('rejects a synchronous transport write failure: %s', async (failure) => { - const stream = new Duplex({ read() {}, write() { throw failure } }) - const channel = new JsonChannel(stream, 64, () => {}, () => {}) - onTestFinished(() => { channel.close() }) - await expect(channel.send({ value: 1 })).rejects.toThrow('write failed') -}) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts index 7027604bca..4c3f5cc624 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts @@ -11,7 +11,7 @@ import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv } from '@deepseek-ai/dsh-sandbox' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { Config } from '../src/index.ts' -import { JsonChannel } from '../src/channel.ts' +import { rawPeer } from './raw-peer.ts' import { encodePtcJsonWire } from '../src/json-wire.ts' import { mountRuntime } from './setup.ts' @@ -58,11 +58,14 @@ async function setup(config: Config = {}, mode: 'read-only' | 'danger-full-acces waitForExit, } const writes = new Set>() + let runId = '' let receive: (message: unknown) => void = () => {} - const peer = new JsonChannel(childControl, 1024 * 1024, (message) => { + const peer = rawPeer(childControl, (message) => { + if (typeof message === 'object' && message !== null && 't' in message && message.t === 'q' + && 'm' in message && message.m === 'run' && 'i' in message && typeof message.i === 'string') runId = message.i messages.push(message) receive(message) - }, () => {}) + }) const emit = (message: unknown): void => { const write = peer.send(message).catch(() => {}).finally(() => { writes.delete(write) }) writes.add(write) @@ -77,25 +80,26 @@ async function setup(config: Config = {}, mode: 'read-only' | 'danger-full-acces direct.resolve({ exitCode: 0, signal: null }) await Promise.all(writes) }) - const start = (input: PtcRunRequest = request, first: unknown = { type: 'ready' }) => { + const start = (input: PtcRunRequest = request, first: unknown = { t: 'q', m: 'ready', a: [] }) => { const result = runtime.run(runtime.resolve(input)) if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emit(first) }) return result } const onBoot = (callback: () => void): void => { receive = (message) => { - if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'boot') callback() + if (typeof message === 'object' && message !== null && 'm' in message && message.m === 'run') callback() } } return { ctx, runtime, handle, terminate, waitForExit, direct, stdout, stderr, control, peer, messages, spawn, resolveExecutable, emit, start, onBoot, + complete: (result: unknown) => { emit({ t: 's', i: runId, r: result }) }, receive: (callback: typeof receive) => { receive = callback }, } } function call(id: number, value: string = '') { - return { type: 'call', id, global: 'tools', name: 'test', args: encodePtcJsonWire(value) } + return { t: 'q', i: String(id), m: 'invoke', a: [1, 'tools', 'test', encodePtcJsonWire(value)] } } function withBinding(fn: PtcBindingFunction): PtcRunRequest { @@ -117,7 +121,6 @@ describe('Node runtime host failures', () => { [{ maxOutputBytes: 3 }, 'maxOutputBytes'], [{ maxOutputBytes: 4.5 }, 'maxOutputBytes'], [{ maxMessageBytes: 1.5 }, 'maxMessageBytes'], - [{ maxMessageBytes: 0x1_0000_0000 }, 'maxMessageBytes'], [{ maxPendingCalls: 1.5 }, 'maxPendingCalls'], [{ maxOldGenerationSizeMb: 1.5 }, 'maxOldGenerationSizeMb'], [{ launch: { kind: 'node-script', executable: '' } }, 'launch.executable'], @@ -149,14 +152,14 @@ describe('Node runtime host failures', () => { it('keeps partial enforcement separate from a successful program', async () => { const h = await setup({}, 'read-only') vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv])) - h.onBoot(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) }) + h.onBoot(() => { h.complete({ value: encodePtcJsonWire(42) }) }) expect(await h.start()).toEqual({ logs: [], value: 42, sandbox: { mode: 'read-only', denied: false, enforcement: 'partial' } }) }) it.each([['EACCES: blocked', true], ['EPERM: unrelated dialect', false]] as const)('uses only the selected denial dialect for %s', async (message, denied) => { const h = await setup({}, 'read-only') vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv])) - h.onBoot(() => { h.emit({ type: 'done', error: { kind: 'exception', message } }) }) + h.onBoot(() => { h.complete({ error: { kind: 'exception', message } }) }) const result = await h.start() expect(result.error).toEqual({ kind: 'exception', message }) expect(result.sandbox).toEqual({ mode: 'read-only', denied, enforcement: 'partial' }) @@ -298,7 +301,7 @@ describe('Node runtime host failures', () => { it('classifies invalid UTF-8 on the live control stream as a protocol failure', async () => { const h = await setup() - h.onBoot(() => { h.control.push(Buffer.from([0, 0, 0, 1, 0xff])) }) + h.onBoot(() => { h.control.push(Buffer.from([0xff, 10])) }) expect((await h.start()).error?.kind).toBe('protocol') }) @@ -306,35 +309,36 @@ describe('Node runtime host failures', () => { const h = await setup() const binding = vi.fn(async () => null) const result = await h.start(withBinding(binding), call(1)) - expect(result.error?.message).toContain('before bootstrap readiness') + expect(result.error?.message).toContain('active PTC evaluation') expect(binding).not.toHaveBeenCalled() }) it.each<[unknown, string]>([ - [null, 'invalid control frame'], - [{ type: 'log', text: 1 }, 'invalid log frame'], - [{ type: 'done', error: null }, 'invalid terminal error'], - [{ type: 'done', error: { kind: 'exception', message: 1 } }, 'invalid terminal error'], - [{ type: 'done', error: { kind: 'timeout', message: 'forged' } }, 'invalid terminal error'], - [{ type: 'call', id: 0, global: 'tools', name: 'test' }, 'invalid binding call identity'], - [{ ...call(1), name: 'constructor' }, 'undeclared binding'], - [{ type: 'call', id: 1, global: 'tools', name: 'test' }, 'arguments must be lossless JSON'], - [{ type: 'unknown' }, 'unknown control message'], - ])('rejects malformed program frame %j', async (frame, message) => { + [null, 'invalid RPC frame'], + [{ t: 'q', m: 'ready', a: [] }, 'duplicate program readiness'], + [{ t: 'q', m: 'log', a: [1, 1] }, 'invalid program log'], + [{ t: 'q', m: 'outputLimit', a: [0] }, 'invalid program output limit'], + [{ t: 'q', m: 'invoke', a: [1, null, 'test', []], i: 'call' }, 'invalid binding call identity'], + [{ t: 'q', m: 'invoke', a: [0, 'tools', 'test', []], i: 'call' }, 'active PTC evaluation'], + [{ t: 'q', m: 'invoke', a: [1, 'tools', 'constructor', []], i: 'call' }, 'undeclared binding'], + [{ t: 'q', m: 'invoke', a: [1, 'tools', 'test'], i: 'call' }, 'arguments must be lossless JSON'], + [{ t: 'q', m: 'constructor', a: [] }, 'invalid RPC method call'], + [{ t: 'q', m: 'toString', a: [] }, 'invalid RPC method call'], + [{ t: 'unknown' }, 'unknown RPC message'], + ])('rejects malformed program traffic %j', async (frame, message) => { const h = await setup() const binding = vi.fn(async () => null) h.onBoot(() => { h.emit(frame) }) const result = await h.start(withBinding(binding)) - expect(result.error).toEqual({ kind: 'protocol', message: expect.stringContaining(message) as unknown }) + expect(result.error?.kind).toBe('protocol') + expect(result.error?.message).toContain(message) expect(binding).not.toHaveBeenCalled() }) - it('refuses a repeated call id instead of dispatching it twice', async () => { + it.each([null, { error: null }, { error: { kind: 'exception', message: 1 } }, { error: { kind: 'timeout', message: 'forged' } }])('rejects malformed run results %j', async (raw) => { const h = await setup() - const binding = vi.fn(async () => null) - h.onBoot(() => { h.emit(call(1)); h.emit(call(1)) }) - expect((await h.start(withBinding(binding))).error?.kind).toBe('protocol') - expect(binding).toHaveBeenCalledOnce() + h.onBoot(() => { h.complete(raw) }) + expect((await h.start()).error?.kind).toBe('protocol') }) it.each([{ maxPendingCalls: 1 }, { maxMessageBytes: 512 }])('bounds unresolved host calls with %j', async (config) => { @@ -351,37 +355,37 @@ describe('Node runtime host failures', () => { it('rejects an untransferable completion instead of returning a substituted value', async () => { const h = await setup() - h.onBoot(() => { h.emit({ type: 'done', value: { invalid: true } }) }) + h.onBoot(() => { h.complete({ value: { invalid: true } }) }) expect((await h.start()).error?.kind).toBe('invalid-output') }) it('returns non-lossless binding resolutions as program-visible binding failures', async () => { const h = await setup() h.receive((message) => { - if (typeof message !== 'object' || message === null || !('type' in message)) return - if (message.type === 'boot') h.emit(call(1)) - if (message.type === 'reply') h.emit({ type: 'done' }) + if (typeof message !== 'object' || message === null || !('t' in message)) return + if ('m' in message && message.m === 'run') h.emit(call(1)) + if (message.t === 's') h.complete({}) }) expect((await h.start(withBinding(async () => Number.NaN))).error).toBeUndefined() - expect(h.messages).toContainEqual({ type: 'reply', id: 1, ok: false, message: expect.stringContaining('lossless JSON') as unknown }) + expect(h.messages).toContainEqual({ t: 's', i: '1', e: { message: 'binding resolution must be lossless JSON' } }) }) it('does not publish a late binding reply after the program has settled', async () => { const h = await setup() const release = Promise.withResolvers() onTestFinished(() => { release.resolve(null) }) - h.onBoot(() => { h.emit(call(1)); h.emit({ type: 'done' }) }) + h.onBoot(() => { h.emit(call(1)); h.complete({}) }) expect((await h.start(withBinding(async () => await release.promise))).error).toBeUndefined() release.resolve(null) await setImmediate() - expect(h.messages).not.toContainEqual(expect.objectContaining({ type: 'reply' })) + expect(h.messages).not.toContainEqual(expect.objectContaining({ t: 's' })) expect(h.control.destroyed).toBe(true) }) it('reports failed managed cleanup even when the program returns successfully', async () => { const h = await setup() vi.mocked(h.waitForExit).mockRejectedValue(new Error('range cannot be observed')) - h.onBoot(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) }) + h.onBoot(() => { h.complete({ value: encodePtcJsonWire(42) }) }) expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'managed process cleanup failed: range cannot be observed' }) }) @@ -397,11 +401,26 @@ describe('Node runtime host failures', () => { expect(result.error).toEqual({ kind: 'protocol', message: 'control output exceeds 256 queued bytes' }) }) + it('keeps an output overflow delivered beside the program result', async () => { + const h = await setup({ maxOutputBytes: 64 }, 'read-only') + vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv])) + h.receive((message) => { + const run = message as { m?: string; i?: string } + if (run.m !== 'run') return + const result = { t: 's', i: run.i, r: { error: { kind: 'exception', message: 'EACCES: blocked' } } } + const log = { t: 'q', m: 'log', a: [1, 'x'.repeat(100)] } + h.control.push(Buffer.from(`${JSON.stringify(result)}\n${JSON.stringify(log)}\n`)) + }) + const result = await h.start() + expect(result.error?.kind).toBe('output-limit') + expect(result.sandbox?.denied).toBe(false) + }) + it('retains admitted logs when the program reports its own output limit', async () => { const h = await setup() h.onBoot(() => { - h.emit({ type: 'log', text: 'retained' }) - h.emit({ type: 'done', error: { kind: 'output-limit', message: 'child ledger exhausted' } }) + h.emit({ t: 'q', m: 'log', a: [1, 'retained'] }) + h.complete({ error: { kind: 'output-limit', message: 'child ledger exhausted' } }) }) const result = await h.start() expect(result.error?.kind).toBe('output-limit') @@ -413,7 +432,7 @@ describe('Node runtime host failures', () => { const cleaning = Promise.withResolvers() vi.mocked(h.terminate).mockImplementation(() => { h.direct.resolve({ exitCode: 0, signal: null }) }) vi.mocked(h.waitForExit).mockImplementation(async () => { cleaning.resolve(undefined); return true }) - h.onBoot(() => { h.emit(failure === undefined ? { type: 'done' } : { type: 'done', error: failure }) }) + h.onBoot(() => { h.complete(failure === undefined ? {} : { error: failure }) }) vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) try { const pending = h.start() @@ -434,7 +453,7 @@ describe('Node runtime host failures', () => { stream.write(Buffer.from([0x82, 0xac])) stream.end(Buffer.from([0xe2])) } - h.emit({ type: 'done' }) + h.complete({}) }) const result = await h.start() expect(result.error).toBeUndefined() @@ -489,7 +508,7 @@ describe('Node runtime host failures', () => { vi.stubEnv('TMP', 'fixture-tmp-second') vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit') - h.onBoot(() => { h.emit({ type: 'done' }) }) + h.onBoot(() => { h.complete({}) }) expect((await h.start()).error).toBeUndefined() const env = h.spawn.mock.calls[0]?.[0].env ?? {} expect(Object.hasOwn(env, 'TEMP')).toBe(false) @@ -501,7 +520,7 @@ describe('Node runtime host failures', () => { it('selects the remote embedded bootstrap without leaking ambient environment', async () => { const h = await setup({ launch: { kind: 'embedded', executable: process.execPath } }) - h.onBoot(() => { h.emit({ type: 'done' }) }) + h.onBoot(() => { h.complete({}) }) expect((await h.start()).error).toBeUndefined() const spec = h.spawn.mock.calls[0]?.[0] expect(spec?.env?.DSH_PTC_RUNTIME_NODE).toBe('1') @@ -516,7 +535,7 @@ it('keeps failed per-call cleanup registered until process observation succeeds' const h = await setup() const session = h.ctx.sessions.create(SessionId('per-call-cleanup-retry')) h.waitForExit.mockRejectedValue(new Error('range cannot be observed')) - h.onBoot(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) }) + h.onBoot(() => { h.complete({ value: encodePtcJsonWire(42) }) }) const result = await h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }) }) expect(result.error?.kind).toBe('worker-exit') setSandboxMode(session, 'read-only') @@ -541,9 +560,9 @@ it('preserves execution context for bindings delivered by an external control st const session = h.ctx.sessions.create(SessionId('external-binding-context')) const booted = Promise.withResolvers() h.receive((raw) => { - const message = raw as { type: string; ok?: boolean; value?: unknown } - if (message.type === 'boot') booted.resolve(undefined) - if (message.type === 'reply') h.emit({ type: 'done', value: message.value }) + const message = raw as { t: string; m?: string; r?: unknown } + if ('m' in message && message.m === 'run') booted.resolve(undefined) + if (message.t === 's') h.complete({ value: message.r }) }) const context = new AsyncLocalStorage() const pending = context.run('binding context', () => h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }), diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts index d0461f716e..59a180c5ce 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts @@ -8,7 +8,7 @@ import type { PtcBindingFunction, PtcRunRequest } from '@deepseek-ai/dsh-ptc-run import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' import type { Config } from '../src/index.ts' import { mountRuntime } from './setup.ts' -import { JsonChannel } from '../src/channel.ts' +import { rawPeer } from './raw-peer.ts' import { encodePtcJsonWire } from '../src/json-wire.ts' const request: PtcRunRequest = { program: '1', bindings: [], stateOwner: {} } @@ -54,11 +54,14 @@ async function setup(config: Config = {}) { waitForExit, } const writes = new Set>() + let runId = '' let receive: (message: unknown) => void = () => {} - const peer = new JsonChannel(childControl, 1024 * 1024, (message) => { + const peer = rawPeer(childControl, (message) => { + if (typeof message === 'object' && message !== null && 't' in message && message.t === 'q' + && 'm' in message && message.m === 'run' && 'i' in message && typeof message.i === 'string') runId = message.i messages.push(message) receive(message) - }, () => {}) + }) const emit = (message: unknown): void => { const write = peer.send(message).catch(() => {}).finally(() => { writes.delete(write) }) writes.add(write) @@ -73,25 +76,25 @@ async function setup(config: Config = {}) { direct.resolve({ exitCode: 0, signal: null }) await Promise.all(writes) }) - const start = (input: PtcRunRequest = request, first: unknown = { type: 'ready' }) => { + const start = (input: PtcRunRequest = request, first: unknown = { t: 'q', m: 'ready', a: [] }) => { const result = runtime.run(runtime.resolve(input)) if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emit(first) }) return result } const onBoot = (callback: () => void): void => { receive = (message) => { - if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'run') callback() + if (typeof message === 'object' && message !== null && 'm' in message && message.m === 'run') callback() } } return { ctx, fiber, runtime, handle, terminate, waitForExit, direct, stdout, stderr, control, peer, messages, spawn, resolveExecutable, emit, start, onBoot, + complete: (result: unknown) => { emit({ t: 's', i: runId, r: result }) }, receive: (callback: typeof receive) => { receive = callback }, } } -function frame(payload: unknown, id = 1) { return { id, payload } } -function call(id = 1, args: unknown = encodePtcJsonWire(null)) { return frame({ type: 'call', id, global: 'tools', name: 'test', args }) } +function call(id = 1, args: unknown = encodePtcJsonWire(null)) { return { t: 'q', i: String(id), m: 'invoke', a: [1, 'tools', 'test', args] } } const bound: PtcRunRequest = { ...request, bindings: [{ global: 'tools', functions: { test: async () => 42 } }] } it.each(['per-call', 'persistent-in-memory'] as const)('cancels a %s call when its Session policy changes before process launch', async (executionMode) => { @@ -100,10 +103,9 @@ it.each(['per-call', 'persistent-in-memory'] as const)('cancels a %s call when i const policy = h.ctx.sandboxPolicy.resolve({ session }) expect(policy.mode).toBe('danger-full-access') h.receive((message) => { - if (typeof message !== 'object' || message === null || !('type' in message)) return - const done = { type: 'done', value: encodePtcJsonWire(1) } - if (message.type === 'run') h.emit(frame(done)) - else if (message.type === 'boot') h.emit(done) + if (typeof message !== 'object' || message === null || !('t' in message)) return + const done = { value: encodePtcJsonWire(1) } + if ('m' in message && message.m === 'run') h.complete(done) }) const pending = h.start({ @@ -126,10 +128,9 @@ it.each(['per-call', 'persistent-in-memory'] as const)('keeps an approved wider setSandboxMode(session, 'read-only') const standing = h.ctx.sandboxPolicy.resolve({ session }) h.receive((message) => { - if (typeof message !== 'object' || message === null || !('type' in message)) return - const done = { type: 'done', value: encodePtcJsonWire(1) } - if (message.type === 'run') h.emit(frame(done)) - else if (message.type === 'boot') h.emit(done) + if (typeof message !== 'object' || message === null || !('t' in message)) return + const done = { value: encodePtcJsonWire(1) } + if ('m' in message && message.m === 'run') h.complete(done) }) const pending = h.start({ program: executionMode === 'per-call' ? 'return 1' : '1', @@ -149,8 +150,8 @@ it.each(['per-call', 'persistent-in-memory'] as const)('keeps a %s call canceled const cleaning = Promise.withResolvers() const cleanup = Promise.withResolvers() h.receive((message) => { - if (typeof message === 'object' && message !== null && 'type' in message - && (message.type === 'run' || message.type === 'boot')) booted.resolve(undefined) + if (typeof message === 'object' && message !== null && 'm' in message + && message.m === 'run') booted.resolve(undefined) }) h.waitForExit.mockImplementation(() => { cleaning.resolve(undefined); return cleanup.promise }) const first = h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }) }) @@ -177,7 +178,7 @@ it.each(['per-call', 'persistent-in-memory'] as const)('keeps a %s call canceled it('rejects a retained call when its provider unloads during earlier policy cleanup', async () => { const h = await setup() const session = h.ctx.sessions.create(SessionId('unloading-admission')) - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) await h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }) }) const cleaning = Promise.withResolvers() const cleanup = Promise.withResolvers() @@ -208,12 +209,12 @@ it('rejects a retained call when its provider unloads during earlier policy clea describe('persistent PTC host protocol', () => { it.each([ null, - { id: 99, payload: {} }, - frame({ type: 'other' }), - frame({ type: 'log', text: 1 }), - frame({ type: 'done', error: { kind: 'other', message: '' } }), - frame({ type: 'done', error: { kind: 'exception', message: 1 } }), - call(2), + { t: 'q', m: 'log', a: [99, 'stale'] }, + { t: 'q', m: 'outputLimit', a: [99] }, + { t: 'q', m: 'other', a: [] }, + { t: 'q', m: 'log', a: [1, 1] }, + { t: 'q', m: 'invoke', a: [1, null, 'test', []], i: 'call' }, + { t: 'q', m: 'invoke', a: [99, 'tools', 'test', encodePtcJsonWire(null)], i: 'call' }, call(1, 'not a wire value'), ])('rejects invalid process traffic %j', async (raw) => { const h = await setup() @@ -223,9 +224,15 @@ describe('persistent PTC host protocol', () => { expect(h.waitForExit).toHaveBeenCalledTimes(1) }) + it.each([{ error: { kind: 'other', message: '' } }, { error: { kind: 'exception', message: 1 } }])('rejects malformed results %j', async (raw) => { + const h = await setup() + h.onBoot(() => { h.complete(raw) }) + expect((await h.start()).error?.kind).toBe('protocol') + }) + it('requires the readiness handshake and all requested pipes', async () => { const h = await setup() - expect((await h.start(request, { type: 'other' })).error?.kind).toBe('protocol') + expect((await h.start(request, { t: 'q', m: 'other', a: [] })).error?.kind).toBe('protocol') const missing = await setup() missing.spawn.mockReturnValue({ ...missing.handle, control: undefined }) expect((await missing.start(request, NO_INITIAL_FRAME)).error?.message).toContain('did not supply') @@ -233,16 +240,16 @@ describe('persistent PTC host protocol', () => { it.each(['exception', 'invalid-output', 'output-limit'])('accepts a terminal %s failure', async (kind) => { const h = await setup() - h.onBoot(() => { h.emit(frame({ type: 'done', error: { kind, message: 'example' } })) }) + h.onBoot(() => { h.complete({ error: { kind, message: 'example' } }) }) expect((await h.start()).error?.kind).toBe(kind) }) it('rejects lossy terminal values and bounds complete results', async () => { const h = await setup() - h.onBoot(() => { h.emit(frame({ type: 'done', value: 'bad' })) }) + h.onBoot(() => { h.complete({ value: 'bad' }) }) expect((await h.start()).error?.kind).toBe('invalid-output') const limited = await setup({ maxOutputBytes: 30 }) - limited.onBoot(() => { limited.emit(frame({ type: 'log', text: 'x'.repeat(100) })) }) + limited.onBoot(() => { limited.emit({ t: 'q', m: 'log', a: [1, 'x'.repeat(100)] }) }) expect((await limited.start()).error?.kind).toBe('output-limit') }) @@ -256,14 +263,14 @@ describe('persistent PTC host protocol', () => { const h = await setup() h.receive((raw) => { const message = raw as Record - if (message.type === 'run') h.emit(call()) - else if (message.type === 'reply') h.emit(frame({ type: 'done', value: encodePtcJsonWire(message.ok === true) })) + if (message.m === 'run') h.emit(call()) + else if (message.t === 's') h.complete({ value: encodePtcJsonWire(message.e === undefined) }) }) const result = await h.start({ ...request, bindings: [{ global: 'tools', functions }] }) expect(result.error).toBeUndefined() - const reply = h.messages.find(raw => (raw as { type: string }).type === 'reply') as { ok: boolean; message?: string } - expect(result.value).toBe(reply.ok) - if (!reply.ok) expect(reply.message).toMatch(/denied|unavailable/) + const reply = h.messages.find(raw => (raw as { t: string }).t === 's') as { e?: { message: string } } + expect(result.value).toBe(reply.e === undefined) + if (reply.e) expect(reply.e.message).toMatch(/denied|unavailable/) } }) @@ -275,7 +282,7 @@ describe('persistent PTC host protocol', () => { expect((await h.start({ ...request, bindings: [{ global: 'tools', functions: { test: () => pending.promise } }] })).error?.kind).toBe('protocol') } finally { pending.resolve(null) } await setImmediate() - expect(h.messages.some(raw => (raw as { type: string }).type === 'reply')).toBe(false) + expect(h.messages.some(raw => (raw as { t: string }).t === 's')).toBe(false) }) it('cleans up after launch failure and cancellation during executable resolution', async () => { @@ -298,19 +305,19 @@ describe('persistent PTC host protocol', () => { h.onBoot(() => { h.stdout.write('stdout') h.stderr.write('stderr') - h.emit(frame({ type: 'done' })) + h.complete({}) }) expect(await h.start()).toMatchObject({ logs: ['stdout', 'stderr'] }) h.stdout.write('idle stdout') h.stderr.write('idle stderr') - h.onBoot(() => { h.emit(frame({ type: 'done' }, 2)) }) + h.onBoot(() => { h.complete({}) }) expect(await h.start(request, NO_INITIAL_FRAME)).toMatchObject({ logs: [] }) expect(h.terminate).not.toHaveBeenCalled() }) it('clears retained resources when final diagnostic accounting detects an overflow', async () => { const h = await setup({ maxOutputBytes: 100 }) - h.onBoot(() => { h.emit(frame({ type: 'done', error: { kind: 'exception', message: 'x'.repeat(1000) } })) }) + h.onBoot(() => { h.complete({ error: { kind: 'exception', message: 'x'.repeat(1000) } }) }) const result = await h.start() expect(result.error?.kind).toBe('output-limit') expect(result.error?.message).toContain('retained variables were cleared') @@ -320,7 +327,7 @@ describe('persistent PTC host protocol', () => { it('keeps the first outcome when raw output overflows beside a terminal frame', async () => { const h = await setup({ maxOutputBytes: 100 }) h.onBoot(() => { - h.emit(frame({ type: 'done', value: encodePtcJsonWire(42) })) + h.complete({ value: encodePtcJsonWire(42) }) queueMicrotask(() => { h.stdout.write('x'.repeat(1000)) }) }) expect((await h.start()).error?.kind).toBe('output-limit') @@ -342,6 +349,13 @@ describe('persistent PTC host protocol', () => { expect(result.error?.message).toContain('fatal output') }) + it('terminates the process when the control pipe fails during an evaluation', async () => { + const h = await setup() + h.onBoot(() => { h.control.emit('error', new Error('control transport failed')) }) + expect((await h.start()).error?.kind).toBe('worker-exit') + expect(h.terminate).toHaveBeenCalled() + }) + it('terminates when a binding result exceeds the control write budget', async () => { const h = await setup({ maxMessageBytes: 1024 }) h.onBoot(() => { h.emit(call()) }) @@ -353,7 +367,7 @@ describe('persistent PTC host protocol', () => { it.each(['exit', 'abort', 'cleanup'] as const)('does not dispatch to an exited idle process during %s handling', async (outcome) => { const h = await setup() - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) await h.start() h.onBoot(() => { h.stdout.end(); h.stderr.end() }) h.direct.resolve({ exitCode: 9, signal: null }) @@ -364,7 +378,9 @@ describe('persistent PTC host protocol', () => { const result = await h.runtime.run(h.runtime.resolve({ ...request, program: '2', signal: controller.signal })) expect(result.error?.kind).toBe(outcome === 'abort' ? 'abort' : 'worker-exit') expect(result.error?.message).toContain(outcome === 'abort' ? 'execution canceled' : outcome === 'cleanup' ? 'exit cleanup failed' : 'exited (9, signal null)') - expect(h.messages).toEqual([expect.objectContaining({ type: 'run', id: 1 })]) + expect(h.messages).toHaveLength(1) + expect(h.messages[0]).toMatchObject({ t: 'q', m: 'run' }) + expect((h.messages[0] as { a: unknown[] }).a[0]).toBe(1) expect(h.spawn).toHaveBeenCalledTimes(1) expect(h.terminate).toHaveBeenCalledTimes(1) expect(h.waitForExit).toHaveBeenCalledTimes(1) @@ -387,7 +403,7 @@ describe('persistent PTC host protocol', () => { it('reports failed managed cleanup and waits for both exit and pipe drain', async () => { const h = await setup() h.waitForExit.mockRejectedValue(new Error('cleanup failed')) - h.onBoot(() => { h.emit(frame({ type: 'output-limit' })) }) + h.onBoot(() => { h.emit({ t: 'q', m: 'outputLimit', a: [1] }) }) expect((await h.start()).error?.message).toContain('cleanup failed') }) @@ -396,7 +412,7 @@ describe('persistent PTC host protocol', () => { { maxOutputBytes: 4, kind: 'output-limit' }, ])('bounds cleanup failure after parse diagnostics overflow a $maxOutputBytes-byte budget', async ({ maxOutputBytes, kind }) => { const h = await setup({ maxOutputBytes }) - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) expect(await h.start()).toMatchObject({ logs: [] }) h.waitForExit.mockRejectedValue(new Error('x')) const result = await h.runtime.run(h.runtime.resolve({ ...request, program: 'return 1' })) @@ -415,7 +431,7 @@ describe('persistent PTC host protocol', () => { await expect(h.runtime.run({ ...request, cwd: '.', timeoutMs: null })).rejects.toThrow('resolved inputs') await expect(h.runtime.run({ ...request, cwd: process.cwd(), timeoutMs: Infinity })).rejects.toThrow('resolved inputs') for (const config of [ - { timeoutMs: 0 }, { maxMessageBytes: 0x1_0000_0000 }, { maxOutputBytes: 3 }, { graceMs: 2_147_483_648 }, + { timeoutMs: 0 }, { maxMessageBytes: 1.5 }, { maxOutputBytes: 3 }, { graceMs: 2_147_483_648 }, { launch: { kind: 'node-script', executable: '' } }, { launch: { kind: 'node-script', executable: process.execPath, bootstrapPath: 'relative' } }, ] satisfies Config[]) { @@ -425,7 +441,7 @@ describe('persistent PTC host protocol', () => { it('waits for another environment to stop before reporting a failed cleanup', async () => { const h = await setup() - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) await h.start() const entered: PromiseWithResolvers = Promise.withResolvers() const release: PromiseWithResolvers = Promise.withResolvers() @@ -457,15 +473,15 @@ describe('persistent PTC host protocol', () => { it('rejects corrupt framed traffic before it reaches the evaluator', async () => { const h = await setup() - h.onBoot(() => { h.control.emit('data', Buffer.from([0, 0, 0, 1, 255])) }) + h.onBoot(() => { h.control.emit('data', Buffer.from([255, 10])) }) expect((await h.start()).error?.kind).toBe('protocol') }) it('discards unsolicited idle traffic and awaits the retained process exit', async () => { const h = await setup() - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) expect((await h.start()).error).toBeUndefined() - h.emit(frame({ type: 'log', text: 'late' })) + h.emit({ t: 'q', m: 'log', a: [1, 'late'] }) await h.direct.promise expect(h.terminate).toHaveBeenCalledTimes(1) }) @@ -475,7 +491,7 @@ it.each(['owner release', 'provider unload'])('keeps idle-crash cleanup tracked const cleanup = Promise.withResolvers() const stopped = Promise.withResolvers() h.waitForExit.mockImplementation(async () => { cleanup.resolve(true); return stopped.promise }) - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) let closing: Promise | undefined try { await h.start() @@ -496,11 +512,41 @@ it.each(['owner release', 'provider unload'])('keeps idle-crash cleanup tracked } finally { stopped.resolve(true); await closing } }) +it('logs unexpected idle readiness and its failed cleanup', async () => { + const h = await setup() + const reported = vi.spyOn(h.fiber.ctx.logger, 'warn').mockImplementation(() => {}) + try { + h.onBoot(() => { h.complete({}) }) + await h.start() + h.waitForExit.mockRejectedValueOnce(new Error('idle cleanup failed')) + h.emit({ t: 'q', m: 'ready', a: [] }) + await vi.waitFor(() => { expect(reported).toHaveBeenCalledTimes(2) }) + expect(reported.mock.calls).toEqual([['unexpected persistent PTC readiness'], ['idle cleanup failed']]) + } finally { reported.mockRestore() } +}) + +it('does not log a launch failure after cancellation closed the environment', async () => { + const h = await setup() + const entered = Promise.withResolvers() + const lookup = Promise.withResolvers() + h.resolveExecutable.mockImplementation(() => { entered.resolve(undefined); return lookup.promise }) + const reported = vi.spyOn(h.fiber.ctx.logger, 'warn').mockImplementation(() => {}) + try { + const controller = new AbortController() + const result = h.start({ ...request, signal: controller.signal }, NO_INITIAL_FRAME) + await entered.promise + controller.abort() + lookup.reject(new Error('lookup aborted')) + expect((await result).error?.kind).toBe('abort') + expect(reported).not.toHaveBeenCalled() + } finally { reported.mockRestore() } +}) + it('logs an idle process failure with its bounded stderr tail', async () => { const h = await setup({ maxOutputBytes: 100 }) const reported = vi.spyOn(h.fiber.ctx.logger, 'warn').mockImplementation(() => {}) try { - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) await h.start() h.stdout.end() h.stderr.end('x'.repeat(200) + 'idle fatal diagnostic') @@ -513,7 +559,7 @@ it('logs an idle process failure with its bounded stderr tail', async () => { it('retries process observation before releasing an owner after cleanup fails', async () => { const h = await setup() - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) await h.start() const failure = new Error('release cleanup failed') h.waitForExit.mockRejectedValue(failure) @@ -533,7 +579,7 @@ it('reserves the owner while parse-output overflow releases its environment', as const h = await setup({ maxOutputBytes: 32 }) const cleanup = Promise.withResolvers() const stopped = Promise.withResolvers() - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) await h.start() h.waitForExit.mockImplementation(async () => { cleanup.resolve(true); return stopped.promise }) const pending = h.runtime.run(h.runtime.resolve({ ...request, program: 'return 1' })) @@ -582,7 +628,7 @@ it('reports a sandbox runner refusal before the REPL is ready', async () => { it('releases a confirmed exited process even when its output pipe did not drain', async () => { const h = await setup() - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) await h.start() h.stdout.destroy() const warn = vi.spyOn(h.ctx.logger, 'warn').mockImplementation(() => {}) @@ -596,8 +642,8 @@ it('releases a confirmed exited process even when its output pipe did not drain' it('preserves logs in a result-level failure when temporary process pipes do not drain', async () => { const h = await setup() h.onBoot(() => { - h.emit(frame({ type: 'log', text: 'captured before cleanup' })) - h.emit(frame({ type: 'done', value: encodePtcJsonWire(42) })) + h.emit({ t: 'q', m: 'log', a: [1, 'captured before cleanup'] }) + h.complete({ value: encodePtcJsonWire(42) }) h.stdout.destroy() }) const result = await h.start({ program: '1', bindings: [] }) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts index 882cdfc8b5..2a991aa9ef 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts @@ -1,9 +1,9 @@ import { Duplex, PassThrough } from 'node:stream' import { expect, it, onTestFinished } from 'vitest' -import { JsonChannel } from '../src/channel.ts' +import { createProcessRpc } from '../src/rpc.ts' import { runPersistentMain, type PersistentProcess } from '../src/persistent-process.ts' import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts' -import type { ProgramBootData } from '../src/protocol.ts' +import type { ProgramBootData, HostMethods, ProgramMethods } from '../src/protocol.ts' async function setup(limit = 4096, chdir: (cwd: string) => void = () => {}) { const first = new PassThrough() @@ -15,31 +15,31 @@ async function setup(limit = 4096, chdir: (cwd: string) => void = () => {}) { const state: PersistentProcess = { env: { FIXTURE_SECRET: 'test', PATH: '/native' }, stdout: { write: () => true }, stderr: { write: () => true }, chdir } const nativeEnvironment = state.env const ready = Promise.withResolvers() - const pending = new Map>>>() const seen: Record[] = [] let sequence = 0 - let onCall: (id: number, payload: Record) => void = (_id, payload) => { - void peer.send({ type: 'reply', id: payload.id, ok: true, value: encodePtcJsonWire(42) }) + const methods: HostMethods = { + ready: () => { ready.resolve(true) }, + invoke: async (id, global, name, args) => { + seen.push({ type: 'call', id, global, name, args }) + return encodePtcJsonWire(42) + }, + log: (id, text) => { seen.push({ type: 'log', id, text }) }, + outputLimit: (id) => { seen.push({ type: 'output-limit', id }) }, } - const peer = new JsonChannel(host, limit, (raw) => { - const message = raw as { type?: string; id: number; payload: Record } - if (message.type === 'ready') { ready.resolve(true); return } - seen.push(message.payload) - if (message.payload.type === 'call') onCall(message.id, message.payload) - if (message.payload.type === 'done') pending.get(message.id)?.resolve(message.payload) - }, () => {}) + const peer = createProcessRpc(host, limit, methods, [], () => {}) const main = runPersistentMain(child, limit, state) - const close = async () => { peer.close(); host.destroy(); child.destroy(); await main } + const close = async () => { peer.$close(); host.destroy(); child.destroy(); await main } onTestFinished(close) await ready.promise const run = (code: string, namespaces: ProgramBootData['namespaces'] = [], maxOutputBytes = 1024) => { const id = ++sequence - const result = Promise.withResolvers>() - pending.set(id, result) - void peer.send({ type: 'run', id, cwd: '/', data: { code, namespaces, maxOutputBytes } }) - return result.promise + return peer.run(id, '/', { code, namespaces, maxOutputBytes }).then((result) => { + seen.push({ type: 'done', id, ...result }) + return result + }) } - return { state, nativeEnvironment, run, seen, peer, main, close, host, setCall: (handler: typeof onCall) => { onCall = handler } } + + return { state, nativeEnvironment, run, seen, peer, main, close, host, setCall: (handler: HostMethods['invoke']) => { methods.invoke = handler } } } it('keeps an injected process local to the REPL while it runs and after it closes', async () => { @@ -60,7 +60,7 @@ it('keeps an injected process local to the REPL while it runs and after it close it('keeps an environment while capturing each evaluation and binding replies separately', async () => { const h = await setup() const declarations = [{ global: 'tools', names: ['echo'], errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' } }] - expect(await h.run('let count = 1; const saved = tools.echo;', declarations)).toEqual({ type: 'done' }) + expect(await h.run('let count = 1; const saved = tools.echo;', declarations)).toEqual({}) const result = await h.run('count++; process.stdout.write("raw"); console.log("value", count); await saved({})', declarations) expect(decodePtcJsonWire(result.value)).toBe(42) expect(h.seen.filter(item => item.type === 'log').map(item => item.text)).toEqual(['raw', 'value 2']) @@ -107,16 +107,23 @@ it('contains directory failures and output overflow in the active evaluation', a expect(h.seen.some(item => item.type === 'output-limit')).toBe(true) }) -it('refuses overlapping host evaluations and settles transport shutdown', async () => { +it('rejects overlapping host evaluations without replacing the active run', async () => { const h = await setup() - h.setCall(() => { void h.peer.send({ type: 'run', id: 2, cwd: '/', data: { code: '1', namespaces: [], maxOutputBytes: 1000 } }) }) - void h.run('await tools.echo({})', [{ global: 'tools', names: ['echo'] }]) - await h.main + const entered = Promise.withResolvers() + const release = Promise.withResolvers>() + h.setCall(() => { entered.resolve(undefined); return release.promise }) + const running = h.run('await tools.echo({})', [{ global: 'tools', names: ['echo'] }]) + try { + await entered.promise + await expect(h.run('1')).rejects.toThrow('already has an active evaluation') + release.resolve(encodePtcJsonWire(42)) + expect(decodePtcJsonWire((await running).value)).toBe(42) + } finally { release.resolve(encodePtcJsonWire(null)); await running } }) it('closes the process channel when a terminal result cannot fit its write budget', async () => { const h = await setup(1024) - void h.run('"x".repeat(2000)', [], 8000) + await expect(h.run('"x".repeat(2000)', [], 8000)).rejects.toThrow() await h.main }) @@ -128,17 +135,19 @@ it('ignores output and rejects tool calls from a completed evaluation', async () expect(decodePtcJsonWire(result.value)).toContain('evaluation has ended') expect(h.seen.some(item => item.type === 'log' || item.type === 'call')).toBe(false) expect(decodePtcJsonWire((await h.run('await tools.echo({})', declarations)).value)).toBe(42) - expect(h.seen.find(item => item.type === 'call')?.id).toBe(1) + expect(h.seen.find(item => item.type === 'call')?.id).toBe(3) }) it('rejects unfinished binding waits when a program returns', async () => { const h = await setup() - h.setCall(() => {}) + const pending = Promise.withResolvers>() + onTestFinished(() => { pending.resolve(encodePtcJsonWire(null)) }) + h.setCall(() => pending.promise) await h.run('let rejected; void tools.echo({}).catch(error => { rejected = error.message });', [{ global: 'tools', names: ['echo'] }]) expect(decodePtcJsonWire((await h.run('rejected')).value)).toContain('ended before the binding completed') }) -it.each([0, 1.5, 4294967296])('rejects an invalid control limit %s', async (limit) => { +it.each([0, 1.5])('rejects an invalid control limit %s', async (limit) => { const stream = new PassThrough() try { await expect(runPersistentMain(stream, limit, { env: {}, stdout: { write: () => true }, stderr: { write: () => true }, chdir: () => {} })).rejects.toThrow('invalid control message limit') diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/process-main.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/process-main.spec.ts index a62e9ca3a3..73e80ab60a 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/process-main.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/process-main.spec.ts @@ -1,9 +1,9 @@ import { Duplex, PassThrough } from 'node:stream' import { setImmediate as nextTurn } from 'node:timers/promises' -import { expect, it, onTestFinished } from 'vitest' -import { JsonChannel } from '../src/channel.ts' -import { runNodeMain } from '../src/process.ts' -import type { ProgramProcess } from '../src/process.ts' +import { expect, it, onTestFinished, vi } from 'vitest' +import { createProcessRpc } from '../src/rpc.ts' +import { runNodeMain, type ProgramProcess } from '../src/process.ts' +import type { HostMethods, ProgramMethods } from '../src/protocol.ts' import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts' function endpoints() { @@ -18,126 +18,113 @@ function endpoints() { } function processState(): ProgramProcess { return { env: { FIXTURE_SECRET: 'test' }, stdout: { write: () => true }, stderr: { write: () => true }, exitCode: undefined } } -it('clears process environment, dispatches a binding reply and flushes the terminal frame', async () => { +async function setup(limit = 4096) { const { child, host } = endpoints() const state = processState() const nativeEnvironment = state.env - nativeEnvironment.SystemRoot = 'C:\\Windows' - nativeEnvironment.PATH = '/native/bin' - nativeEnvironment.TMP = 'C:\\sandbox-temp' - nativeEnvironment.TEMP = 'C:\\sandbox-temp' - const messages: Record[] = [] - const peer = new JsonChannel(host, 4096, (raw) => { - const message = raw as Record - messages.push(message) - if (message.type === 'ready') void peer.send({ type: 'boot', data: { code: 'console.log("ready"); return await tools.echo({});', namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes: 1024 } }) - if (message.type === 'call') void peer.send({ type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire(42) }) - if (message.type === 'done') host.end() - }, () => {}) - onTestFinished(() => { peer.close() }) - await runNodeMain(child, 4096, state) - expect(state.env).toEqual({}) - expect(state.env).not.toBe(nativeEnvironment) - expect(Object.getPrototypeOf(state.env)).toBeNull() - expect(nativeEnvironment).toEqual({ SystemRoot: 'C:\\Windows', PATH: '/native/bin', TMP: 'C:\\sandbox-temp', TEMP: 'C:\\sandbox-temp' }) - expect(state.exitCode).toBeUndefined() - expect(decodePtcJsonWire(messages.find(message => message.type === 'done')?.value)).toBe(42) -}) - -it('keeps the control pipe open for a late binding reply until the host closes it', async () => { - const { child, host } = endpoints() - const state = processState() - const terminal = Promise.withResolvers() - const messages: string[] = [] - let callId: unknown - const peer = new JsonChannel(host, 4096, (raw) => { - const message = raw as Record - messages.push(String(message.type)) - if (message.type === 'ready') void peer.send({ type: 'boot', data: { - code: 'void tools.echo({}).then(() => { throw new Error("late reply resumed the program") }); setImmediate(() => console.log("late timer")); return 42;', - namespaces: [{ global: 'tools', names: ['echo'] }], - maxOutputBytes: 1024, - } }) - if (message.type === 'call') callId = message.id - if (message.type === 'done') terminal.resolve(decodePtcJsonWire(message.value)) - }, (error) => { terminal.reject(error) }) - const main = runNodeMain(child, 4096, state) - try { - expect(await terminal.promise).toBe(42) - // Settle the terminal write callbacks while the host still owns the open pipe. - await nextTurn() - expect(child.destroyed).toBe(false) - await peer.send({ type: 'reply', id: callId, ok: true, value: encodePtcJsonWire(42) }) - await nextTurn() - expect(messages).toEqual(['ready', 'call', 'done']) - } finally { - peer.close() - await main + Object.assign(nativeEnvironment, { SystemRoot: 'C:\\Windows', PATH: '/native/bin', TMP: 'C:\\sandbox-temp', TEMP: 'C:\\sandbox-temp' }) + const ready = Promise.withResolvers() + const logs: string[] = [] + const methods: HostMethods = { + ready: () => { ready.resolve(undefined) }, + invoke: async () => encodePtcJsonWire(42), + log: (_id, text) => { logs.push(text) }, + outputLimit: () => {}, } - expect(state.exitCode).toBeUndefined() + const peer = createProcessRpc(host, limit, methods, [], () => {}) + const main = runNodeMain(child, limit, state) + onTestFinished(async () => { peer.$close(); child.destroy(); await main }) + await ready.promise + const run = (code: string, maxOutputBytes = 1024) => peer.run(1, '/', { + code, namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes, + }) + return { child, host, state, nativeEnvironment, methods, peer, main, logs, run } +} + +it('clears the environment and returns a binding result after captured output', async () => { + const h = await setup() + const result = await h.run('console.log("ready"); return await tools.echo({});') + expect(decodePtcJsonWire(result.value)).toBe(42) + expect(h.logs).toEqual(['ready']) + expect(h.state.env).toEqual({}) + expect(h.state.env).not.toBe(h.nativeEnvironment) + expect(Object.getPrototypeOf(h.state.env)).toBeNull() + expect(h.nativeEnvironment).toEqual({ SystemRoot: 'C:\\Windows', PATH: '/native/bin', TMP: 'C:\\sandbox-temp', TEMP: 'C:\\sandbox-temp' }) + h.peer.$close() + await h.main + expect(h.state.exitCode).toBeUndefined() }) -it.each([0, -1, 1.5, 4294967296])('rejects an invalid bootstrap frame limit %i', async (limit) => { +it('rejects unfinished calls and suppresses later logs while awaiting host shutdown', async () => { + const h = await setup() + const release = Promise.withResolvers>() + h.methods.invoke = () => release.promise + try { + expect(decodePtcJsonWire((await h.run('void tools.echo({}).catch(() => console.log("late rejection")); setImmediate(() => console.log("late timer")); return 42;')).value)).toBe(42) + await nextTurn() + expect(h.child.destroyed).toBe(false) + release.resolve(encodePtcJsonWire(42)) + await nextTurn() + expect(h.logs).toEqual([]) + } finally { release.resolve(encodePtcJsonWire(null)) } +}) + +it('reports exhausted output accounting during the run', async () => { + const h = await setup() + const limited = vi.fn() + h.methods.outputLimit = limited + expect((await h.run('console.log("x".repeat(100)); return 1', 64)).error?.kind).toBe('output-limit') + await vi.waitFor(() => { expect(limited).toHaveBeenCalledOnce() }) +}) + +it('sends no binding calls or output notifications after returning its result', async () => { + const h = await setup() + const invoked = vi.fn(async () => encodePtcJsonWire(1)) + const limited = vi.fn() + h.methods.invoke = invoked + h.methods.outputLimit = limited + const result = await h.run('setImmediate(() => { void tools.echo({}).catch(() => {}); console.log("x".repeat(2000)) }); return 1', 64) + expect(decodePtcJsonWire(result.value)).toBe(1) + await nextTurn() + await nextTurn() + expect(invoked).not.toHaveBeenCalled() + expect(limited).not.toHaveBeenCalled() + expect(h.logs).toEqual([]) +}) + +it.each([0, -1, 1.5])('rejects an invalid control message limit %s', async (limit) => { const { child } = endpoints() await expect(runNodeMain(child, limit, processState())).rejects.toThrow('invalid control message limit') }) -it('rejects an unexpected first control frame', async () => { - const { child, host } = endpoints() - const peer = new JsonChannel(host, 4096, () => { void peer.send({ type: 'reply' }) }, () => {}) - onTestFinished(() => { peer.close() }) - await expect(runNodeMain(child, 4096, processState())).rejects.toThrow('expected program boot') +it('records malformed host traffic before evaluation as failure', async () => { + const h = await setup() + h.host.write(Buffer.from('null\n')) + await h.main + expect(h.state.exitCode).toBe(1) }) -it('records an I/O failure before the boot frame', async () => { - const { child, host } = endpoints() - const state = processState() - const peer = new JsonChannel(host, 4096, () => { peer.close() }, () => {}) - await expect(runNodeMain(child, 4096, state)).rejects.toBeInstanceOf(Error) - expect(state.exitCode).toBe(1) +it('records I/O failure before evaluation', async () => { + const h = await setup() + h.peer.$close() + await h.main + expect(h.state.exitCode).toBe(1) }) -it('contains program writes that exceed queued control output', async () => { - const { child, host } = endpoints() - const state = processState() - const peer = new JsonChannel(host, 1024, (raw) => { - if ((raw as { type: string }).type === 'ready') void peer.send({ type: 'boot', data: { code: 'for(let i=0;i<30;i++) console.log("x".repeat(100));', namespaces: [], maxOutputBytes: 8000 } }) - }, () => {}) - onTestFinished(() => { peer.close() }) - await runNodeMain(child, 1024, state) - expect(state.exitCode).toBe(1) +it.each([ + 'console.log("x".repeat(2000));', + 'return "x".repeat(2000)', +])('closes on control output overflow: %s', async (code) => { + const h = await setup(1024) + await expect(h.run(code, 8000)).rejects.toThrow() + await h.main + expect(h.state.exitCode).toBe(1) }) -it('records a terminal frame that cannot fit the control write budget as failure', async () => { - const { child, host } = endpoints() - const state = processState() - const peer = new JsonChannel(host, 1024, (raw) => { - if ((raw as { type: string }).type === 'ready') void peer.send({ type: 'boot', data: { - code: 'return "x".repeat(2000)', namespaces: [], maxOutputBytes: 8000, - } }) - }, () => {}) - onTestFinished(() => { peer.close() }) - await runNodeMain(child, 1024, state) - expect(state.exitCode).toBe(1) -}) - -it('retains a malformed host frame failure after the terminal frame', async () => { - const { child, host } = endpoints() - const state = processState() - const terminal = Promise.withResolvers() - const peer = new JsonChannel(host, 1024, (raw) => { - const message = raw as { type: string } - if (message.type === 'ready') void peer.send({ type: 'boot', data: { - code: 'return 42', namespaces: [], maxOutputBytes: 1000, - } }) - if (message.type === 'done') terminal.resolve(undefined) - }, () => {}) - onTestFinished(() => { peer.close() }) - const main = runNodeMain(child, 1024, state) - try { - await terminal.promise - host.write(Buffer.from([0, 0, 0, 1, 0xff])) - await main - expect(state.exitCode).toBe(1) - } finally { peer.close(); await main } +it('records malformed host traffic after completion', async () => { + const h = await setup() + await h.run('return 42') + h.host.write(Buffer.from([0xff, 10])) + await h.main + expect(h.state.exitCode).toBe(1) }) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/process.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/process.spec.ts index 8f4b16f38c..97c7398421 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/process.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/process.spec.ts @@ -1,19 +1,21 @@ import { spawn } from 'node:child_process' -import { copyFile, mkdtemp, rm } from 'node:fs/promises' +import { copyFile, cp, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' import { Duplex } from 'node:stream' import { expect, it, onTestFinished } from 'vitest' -import { JsonChannel } from '../src/channel.ts' +import { createProcessRpc } from '../src/rpc.ts' +import type { HostMethods, ProgramMethods } from '../src/protocol.ts' import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts' it('boots an unbuilt source closure outside the workspace and exchanges tool replies', async () => { const directory = await mkdtemp(join(tmpdir(), 'dsh-node-source-')) onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) }) - for (const file of ['process.ts', 'bootstrap.ts', 'channel.ts', 'json-wire.ts', 'output-json.ts', 'protocol.ts', 'environment.ts']) { + for (const file of ['process.ts', 'bootstrap.ts', 'rpc.ts', 'json-wire.ts', 'output-json.ts', 'protocol.ts', 'environment.ts']) { await copyFile(new URL(`../src/${file}`, import.meta.url), join(directory, file)) } + await cp(dirname(dirname(fileURLToPath(import.meta.resolve('birpc')))), join(directory, 'node_modules/birpc'), { recursive: true }) const source = `import {Socket} from 'node:net';import {runNodeMain} from ${JSON.stringify(pathToFileURL(join(directory, 'process.ts')).href)};await runNodeMain(new Socket({fd:7,readable:true,writable:true}),100000,process);` const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { env: { PLACEHOLDER_SECRET: 'fixture-only' }, @@ -27,24 +29,26 @@ it('boots an unbuilt source closure outside the workspace and exchanges tool rep child.once('exit', (code) => { if (code !== 0) completed.reject(new Error(`child exit ${code}: ${stderr}`)) }) const control = Array.from(child.stdio)[7] if (!(control instanceof Duplex)) { child.kill(); await finished; throw new Error('missing child control channel') } - const channel = new JsonChannel(control, 100_000, (raw) => { - const message = raw as { type: string; id?: number; args?: unknown; value?: unknown; error?: unknown } - if (message.type === 'ready') { - void channel.send({ type: 'boot', data: { + const channel = createProcessRpc(control, 100_000, { + ready() { + void channel.run(1, directory, { code: 'const answer = await tools.echo({ n: 21 }); return { answer, env: { ...process.env } }', namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes: 10_000, - } }).catch((error: unknown) => { completed.reject(error) }) - } else if (message.type === 'call') { - expect(decodePtcJsonWire(message.args)).toEqual({ n: 21 }) - void channel.send({ type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire(42) }).catch((error: unknown) => { completed.reject(error) }) - } else if (message.type === 'done') { - if (message.error !== undefined) completed.reject(new Error(JSON.stringify(message.error))) - else completed.resolve(decodePtcJsonWire(message.value)) - } - }, (error) => { completed.reject(error) }) - onTestFinished(async () => { channel.close(); child.kill(); await finished }) + }).then((result) => { + if (result.error) completed.reject(new Error(result.error.message)) + else completed.resolve(decodePtcJsonWire(result.value)) + }, (error: unknown) => { completed.reject(error) }) + }, + async invoke(_evaluation, _global, _name, args) { + expect(decodePtcJsonWire(args)).toEqual({ n: 21 }) + return encodePtcJsonWire(42) + }, + log() {}, + outputLimit() {}, + }, [], (error) => { completed.reject(error) }) + onTestFinished(async () => { channel.$close(); child.kill(); await finished }) expect(await completed.promise).toEqual({ answer: 42, env: {} }) - channel.close() + channel.$close() await finished }) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/raw-peer.ts b/packages/ptc-runtime/ptc-runtime-node/tests/raw-peer.ts new file mode 100644 index 0000000000..9597508c52 --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/tests/raw-peer.ts @@ -0,0 +1,20 @@ +/** Raw newline JSON peer for testing hostile RPC traffic without birpc validation. */ +import { createInterface } from 'node:readline' +import type { Duplex } from 'node:stream' + +/** + * Observe and forge JSON messages over a test-owned stream. + * @param stream - Test endpoint. + * @param receive - Raw JSON message observer. + * @returns Write receipts and endpoint cleanup. + */ +export function rawPeer(stream: Duplex, receive: (message: unknown) => void) { + const lines = createInterface({ input: stream, crlfDelay: Infinity }) + lines.on('line', (line) => { receive(JSON.parse(line)) }) + return { + send: (message: unknown): Promise => new Promise((resolve, reject) => { + stream.write(`${JSON.stringify(message)}\n`, (error) => { if (error) reject(error); else resolve() }) + }), + close: (): void => { lines.close(); stream.destroy() }, + } +} diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts new file mode 100644 index 0000000000..fe2fb475e5 --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts @@ -0,0 +1,177 @@ +import { Duplex, PassThrough } from 'node:stream' +import { expect, it, onTestFinished, vi } from 'vitest' +import { createProcessRpc } from '../src/rpc.ts' +import { rawPeer } from './raw-peer.ts' + +function endpoints() { + const inbound = new PassThrough() + const outbound = new PassThrough() + const local = Duplex.from({ readable: inbound, writable: outbound }) + const remote = Duplex.from({ readable: outbound, writable: inbound }) + local.on('error', () => {}) + remote.on('error', () => {}) + onTestFinished(() => { local.destroy(); remote.destroy() }) + return { local, remote } +} + +it('matches concurrent bidirectional results and preserves remote rejection messages', async () => { + const { local, remote } = endpoints() + const release = Promise.withResolvers() + const entered = Promise.withResolvers() + const failures = vi.fn() + const left = createProcessRpc<{ slow(): Promise; fail(): never }, { double(value: number): number }>(local, 4096, + { double: value => value * 2 }, [], failures) + const right = createProcessRpc<{ double(value: number): number }, { slow(): Promise; fail(): never }>(remote, 4096, { + slow: () => { entered.resolve(undefined); return release.promise }, + fail: () => { throw new Error('binding denied') }, + }, [], failures) + onTestFinished(() => { release.resolve('cleanup'); left.$close(); right.$close() }) + const pending = left.slow() + await entered.promise + expect(await right.double(21)).toBe(42) + await expect(left.fail()).rejects.toThrow('binding denied') + release.resolve('completed') + expect(await pending).toBe('completed') + expect(failures).not.toHaveBeenCalled() +}) + +it.each([undefined, null, false, 0, '', 'denied'])('rejects a remote call whose method throws %j', async (thrown) => { + const { local, remote } = endpoints() + const left = createProcessRpc<{ fail(): never }, Record>(local, 4096, {}, [], () => {}) + const right = createProcessRpc, { fail(): never }>(remote, 4096, { fail: () => { throw thrown } }, [], () => {}) + onTestFinished(() => { left.$close(); right.$close() }) + await expect(left.fail()).rejects.toThrow(String(thrown)) +}) + +it('does not invoke a method received beside traffic that closed the endpoint', async () => { + const { local, remote } = endpoints() + const failure = Promise.withResolvers() + const allowed = vi.fn() + const endpoint = createProcessRpc(local, 4096, { allowed }, [], (_error, kind) => { failure.resolve(kind) }) + onTestFinished(() => { endpoint.$close() }) + remote.write(`null\n${JSON.stringify({ t: 'q', m: 'allowed', a: [] })}\n`) + expect(await failure.promise).toBe('protocol') + await new Promise(resolve => setImmediate(resolve)) + expect(allowed).not.toHaveBeenCalled() +}) + +it('lets execution own the deadline and rejects outstanding calls when the transport closes', async () => { + const { local, remote } = endpoints() + const received = Promise.withResolvers() + const raw = rawPeer(remote, () => { received.resolve(undefined) }) + const endpoint = createProcessRpc<{ wait(): Promise }, Record>(local, 4096, {}, [], () => {}) + onTestFinished(() => { endpoint.$close(); raw.close() }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const pending = endpoint.wait() + const rejected = expect(pending).rejects.toThrow('rpc is closed') + await received.promise + const settled = vi.fn() + void pending.then(settled, settled) + await vi.advanceTimersByTimeAsync(120_000) + expect(settled).not.toHaveBeenCalled() + endpoint.$close() + await rejected + await expect(endpoint.wait()).rejects.toThrow('rpc is closed') + } finally { vi.useRealTimers() } +}) + +it('sends notifications without allocating response identifiers', async () => { + const { local, remote } = endpoints() + const received = Promise.withResolvers() + const raw = rawPeer(remote, (message) => { received.resolve(message) }) + const endpoint = createProcessRpc<{ log(text: string): void }, Record>(local, 4096, {}, ['log'], () => {}) + onTestFinished(() => { endpoint.$close(); raw.close() }) + await endpoint.log('visible') + expect(await received.promise).toEqual({ t: 'q', m: 'log', a: ['visible'] }) +}) + +it('ignores a response for another call and returns the matching result', async () => { + const { local, remote } = endpoints() + const endpoint = createProcessRpc<{ value(): string }, Record>(local, 4096, {}, [], () => {}) + const raw = rawPeer(remote, (request) => { + const { i } = request as { i: string } + void raw.send({ t: 's', i: 'unrelated', r: 'wrong' }) + void raw.send({ t: 's', i, r: 'right' }) + }) + onTestFinished(() => { endpoint.$close(); raw.close() }) + expect(await endpoint.value()).toBe('right') +}) + +it.each([ + null, + { t: 'q', m: 'constructor', a: [] }, + { t: 'q', m: 'allowed', a: null }, + { t: 's', i: 'call', e: 'unstructured error' }, + { t: 'unknown' }, +])('closes malformed RPC traffic before invoking local methods: %j', async (message) => { + const { local, remote } = endpoints() + const failure = Promise.withResolvers<{ kind: string; error: Error }>() + const allowed = vi.fn() + const endpoint = createProcessRpc(local, 4096, { allowed }, [], (error, kind) => { failure.resolve({ error, kind }) }) + const raw = rawPeer(remote, () => {}) + onTestFinished(() => { endpoint.$close(); raw.close() }) + await raw.send(message) + expect((await failure.promise).kind).toBe('protocol') + expect(endpoint.$closed).toBe(true) + expect(allowed).not.toHaveBeenCalled() +}) + +it('reassembles fragmented UTF-8 and consecutive JSON lines', async () => { + const { local, remote } = endpoints() + const received: string[] = [] + const complete = Promise.withResolvers() + const endpoint = createProcessRpc(local, 1024, { log(text: string) { + received.push(text) + if (received.length === 2) complete.resolve(undefined) + } }, [], (error) => { complete.reject(error) }) + onTestFinished(() => { endpoint.$close() }) + const bytes = Buffer.from(['你好🙂\ninside', 'next'].map(text => JSON.stringify({ t: 'q', m: 'log', a: [text] }) + '\n').join('')) + for (const byte of bytes) remote.write(Buffer.from([byte])) + await complete.promise + expect(received).toEqual(['你好🙂\ninside', 'next']) +}) + +it.each([ + [Buffer.from('{\n'), false], + [Buffer.from([0xff, 10]), false], + [Buffer.from('{'), true], + [Buffer.from([0xe2]), true], + [Buffer.from('x'.repeat(65)), false], +])('rejects invalid or oversized JSON lines before dispatch (%j, EOF %s)', async (bytes, eof) => { + const { local, remote } = endpoints() + const failed = Promise.withResolvers() + const endpoint = createProcessRpc(local, 64, {}, [], (_error, kind) => { failed.resolve(kind) }) + onTestFinished(() => { endpoint.$close() }) + if (eof) remote.end(bytes) + else remote.write(bytes) + expect(await failed.promise).toBe('protocol') +}) + +it('bounds the native writable queue and rejects all outstanding calls on overflow', async () => { + const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) {} }) + const failed = Promise.withResolvers() + const endpoint = createProcessRpc<{ invoke(value: string): Promise }, Record>(stream, 256, {}, [], (error) => { + failed.resolve(error) + }) + onTestFinished(() => { endpoint.$close() }) + const calls = Array.from({ length: 4 }, () => endpoint.invoke('x'.repeat(64))) + const results = Promise.allSettled(calls) + expect((await failed.promise).message).toContain('queued bytes') + expect((await results).every(result => result.status === 'rejected')).toBe(true) +}) + +it.each(['error', 'close'] as const)('rejects a pending call on transport %s', async (event) => { + const entered = Promise.withResolvers() + const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) { entered.resolve(undefined) } }) + const failed = Promise.withResolvers() + const endpoint = createProcessRpc<{ wait(): Promise }, Record>(stream, 256, {}, [], (_error, kind) => { + failed.resolve(kind) + }) + onTestFinished(() => { endpoint.$close() }) + const rejected = expect(endpoint.wait()).rejects.toThrow('closed') + await entered.promise + stream.emit(event, new Error('transport failed')) + expect(await failed.promise).toBe('io') + await rejected +}) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts index a5773b8fd2..5fd5e42c92 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts @@ -235,10 +235,10 @@ describe('Node program process', () => { }) it.each([ - 'const fs = await import("node:fs"); const b=Buffer.alloc(4); b.writeUInt32BE(4294967295); fs.writeSync(7,b); await new Promise(()=>{});', - 'const fs = await import("node:fs"); const body=Buffer.from(JSON.stringify({type:"call",id:1,global:"tools",name:"undeclared",args:[]})); const h=Buffer.alloc(4); h.writeUInt32BE(body.length); fs.writeSync(7,Buffer.concat([h,body])); await new Promise(()=>{});', + 'const fs = await import("node:fs"); fs.writeSync(7,"x".repeat(2048)); await new Promise(()=>{});', + 'const fs = await import("node:fs"); fs.writeSync(7,JSON.stringify({t:"q",i:"forged",m:"invoke",a:[1,"tools","undeclared",[]]})+"\\n"); await new Promise(()=>{});', ])('refuses hostile program control traffic', async (program) => { - const { run } = await setup() + const { run } = await setup({ maxMessageBytes: 1024 }) expect((await run({ program, bindings: [] })).error?.kind).toBe('protocol') }) diff --git a/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts b/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts index 535efdde08..530d76f714 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from 'tsdown' /** The Node provider and its private process bootstraps are published bundles. */ export default defineConfig([ { entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false }, - { entry: { process: 'lib/types/process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control'] }, - { entry: { 'persistent-process': 'lib/types/persistent-process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control'] }, + { entry: { process: 'lib/types/process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control', 'birpc'] }, + { entry: { 'persistent-process': 'lib/types/persistent-process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control', 'birpc'] }, ]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02f18e8afd..98d5a74503 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12087,6 +12087,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + birpc: + specifier: ^4.2.0 + version: 4.2.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -20131,6 +20134,9 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + birpc@4.2.0: + resolution: {integrity: sha512-KxgKcZPfrtzJDDALHPguGpGJUrzdgpymyiQQgzFjWreHMOpWrnFNVREr5J48x2DBh8ZVioscrV1SBkDipGiX+Q==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -27608,6 +27614,8 @@ snapshots: birpc@4.0.0: {} + birpc@4.2.0: {} + bl@4.1.0: dependencies: buffer: 5.7.1 diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index f1673f57e6..ae65e785da 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -1448,10 +1448,6 @@ "d2c3345795169400442563b4923671c090a724eacaaae7f0c190297b967c1c4d": 1, "dfa30a521aae411e70eee81fc4c0511178589d0656dea01c1c97131bfe097793": 1 }, - "packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts": { - "aa5de2d4958ec64219565d53ec5a9d7f40f1f60037a2d52d1a9c3c65db034d73": 1, - "be027af1ef5cb3bfe41eeb19b54dbb176ec7c23ae367d709059839f9851833d5": 1 - }, "packages/ptc-runtime/ptc-runtime-node/tests/output-json.spec.ts": { "8c4c486bbc27559714f4e0aaf376c72c49ac9157539b03c6c12853b8d3fc6e74": 1 }, diff --git a/scripts/ssh-helper/worker-probe.ts b/scripts/ssh-helper/worker-probe.ts index f84d598258..99c4519b50 100644 --- a/scripts/ssh-helper/worker-probe.ts +++ b/scripts/ssh-helper/worker-probe.ts @@ -4,7 +4,8 @@ import { spawn } from 'node:child_process' import { Duplex, type Readable, type Writable } from 'node:stream' import { join } from 'node:path' import { z } from 'zod' -import { JsonChannel } from '../../packages/ptc-runtime/ptc-runtime-node/src/channel.ts' +import { createProcessRpc } from '../../packages/ptc-runtime/ptc-runtime-node/src/rpc.ts' +import type { HostMethods, ProgramMethods } from '../../packages/ptc-runtime/ptc-runtime-node/src/protocol.ts' import { decodePtcJsonWire } from '../../packages/ptc-runtime/ptc-runtime-node/src/json-wire.ts' import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from '../../packages/subprocess/subprocess/src/control.ts' @@ -40,18 +41,17 @@ export async function probeRestrictedWorker( const streams: ReadonlyArray = child.stdio const control = streams[SUBPROCESS_CONTROL_FD] assert(control instanceof Duplex) - const channel = new JsonChannel(control, 1_048_576, (raw) => { - const message = z.object({ type: z.string(), value: z.unknown().optional(), error: z.unknown().optional() }).parse(raw) - if (message.type === 'ready') { - const code = `const fs=await import('node:fs/promises'); const denied=[]; for(const path of ${JSON.stringify([join(checkout, 'package.json'), hostNode])}) { try { await fs.readFile(path); denied.push('readable') } catch(error) { denied.push(error.code) } } return {executable:process.execPath,env:Object.keys(process.env),denied};` - void channel.send({ type: 'boot', data: { code, namespaces: [], maxOutputBytes: 65_536 } }) - .catch((error: unknown) => { completed.reject(error) }) - } else if (message.type === 'done') { - if (message.error !== undefined) completed.reject(new Error(JSON.stringify(message.error))) - else completed.resolve(decodePtcJsonWire(message.value)) - channel.close() - } else completed.reject(new Error(`Unexpected restricted-worker message: ${message.type}`)) - }, (error) => { completed.reject(error) }) + const code = `const fs=await import('node:fs/promises'); const denied=[]; for(const path of ${JSON.stringify([join(checkout, 'package.json'), hostNode])}) { try { await fs.readFile(path); denied.push('readable') } catch(error) { denied.push(error.code) } } return {executable:process.execPath,env:Object.keys(process.env),denied};` + // Accepting only readiness rejects any other worker message as a protocol failure. + const channel = createProcessRpc>(control, 1_048_576, { + ready() { + void channel.run(1, workspace, { code, namespaces: [], maxOutputBytes: 65_536 }).then((result) => { + if (result.error !== undefined) completed.reject(new Error(JSON.stringify(result.error))) + else completed.resolve(decodePtcJsonWire(result.value)) + channel.$close() + }, (error: unknown) => { completed.reject(error) }) + }, + }, [], (error) => { completed.reject(error) }) const force = setTimeout(() => { child.kill('SIGKILL') }, 20_000) try { const result = z.object({ executable: z.string(), env: z.array(z.string()), denied: z.array(z.enum(['EPERM', 'EACCES'])) }).parse(await completed.promise) @@ -60,7 +60,7 @@ export async function probeRestrictedWorker( assert.equal(result.denied.length, 2) await exited } finally { - channel.close() + channel.$close() child.kill('SIGTERM') try { await exited } finally { clearTimeout(force) } }