refactor(ptc-runtime-node): carry the process control channel over birpc

Replace the length-prefixed JsonChannel and hand-written request/reply
correlation with typed birpc methods over newline-delimited JSON. The
host calls the child's run(); bindings call the host's invoke(); logs
and output-limit reports are notifications. The transport bounds
incoming lines and queued writes, validates UTF-8 and exposed methods,
and rejects outstanding calls when the pipe closes.

Drop logic made obsolete by the new transport, including the 32-bit
frame-length bound on maxMessageBytes.
This commit is contained in:
_Kerman committed 2026-10-20 14:50:05 +08:00
1 parent 37dfd82736
commit 534aafce7a
29 files changed
+1088 -1309

No files matched your search

@@ -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
@@ -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 |
</details>
@@ -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 外层结果计量 |
</details>
@@ -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:~",
@@ -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<string, PtcBinding
}
/**
* Invoke a host binding and encode its lossless result or bounded-channel rejection.
* @param id - Program-issued correlation identifier.
* @param binding - Current evaluation's declared function, absent if it was removed.
* Invoke a host binding and encode its lossless result.
* @param binding - Current evaluation's function, absent if it was removed.
* @param args - Validated JSON arguments detached from the program.
* @returns A reply suitable for the runtime's bounded control channel.
* @returns Flat lossless JSON; unavailable bindings and invalid results reject.
*/
export async function replyToBinding(id: number, binding: PtcBindingFunction | undefined, args: PtcJsonValue): Promise<ReplyMessage> {
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<PtcJsonWire> {
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)
}
@@ -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<PtcJsonWire>
/**
* 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<LogBuffer, 'push'>, 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<DoneMessage, 'type'> {
): ProgramResult {
if (value === undefined) return {}
let snapshot: ReturnType<typeof snapshotPtcJsonValue>
try {
@@ -188,7 +202,7 @@ export function prepareCompletion(
}
/** Build the fixed overflow fragment without carrying rejected variable bytes. */
function outputLimit(maxOutputBytes: number): Omit<DoneMessage, 'type'> {
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<DoneMessage, 'type'> {
): 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<DoneMessage, 'type'> {
): 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<number, PendingCall>): 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<ProgramBootData, 'namespaces'>,
port: BootstrapPort,
pending: Map<number, PendingCall>,
nextId: { value: number },
host: Pick<ProgramHost, 'invoke'>,
errorClasses: Pick<ReadonlyMap<string, BindingErrorConstructor>, 'get'> = makeBindingErrorClasses(data),
): Record<string, unknown>[] {
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<unknown> => {
value: async (args: unknown): Promise<unknown> => {
const errorClass = errorClasses.get(global)
let detached: ReturnType<typeof snapshotPtcJsonValue>
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<typeof snapshotPtcJsonValue>
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<void> {
): Promise<ProgramResult> {
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<number, PendingCall>()
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)
}
@@ -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<void>
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<PendingWrite>()
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<void> {
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<void>()
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<void> { await Promise.allSettled([...this.writes].map(write => write.promise)) }
}
@@ -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')
}
@@ -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<void> }
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<string, unknown> { 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<string, PtcBindingNamespace>,
controller: AbortController,
): Promise<PtcRunResult> {
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<PtcRunResult>()
const signal = spec.signal === undefined ? controller.signal : AbortSignal.any([spec.signal, controller.signal])
let handle: SubprocessHandle | undefined
let channel: JsonChannel | undefined
let channel: BirpcReturn<ProgramMethods, HostMethods> | 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<ProgramMethods, HostMethods>(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) }) })
@@ -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<DoneMessage, 'type'>): 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<void> {
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<Evaluation>()
const closed = Promise.withResolvers<void>()
const pending = new Map<number, PendingCall>()
const nextId = { value: 1 }
const errors = new Map<string, { name: string; memberNameProperty: string; constructor: BindingErrorConstructor }>()
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<ProgramHost, 'invoke'> = {
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<HostMethods, ProgramMethods>(stream, maxMessageBytes, {
async run(id, cwd, data) {
if (current?.active) throw new Error('persistent PTC already has an active evaluation')
const completion = Promise.withResolvers<ProgramResult>()
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()
}
}
@@ -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<string, unknown> => 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<string, PtcBindingNamespace>
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<ProgramMethods, HostMethods>
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<boolean> | undefined
private starting?: Promise<void>
@@ -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<ProgramMethods, HostMethods>(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<PtcJsonWire> {
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
}
}
}
@@ -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<void> {
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<ProgramBootData>()
const listeners: Array<(message: ReplyMessage) => void> = []
let started = false
let failed = false
let terminalSent = false
const hostClosed = Promise.withResolvers<void>()
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<HostMethods, ProgramMethods>(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<Promise<void>>()
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
}
@@ -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<PtcJsonWire>
/**
* 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<ProgramResult>
}
@@ -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<string, unknown> => 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 }
}
@@ -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<string, unknown> => 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<Remote extends object, Local extends object>(
stream: Duplex,
maxMessageBytes: number,
functions: Local,
eventNames: (keyof Remote)[],
failure: (error: Error, kind: 'io' | 'protocol') => void,
): BirpcReturn<Remote, Local> {
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<Remote, Local>(functions, {
timeout: -1,
eventNames,
on: (listener) => { receive = listener },
off: () => { receive = undefined; stream.destroy() },
serialize: (message: Record<string, unknown>) => {
// 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
}
@@ -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<ProgramHost['invoke']>(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<unknown>): Promise<unknown> {
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<number, PendingCall>()
wireReplies(port, pending)
const result = new Promise<unknown>((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<number, PendingCall>()
wireReplies(port, pending)
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['__proto__', 'constructor', 'toString'] }] }, port, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
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<string, (args: unknown) => Promise<unknown>>]
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<number, PendingCall>()
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<string, (args: unknown) => Promise<unknown>>]
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<number, PendingCall>()
const nextId = { value: 1 }
const [tools] = makeNamespaces(
{ namespaces: [toolNamespace(['x'])] }, port, pending, nextId,
) as [Record<string, (args: unknown) => Promise<unknown>>]
const classes = makeBindingErrorClasses(data)
const [tools] = makeNamespaces(data, remote, classes) as [Record<string, (args: unknown) => Promise<unknown>>]
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<number, PendingCall>()
wireReplies(deniedPort, deniedPending)
const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, deniedPort, deniedPending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
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<string, (args: unknown) => Promise<unknown>>]
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<string, (args: unknown) => Promise<unknown>>]
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'])
})
})
@@ -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<unknown>()
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<undefined>()
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<Error>()
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<Error>()
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<Error>()
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<undefined>()
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')
})
@@ -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<Promise<void>>()
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<Config>([{ 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<null>()
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<undefined>()
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<undefined>()
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<string>()
const pending = context.run('binding context', () => h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }),
@@ -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<Promise<void>>()
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<undefined>()
const cleanup = Promise.withResolvers<boolean>()
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<undefined>()
const cleanup = Promise.withResolvers<boolean>()
@@ -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<string, unknown>
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<void> = Promise.withResolvers()
const release: PromiseWithResolvers<string> = 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<true>()
const stopped = Promise.withResolvers<boolean>()
h.waitForExit.mockImplementation(async () => { cleanup.resolve(true); return stopped.promise })
h.onBoot(() => { h.emit(frame({ type: 'done' })) })
h.onBoot(() => { h.complete({}) })
let closing: Promise<void> | 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<undefined>()
const lookup = Promise.withResolvers<string>()
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<true>()
const stopped = Promise.withResolvers<boolean>()
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: [] })
@@ -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<true>()
const pending = new Map<number, ReturnType<typeof Promise.withResolvers<Record<string, unknown>>>>()
const seen: Record<string, unknown>[] = []
let sequence = 0
let onCall: (id: number, payload: Record<string, unknown>) => 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<string, unknown> }
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<ProgramMethods, HostMethods>(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<Record<string, unknown>>()
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<undefined>()
const release = Promise.withResolvers<ReturnType<typeof encodePtcJsonWire>>()
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<ReturnType<typeof encodePtcJsonWire>>()
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')
@@ -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<string, unknown>[] = []
const peer = new JsonChannel(host, 4096, (raw) => {
const message = raw as Record<string, unknown>
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<unknown>()
const messages: string[] = []
let callId: unknown
const peer = new JsonChannel(host, 4096, (raw) => {
const message = raw as Record<string, unknown>
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<undefined>()
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<ProgramMethods, HostMethods>(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<ReturnType<typeof encodePtcJsonWire>>()
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<undefined>()
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)
})
@@ -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<ProgramMethods, HostMethods>(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
})
@@ -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<void> => new Promise((resolve, reject) => {
stream.write(`${JSON.stringify(message)}\n`, (error) => { if (error) reject(error); else resolve() })
}),
close: (): void => { lines.close(); stream.destroy() },
}
}
@@ -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<string>()
const entered = Promise.withResolvers<undefined>()
const failures = vi.fn()
const left = createProcessRpc<{ slow(): Promise<string>; fail(): never }, { double(value: number): number }>(local, 4096,
{ double: value => value * 2 }, [], failures)
const right = createProcessRpc<{ double(value: number): number }, { slow(): Promise<string>; 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<string, never>>(local, 4096, {}, [], () => {})
const right = createProcessRpc<Record<string, never>, { 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<string>()
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<undefined>()
const raw = rawPeer(remote, () => { received.resolve(undefined) })
const endpoint = createProcessRpc<{ wait(): Promise<void> }, Record<string, never>>(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<unknown>()
const raw = rawPeer(remote, (message) => { received.resolve(message) })
const endpoint = createProcessRpc<{ log(text: string): void }, Record<string, never>>(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<string, never>>(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<undefined>()
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<string>()
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<Error>()
const endpoint = createProcessRpc<{ invoke(value: string): Promise<string> }, Record<string, never>>(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<undefined>()
const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) { entered.resolve(undefined) } })
const failed = Promise.withResolvers<string>()
const endpoint = createProcessRpc<{ wait(): Promise<void> }, Record<string, never>>(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
})
@@ -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')
})
@@ -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'] },
])
+8
View File
@@ -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:
[email protected]:
resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==}
[email protected]:
resolution: {integrity: sha512-KxgKcZPfrtzJDDALHPguGpGJUrzdgpymyiQQgzFjWreHMOpWrnFNVREr5J48x2DBh8ZVioscrV1SBkDipGiX+Q==}
[email protected]:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@@ -27608,6 +27614,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
buffer: 5.7.1
-4
View File
@@ -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
},
+14 -14
View File
@@ -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<Readable | Writable | null | undefined> = 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<ProgramMethods, Pick<HostMethods, 'ready'>>(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) }
}