refactor(ptc-runtime-node): remove unused bootstrap paths

This commit is contained in:
_Kerman committed 2026-10-21 16:00:30 +08:00
1 parent 2b7b2a0ee6
commit 4b06d5bf80
11 files changed
+63 -183

No files matched your search

@@ -42,8 +42,8 @@
en: cfd3e311400a9c9c
zh: f7e12639f067e07b
/deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-map:
en: 9a981c5c93673a25
zh: f8e295089807ab2a
en: d49ad6b735b65e38
zh: 904e1460d141539e
/deepseek-ai-dsh-ptc-runtime-node/further-exploration:
en: ac2e2ae13d3f1394
zh: 5d08b9c658fcf83a
@@ -150,7 +150,7 @@ Source containing `await` is checked for top-level await before native compilati
| [`src/output-stream.ts`](src/output-stream.ts) | Bounded stream capture and process-output draining |
| [`src/launch.ts`](src/launch.ts) | Executable/bootstrap arguments and execution-world asset mapping |
| [`src/process.ts`](src/process.ts) | Common child handshake, evaluator lifetime and binding RPC |
| [`src/bootstrap.ts`](src/bootstrap.ts) | Program evaluation, binding proxies and output capture |
| [`src/bootstrap.ts`](src/bootstrap.ts) | Binding proxies, JSON validation and output capture |
| [`src/rpc.ts`](src/rpc.ts) | Typed birpc transport, message limits and connection lifetime |
| [`src/protocol.ts`](src/protocol.ts) | Host and program methods, and validation of untrusted program results |
| [`src/output-ledger.ts`](src/output-ledger.ts) | Host accounting for the outer result |
@@ -150,7 +150,7 @@ Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host
| [`src/output-stream.ts`](src/output-stream.ts) | 有界流捕获与进程输出排空 |
| [`src/launch.ts`](src/launch.ts) | 可执行文件/bootstrap 参数与执行世界资源映射 |
| [`src/process.ts`](src/process.ts) | 共用的子进程握手、求值器生命周期与绑定 RPC |
| [`src/bootstrap.ts`](src/bootstrap.ts) | 程序求值、绑定代理与输出捕获 |
| [`src/bootstrap.ts`](src/bootstrap.ts) | 绑定代理、JSON 校验与输出捕获 |
| [`src/rpc.ts`](src/rpc.ts) | 类型化 birpc 传输、消息限制与连接生命周期 |
| [`src/protocol.ts`](src/protocol.ts) | Host 与程序方法,以及不可信程序结果的校验 |
| [`src/output-ledger.ts`](src/output-ledger.ts) | Host 外层结果计量 |
@@ -1,11 +1,9 @@
/**
* Program evaluation, output capture, and host binding proxies over the process channel.
* Output capture, result encoding, and host binding proxies over the process channel.
* @module @deepseek-ai/dsh-ptc-runtime-node/src/bootstrap
*/
import { inspect } from 'node:util'
import { createProgramEvaluator } from './evaluators/index.ts'
import type { ProgramEvaluator } from './evaluators/types.ts'
import type { HostMethods, ProgramResult, ProgramBootData } from './protocol.ts'
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
import { decodePtcJsonWire, encodePtcJsonWire, snapshotPtcJsonValue } from './json-wire.ts'
@@ -247,21 +245,6 @@ function bindingFailure(errorClass: BindingErrorConstructor | undefined, memberN
return errorClass ? new errorClass(memberName, message) : new CapturedError(message)
}
/**
* Build each declared error class once so calls and `instanceof` share constructor identity.
* @param data - binding namespace declarations from the boot payload.
* @returns constructors keyed by their owning namespace global.
*/
export function makeBindingErrorClasses(
data: Pick<ProgramBootData, 'namespaces'>,
): Map<string, BindingErrorConstructor> {
const classes = new Map<string, BindingErrorConstructor>()
for (const namespace of data.namespaces) {
if (namespace.errorClass) classes.set(namespace.global, makeBindingErrorClass(namespace.errorClass))
}
return classes
}
/**
* Build null-prototype binding globals with namespace-specific rejection classes.
* @param data - Namespace declarations injected into the program.
@@ -272,7 +255,7 @@ export function makeBindingErrorClasses(
export function makeNamespaces(
data: Pick<ProgramBootData, 'namespaces'>,
host: Pick<HostMethods, 'invoke'>,
errorClasses: Pick<ReadonlyMap<string, BindingErrorConstructor>, 'get'> = makeBindingErrorClasses(data),
errorClasses: Pick<ReadonlyMap<string, BindingErrorConstructor>, 'get'>,
): Record<string, unknown>[] {
return data.namespaces.map(({ global, names }) => {
const namespace = Object.create(null) as Record<string, unknown>
@@ -300,51 +283,3 @@ export function makeNamespaces(
return namespace
})
}
/**
* Run one program through its selected evaluator 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 Completion or a bounded program failure.
*/
export async function runProgram(
host: Omit<HostMethods, 'ready'>,
data: ProgramBootData,
streams: { stdout: PatchableStream; stderr: PatchableStream },
): Promise<ProgramResult> {
const logs = new LogBuffer(
data.maxOutputBytes,
(text) => { host.log(text) },
() => { host.outputLimit() },
)
captureStreamWrites(logs, streams.stdout)
captureStreamWrites(logs, streams.stderr)
const errorClasses = makeBindingErrorClasses(data)
const namespaces = makeNamespaces(data, host, errorClasses)
let evaluator: ProgramEvaluator | undefined
try {
const completion = Promise.withResolvers<unknown>()
evaluator = createProgramEvaluator(data.programFormat ?? 'function-body', (message) => { completion.reject(new CapturedError(message)) })
const globals = evaluator.globals
const defineGlobal = (name: string, value: unknown): void => {
Object.defineProperty(globals, name, { configurable: true, enumerable: true, writable: true, value })
}
for (const [index, namespace] of data.namespaces.entries()) defineGlobal(namespace.global, namespaces[index])
for (const namespace of data.namespaces) {
if (namespace.errorClass) defineGlobal(namespace.errorClass.name, errorClasses.get(namespace.global))
}
defineGlobal('console', makeConsoleShim(logs))
evaluator.evaluate(data.code, (result) => {
if (result.ok) completion.resolve(result.value)
else completion.reject(result.error)
})
const value = await completion.promise
return prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes)
} catch (error: unknown) {
return prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes)
} finally {
evaluator?.close()
}
}
@@ -41,7 +41,7 @@ export function resolveLaunch(config?: LaunchConfig): LaunchConfig {
* @param persistent - Whether the selected mode requires retained-process launch support.
* @returns Arguments following the resolved Node executable.
*/
export function bootstrapArgs(fs: Pick<FileSystem, 'processPathFromHostPath'>, config: LaunchConfig, maxMessageBytes: number, persistent: boolean = false): string[] {
export function bootstrapArgs(fs: Pick<FileSystem, 'processPathFromHostPath'>, config: LaunchConfig, maxMessageBytes: number, persistent = false): string[] {
switch (config.kind) {
case 'embedded':
if (persistent) throw new Error('Persistent PTC requires a node-script launch')
@@ -2,6 +2,4 @@
import { openInheritedControlChannel } from '@deepseek-ai/dsh-subprocess/control'
import { runPersistentMain } from './persistent-process.ts'
const format = process.argv[3] ?? 'repl'
if (format !== 'function-body' && format !== 'repl' && format !== 'script') throw new Error(`unsupported persistent PTC format: ${format}`)
void runPersistentMain(openInheritedControlChannel(), Number(process.argv[2]), process, format)
void runPersistentMain(openInheritedControlChannel(), Number(process.argv[2]), process)
@@ -1,24 +1,17 @@
/** Existing retained bootstrap entry delegates to the shared Node evaluation loop. */
import type { PtcProgramFormat } from '@deepseek-ai/dsh-ptc-runtime'
import type { Duplex } from 'node:stream'
import { runNodeMain } from './process.ts'
import type { ProgramProcess } from './process.ts'
/** Process state required by retained bootstraps that change cwd between evaluations. */
export interface PersistentProcess extends ProgramProcess {
chdir(directory: string): void
}
/**
* Serve evaluations through the installed retained-bootstrap entry.
* @param stream - Inherited private control endpoint.
* @param maxMessageBytes - Maximum control frame and queued output bytes.
* @param processState - Child environment, output and directory control.
* @param format - Default syntax for hosts that omit it from program data.
* @returns After control closure and evaluator cleanup.
*/
export function runPersistentMain(
stream: Duplex, maxMessageBytes: number, processState: PersistentProcess, format: PtcProgramFormat = 'repl',
stream: Duplex, maxMessageBytes: number, processState: ProgramProcess,
): Promise<void> {
return runNodeMain(stream, maxMessageBytes, processState, format)
return runNodeMain(stream, maxMessageBytes, processState, 'repl')
}
@@ -16,7 +16,7 @@ export interface ProgramProcess {
stdout: PatchableStream
stderr: PatchableStream
exitCode?: string | number | null | undefined
chdir?(directory: string): void
chdir(directory: string): void
}
interface Evaluation {
@@ -85,7 +85,6 @@ export async function runNodeMain(
current = evaluation
context.run(evaluation, () => {
try {
if (processState.chdir === undefined) throw new Error('Node program process cannot change its working directory')
processState.chdir(cwd)
if (evaluator === undefined) {
evaluator = createProgramEvaluator(format, (error) => {
@@ -1,22 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runProgram } from '../src/bootstrap.ts'
import type { PatchableStream } from '../src/bootstrap.ts'
import { LogBuffer, makeBindingErrorClass, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException } from '../src/bootstrap.ts'
import type { BindingErrorConstructor, PatchableStream } from '../src/bootstrap.ts'
import type { HostMethods } from '../src/protocol.ts'
import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts'
import { encodePtcJsonWire } from '../src/json-wire.ts'
function host(): Omit<HostMethods, 'ready'> & { logs: string[]; limits: number } {
const calls = {
logs: [] as string[], limits: 0,
invoke: vi.fn<HostMethods['invoke']>(async () => encodePtcJsonWire(42)),
log: (text: string) => { calls.logs.push(text) },
outputLimit: () => { calls.limits += 1 },
}
return calls
function host(): Pick<HostMethods, 'invoke'> {
return { invoke: vi.fn<HostMethods['invoke']>(async () => encodePtcJsonWire(42)) }
}
function fakeStreams(): { stdout: PatchableStream; stderr: PatchableStream } {
return { stdout: { write: () => true }, stderr: { write: () => true } }
}
const BOOT = { maxOutputBytes: 65_536, retainsState: false }
const TOOL_ERROR_CLASS = { name: 'ToolCallError', memberNameProperty: 'toolName' } as const
function toolNamespace(names: string[]) { return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS } }
@@ -160,7 +150,7 @@ describe('makeNamespaces', () => {
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>>]
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['bad', '__proto__', 'constructor', 'toString'] }] }, remote, new Map<string, BindingErrorConstructor>()) as [Record<string, (args: unknown) => Promise<unknown>>]
expect(Object.getPrototypeOf(tools)).toBeNull()
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')
@@ -169,7 +159,7 @@ describe('makeNamespaces', () => {
it('uses the declared constructor for remote and argument failures', async () => {
const remote = host()
const data = { namespaces: [toolNamespace(['x'])] }
const classes = makeBindingErrorClasses(data)
const classes = new Map([['tools', makeBindingErrorClass(TOOL_ERROR_CLASS)]])
const [tools] = makeNamespaces(data, remote, classes) as [Record<string, (args: unknown) => Promise<unknown>>]
const decorated = [1]
Object.defineProperty(decorated, 'extra', { value: true })
@@ -189,71 +179,8 @@ describe('makeNamespaces', () => {
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>>]
const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, remote, new Map<string, BindingErrorConstructor>()) 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('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)
}
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, retainsState: false, 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, retainsState: false, 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: `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(decodePtcJsonWire(result.value)).toEqual({ caught: true, name, member: 'x', message: 'denied by host' })
})
it('leaves the owned output streams connected to their capture sink', async () => {
const remote = host()
const streams = fakeStreams()
await runProgram(remote, { ...BOOT, code: 'return 1', namespaces: [] }, streams)
streams.stdout.write('captured')
expect(remote.logs).toEqual(['captured'])
})
})
it('reports an asynchronous REPL failure through the standalone bootstrap', async () => {
const result = await runProgram(host(), { ...BOOT, programFormat: 'repl', code: 'setImmediate(() => { throw new Error("async bootstrap failure") }); await new Promise(() => {})', namespaces: [] }, fakeStreams())
expect(result).toHaveProperty('error.message', expect.stringContaining('async bootstrap failure'))
})
@@ -2,7 +2,8 @@ import { Duplex, PassThrough } from 'node:stream'
import { setImmediate } from 'node:timers/promises'
import { expect, it, onTestFinished } from 'vitest'
import { createProcessRpc } from '../src/rpc.ts'
import { runPersistentMain, type PersistentProcess } from '../src/persistent-process.ts'
import { runPersistentMain } from '../src/persistent-process.ts'
import type { ProgramProcess } from '../src/process.ts'
import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts'
import type { ProgramBootData, HostMethods, ProgramMethods } from '../src/protocol.ts'
@@ -13,7 +14,7 @@ async function setup(limit = 4096, chdir: (cwd: string) => void = () => {}) {
const host = Duplex.from({ readable: second, writable: first })
child.on('error', () => {})
host.on('error', () => {})
const state: PersistentProcess = { env: { FIXTURE_SECRET: 'test', PATH: '/native' }, stdout: { write: () => true }, stderr: { write: () => true }, chdir }
const state: ProgramProcess = { env: { FIXTURE_SECRET: 'test', PATH: '/native' }, stdout: { write: () => true }, stderr: { write: () => true }, chdir }
const nativeEnvironment = state.env
const ready = Promise.withResolvers<true>()
const seen: Record<string, unknown>[] = []
@@ -4,7 +4,7 @@ import { setImmediate as nextTurn } from 'node:timers/promises'
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 type { HostMethods, ProgramBootData, ProgramMethods } from '../src/protocol.ts'
import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts'
function endpoints() {
@@ -19,7 +19,7 @@ function endpoints() {
}
function processState(): ProgramProcess { return { env: { FIXTURE_SECRET: 'test' }, stdout: { write: () => true }, stderr: { write: () => true }, exitCode: undefined, chdir: () => {} } }
async function setup(limit = 4096) {
async function setup(limit = 4096, namespaces: ProgramBootData['namespaces'] = [{ global: 'tools', names: ['echo'] }]) {
const { child, host } = endpoints()
const state = processState()
const nativeEnvironment = state.env
@@ -38,16 +38,20 @@ async function setup(limit = 4096) {
await ready.promise
const run = (code: string, maxOutputBytes = 1024, programFormat?: PtcProgramFormat) => peer.run('/', {
code, ...programFormat === undefined ? {} : { programFormat },
retainsState: false, namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes,
retainsState: false, namespaces, 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'])
h.methods.invoke = async (_global, _name, wire) => {
const args = decodePtcJsonWire(wire) as { n: number }
return encodePtcJsonWire(args.n * 2)
}
const result = await h.run('const doubled = await tools.echo({ n: 21 }); console.log("got", doubled); return { doubled };')
expect(decodePtcJsonWire(result.value)).toEqual({ doubled: 42 })
expect(h.logs).toEqual(['got 42'])
expect(h.state.env).toEqual({})
expect(h.state.env).not.toBe(h.nativeEnvironment)
expect(Object.getPrototypeOf(h.state.env)).toBeNull()
@@ -91,14 +95,43 @@ it.each(['function-body', 'repl', 'script'] as const)('keeps an unawaited call p
expect(h.state.exitCode).toBeUndefined()
})
it('reports exhausted output accounting during the run', async () => {
it.each([4, 64])('reports exhausted output accounting during the run with a %s-byte limit', async (maxOutputBytes) => {
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')
expect(await h.run('console.log("x".repeat(100)); return null', maxOutputBytes))
.toEqual({ error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } })
if (maxOutputBytes === 4) expect(h.logs).toEqual([])
await vi.waitFor(() => { expect(limited).toHaveBeenCalledOnce() })
})
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 h = await setup(65_536)
const result = await h.run(code, 65_536)
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) => {
const h = await setup()
expect(await h.run(code, 64)).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 h = await setup(4096, [{ global, names: ['x'], errorClass: { name, memberNameProperty } }])
h.methods.invoke = async () => { throw new Error('denied by host') }
const result = await h.run(`try { await ${global}.x({}) } catch (error) { return { caught: error instanceof ${name}, name: error.name, member: error.${memberNameProperty}, message: error.message } }`)
expect(decodePtcJsonWire(result.value)).toEqual({ caught: true, name, member: 'x', message: 'denied by host' })
})
it('sends no binding calls or output notifications after returning its result', async () => {
const h = await setup()
const invoked = vi.fn(async () => encodePtcJsonWire(1))
@@ -150,9 +183,3 @@ it('records malformed host traffic after completion', async () => {
await h.main
expect(h.state.exitCode).toBe(1)
})
it('reports a missing directory operation before evaluating the program', async () => {
const h = await setup()
delete h.state.chdir
expect(await h.run('return 42')).toHaveProperty('error.message', expect.stringContaining('cannot change its working directory'))
})