refactor(ptc-runtime-node): rely on birpc defaults for message handling

Keep the stream side owned here: line bounds, the leading-brace check,
the write-queue limit and closure. On the birpc side, drop the strict
envelope validation, falsy-rejection normalization and fatal UTF-8
decoding. Calls to methods an endpoint does not own are answered with
birpc's error and unknown messages are ignored; the resolver still runs
only own methods and none after the endpoint closes.
This commit is contained in:
_Kerman committed 2026-10-20 23:28:27 +08:00
1 parent 427d62d4a7
commit 7bab95f8da
8 files changed
+52 -51

No files matched your search

@@ -30,14 +30,14 @@
en: ad72b8bdba6a3966
zh: 28a20fb5fb7c622d
/deepseek-ai-dsh-ptc-runtime-node/use-this-package/failures:
en: 7682e50f9dcfa25c
zh: ddcb3502e12be06f
en: 0ba661c7030f368c
zh: dc2be3553eda447d
/deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation:
en: 9020b491a39e4bf5
zh: bcd1b2b1a3bde7f3
/deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/launch-and-control:
en: cf48f246710f89d9
zh: 423c25a365155223
en: 94e08458a48b8c16
zh: 48087aaa4ef028b6
/deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-and-built-bootstraps:
en: f9d796233bfb3b79
zh: f7c5803a6ccdf9c2
@@ -93,7 +93,7 @@ Omitting `timeoutMs` uses the configured elapsed default; numeric requests are v
### Failures
Program parse errors and thrown exceptions are `exception`; deadline expiry is `timeout`; cancellation is `abort`; malformed or excessive control traffic is `protocol`; unavailable confinement is `sandbox-unavailable`; early process exit or failed managed cleanup is `worker-exit`. The substrate-independent failure name remains `worker-exit` for process providers. Lossy completions are `invalid-output`, and an oversized outer result is `output-limit`, retaining the fitting log prefix. Invalid or unsupported options and calls after disposal reject as caller misuse.
Program parse errors and thrown exceptions are `exception`; deadline expiry is `timeout`; cancellation is `abort`; unparsable or oversized control traffic and invalid program messages are `protocol`; unavailable confinement is `sandbox-unavailable`; early process exit or failed managed cleanup is `worker-exit`. The substrate-independent failure name remains `worker-exit` for process providers. Lossy completions are `invalid-output`, and an oversized outer result is `output-limit`, retaining the fitting log prefix. Invalid or unsupported options and calls after disposal reject as caller misuse.
-----
@@ -109,7 +109,7 @@ 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.
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 binding names and lossless JSON before dispatch; one pipe delivers each evaluation's messages before its result, so messages carry no evaluation identity. Execution deadlines remain host-owned, including calls that await approval.
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 each line and the native write queue and rejects pending calls when the pipe closes; birpc answers calls to methods an endpoint does not own with an error, and runs no method after the pipe closes. The host validates binding names and lossless JSON before dispatch; one pipe delivers each evaluation's messages before its result, so messages carry no evaluation identity. Execution deadlines remain host-owned, including calls that await approval.
Per-call completion leaves unfinished binding waits pending and stops binding calls and 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.
@@ -93,7 +93,7 @@ PTC 消费方按 [dsh-tools](../../core/tools/README.zh.md#ptc-mode) 的说明
### 失败
程序解析错误与抛出异常为 `exception`;截止到期为 `timeout`;取消为 `abort`;畸形或超量控制通信为 `protocol`;约束不可用为 `sandbox-unavailable`;进程提前退出或受管清理失败为 `worker-exit`。进程提供方保留与执行基底无关的失败名 `worker-exit`。有损完成值为 `invalid-output`,外层结果超限为 `output-limit`,并保留能容纳的日志前缀。无效或不支持的选项,以及资源释放后的调用,以调用方误用拒绝。
程序解析错误与抛出异常为 `exception`;截止到期为 `timeout`;取消为 `abort`;无法解析或超量的控制通信以及无效的程序消息为 `protocol`;约束不可用为 `sandbox-unavailable`;进程提前退出或受管清理失败为 `worker-exit`。进程提供方保留与执行基底无关的失败名 `worker-exit`。有损完成值为 `invalid-output`,外层结果超限为 `output-limit`,并保留能容纳的日志前缀。无效或不支持的选项,以及资源释放后的调用,以调用方误用拒绝。
-----
@@ -109,7 +109,7 @@ 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` 值传递;环境中的加载器和调试器标志会被丢弃。
Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host 的 `invoke()` 方法,捕获的输出通过通知传递。birpc 负责请求关联、远程结果和异常传递。逐行 JSON 通过继承的控制管道传输,与 stdout/stderr 分离。RPC 适配层限制未完成消息和原生写入队列的大小,校验 UTF-8 与公开方法,并在管道关闭时拒绝待完成调用。Host 在分派前校验绑定名与无损 JSON;同一管道保证每次求值的消息先于其结果到达,因此消息不携带求值身份。执行截止时间仍由 Host 管理,包含等待审批的调用。
Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host 的 `invoke()` 方法,捕获的输出通过通知传递。birpc 负责请求关联、远程结果和异常传递。逐行 JSON 通过继承的控制管道传输,与 stdout/stderr 分离。RPC 适配层限制每行消息和原生写入队列的大小,并在管道关闭时拒绝待完成调用;对端点自身没有的方法,birpc 以错误回复,管道关闭后不再执行任何方法。Host 在分派前校验绑定名与无损 JSON;同一管道保证每次求值的消息先于其结果到达,因此消息不携带求值身份。执行截止时间仍由 Host 管理,包含等待审批的调用。
单次执行完成后,尚未完成的绑定等待保持挂起,并停止绑定调用和捕获输出的通知。子进程保持控制管道打开,直到 Host 收到结果并关闭管道。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
@@ -5,9 +5,7 @@ import { createBirpc, type BirpcReturn } from 'birpc'
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))
const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error)
/**
* Connect typed methods over one control endpoint; execution owns all deadlines.
@@ -28,7 +26,8 @@ export function createProcessRpc<Remote extends object, Local extends object>(
const fail = (error: unknown, kind: 'io' | 'protocol'): void => {
if (rpc.$closed) return
rpc.$close()
failure(toError(error), kind)
// Streams, the JSON parser and the line checks fail only with Error objects.
failure(error as Error, kind)
}
let receive: ((line: string) => void) | undefined
const rpc = createBirpc<Remote, Local>(functions, {
@@ -37,7 +36,7 @@ export function createProcessRpc<Remote extends object, Local extends object>(
on: (listener) => { receive = listener },
off: () => { receive = undefined; stream.destroy() },
// JSON drops Error fields; the peer receives only the diagnostic.
serialize: (message: Record<string, unknown>) => `${stringify(message.e instanceof Error ? { ...message, e: { message: message.e.message } } : message)}\n`,
serialize: (message: { e?: unknown }) => `${stringify(message.e === undefined ? message : { ...message, e: { message: messageOf(message.e) } })}\n`,
post: (line: string) => {
// A binding can settle after the endpoint closes; the Writable owns already queued bytes.
if (rpc.$closed) return
@@ -46,25 +45,16 @@ export function createProcessRpc<Remote extends object, Local extends object>(
},
deserialize: (line: string) => {
// A line that parses after the leading-brace check is a JSON object.
const message = parse(line) as Record<string, unknown>
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) }
}
const message = parse(line) as { e?: { message?: unknown } }
if (message.e) message.e = new Error(String(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) }
},
// Only own methods are callable, and none runs once the endpoint closes,
// including calls that arrived in the same chunk as the closing message.
resolver: (name, fn) => Object.hasOwn(functions, name)
? (...args: unknown[]) => rpc.$closed ? undefined : fn.apply(functions, args)
: undefined,
})
const decoder = new TextDecoder('utf-8', { fatal: true })
let parts: Buffer[] = []
let size = 0
const buffer = (part: Buffer): void => {
@@ -79,7 +69,7 @@ export function createProcessRpc<Remote extends object, Local extends object>(
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))
const line = Buffer.concat(parts, size).toString('utf8')
parts = []
size = 0
start = end + 1
@@ -326,9 +326,6 @@ describe('Node runtime host failures', () => {
[{ t: 'q', m: 'invoke', a: [null, 'test', []], i: 'call' }, 'undeclared binding'],
[{ t: 'q', m: 'invoke', a: ['tools', 'constructor', []], i: 'call' }, 'undeclared binding'],
[{ t: 'q', m: 'invoke', a: ['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)
@@ -339,6 +336,16 @@ describe('Node runtime host failures', () => {
expect(binding).not.toHaveBeenCalled()
})
it('answers calls to unknown methods without failing the run', async () => {
const h = await setup()
h.onBoot(() => {
h.emit({ t: 'q', i: 'unknown', m: 'constructor', a: [] })
h.complete({ value: encodePtcJsonWire(42) })
})
expect((await h.start()).value).toBe(42)
expect(h.messages).toContainEqual({ t: 's', i: 'unknown', e: { message: '[birpc] function "constructor" not found' } })
})
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()
h.onBoot(() => { h.complete(raw) })
@@ -209,7 +209,6 @@ it('rejects a retained call when its provider unloads during earlier policy clea
describe('persistent PTC host protocol', () => {
it.each([
null,
{ t: 'q', m: 'other', a: [] },
{ t: 'q', m: 'log', a: [1] },
call(1, 'not a wire value'),
])('rejects invalid process traffic %j', async (raw) => {
@@ -228,7 +227,7 @@ describe('persistent PTC host protocol', () => {
it('requires the readiness handshake and all requested pipes', async () => {
const h = await setup()
expect((await h.start(request, { t: 'q', m: 'other', a: [] })).error?.kind).toBe('protocol')
expect((await h.start(request, null)).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')
@@ -35,7 +35,8 @@ it('matches concurrent bidirectional results and preserves remote rejection mess
expect(failures).not.toHaveBeenCalled()
})
it.each([undefined, null, false, 0, '', 'denied'])('rejects a remote call whose method throws %j', async (thrown) => {
it('delivers a non-Error rejection as its text', async () => {
const thrown: unknown = 'denied'
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 } }, [], () => {})
@@ -49,7 +50,7 @@ it('does not invoke a method received beside traffic that closed the endpoint',
const allowed = vi.fn()
const endpoint = createProcessRpc(local, 4096, { allowed }, [], (_error, kind) => { failure.resolve(kind) })
onTestFinished(() => { endpoint.$close() })
remote.write(`${JSON.stringify({ t: 'unknown' })}\n${JSON.stringify({ t: 'q', m: 'allowed', a: [] })}\n`)
remote.write(`{\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()
@@ -98,23 +99,26 @@ it('ignores a response for another call and returns the matching result', async
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) => {
it('answers calls to methods it does not own and ignores unknown messages', async () => {
const { local, remote } = endpoints()
const failure = Promise.withResolvers<{ kind: string; error: Error }>()
const failures = vi.fn()
const allowed = vi.fn()
const endpoint = createProcessRpc(local, 4096, { allowed }, [], (error, kind) => { failure.resolve({ error, kind }) })
const raw = rawPeer(remote, () => {})
const replies: unknown[] = []
const answered = Promise.withResolvers<undefined>()
const endpoint = createProcessRpc(local, 4096, { allowed }, [], failures)
const raw = rawPeer(remote, (message) => {
replies.push(message)
if (replies.length === 2) answered.resolve(undefined)
})
onTestFinished(() => { endpoint.$close(); raw.close() })
await raw.send(message)
expect((await failure.promise).kind).toBe('protocol')
expect(endpoint.$closed).toBe(true)
for (const message of [{ t: 'unknown' }, { t: 's', i: 'stray', e: 'unstructured' }, { t: 'q', i: 'a', m: 'constructor', a: [] }, { t: 'q', i: 'b', m: 'toString', a: [] }]) {
await raw.send(message)
}
await answered.promise
expect(replies).toEqual(['constructor', 'toString'].map((name, index) => ({ t: 's', i: 'ab'[index], e: { message: `[birpc] function "${name}" not found` } })))
expect(allowed).not.toHaveBeenCalled()
expect(failures).not.toHaveBeenCalled()
expect(endpoint.$closed).toBe(false)
})
it('reassembles fragmented UTF-8 and consecutive JSON lines', async () => {
@@ -134,6 +138,7 @@ it('reassembles fragmented UTF-8 and consecutive JSON lines', async () => {
it.each([
[Buffer.from('{\n'), false],
[Buffer.from('null\n'), false],
[Buffer.from([0xff, 10]), false],
[Buffer.from('{'), true],
[Buffer.from([0xe2]), true],
+1 -1
View File
@@ -42,7 +42,7 @@ export async function probeRestrictedWorker(
const control = streams[SUBPROCESS_CONTROL_FD]
assert(control instanceof Duplex)
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.
// The probe program calls no bindings and prints nothing, so the host serves only readiness.
const channel = createProcessRpc<ProgramMethods, Pick<HostMethods, 'ready'>>(control, 1_048_576, {
ready() {
void channel.run(workspace, { code, namespaces: [], maxOutputBytes: 65_536 }).then((result) => {