fix(code-runtime): support SEA startup and immediate control writes

This commit is contained in:
Tianyi Cui committed 2026-09-12 15:03:01 +08:00
1 parent 98d3af0683
commit 7f5eb6db9a
19 files changed
+145 -49

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-node/README.md
README.md: d0d395922b26e4eea84a27a08757abe71ef99866
README.zh.md: 1c052adee04ceeab2bcbd3dfe391a5424beb0daa
README.md: 76e3e4a7cb2d758797ca130f498fe6ec3f697b35
README.zh.md: 9835128af67d4b57266818aa06ab6cee21412e15
@@ -83,7 +83,7 @@ The host owns policy, deadlines, binding lookup and process cleanup. The child o
### Launch and control
The host strips erasable types, resolves the executable and bootstrap in the configured execution world, wraps the argv through `ctx.sandbox`, and spawns through `ctx.subprocess`. After adopting the inherited control channel, the child retains only executable-search and Windows system paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. The retained native paths keep nested Windows process creation functional. Explicit Node arguments avoid inheriting host loader or inspector flags.
The host strips erasable types, resolves the executable and bootstrap in the configured execution world, wraps the argv through `ctx.sandbox`, and spawns through `ctx.subprocess`. After adopting the inherited control channel, the child retains only executable-search and Windows system paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. The retained native paths keep nested Windows process creation functional. 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. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
@@ -83,7 +83,7 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责
### 启动与控制
Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 包装 argv,并通过 `ctx.subprocess` 启动。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径和 Windows 系统路径,并将程序可见的 `process.env` 替换为空字典。保留的原生路径使嵌套 Windows 进程创建仍可正常工作。显式 Node 参数避免继承 Host 的加载器或调试器标志。
Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 包装 argv,并通过 `ctx.subprocess` 启动。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径和 Windows 系统路径,并将程序可见的 `process.env` 替换为空字典。保留的原生路径使嵌套 Windows 进程创建仍可正常工作。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。
带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
@@ -7,6 +7,11 @@ 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
@@ -19,7 +24,7 @@ export class JsonChannel {
private payloadBytes = 0
private queuedBytes = 0
private closed = false
private tail: Promise<void> = Promise.resolve()
private readonly writes = new Set<PendingWrite>()
constructor(
stream: Duplex,
@@ -34,9 +39,14 @@ export class JsonChannel {
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 => { if (!this.closed) this.failure(error, 'io') }
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
@@ -73,7 +83,7 @@ export class JsonChannel {
}
/**
* Queue a bounded frame and honor stream backpressure.
* 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.
*/
@@ -87,28 +97,31 @@ export class JsonChannel {
const header = Buffer.alloc(4)
header.writeUInt32BE(body.length)
this.queuedBytes += body.length
const write = async (): Promise<void> => {
if (this.closed) throw new Error('control channel is closed')
await new Promise<void>((resolve, reject) => {
const stop = (error?: Error): void => {
this.stream.off('error', onError)
this.stream.off('close', onClose)
if (error) reject(error)
else resolve()
}
const onError = (error: Error): void => { stop(error) }
const onClose = (): void => { stop(new Error('control channel closed during a write')) }
this.stream.once('error', onError)
this.stream.once('close', onClose)
this.stream.cork()
this.stream.write(header)
this.stream.write(body, (error) => { stop(error ?? undefined) })
this.stream.uncork()
})
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()
},
}
const result = this.tail.then(write).finally(() => { this.queuedBytes -= body.length })
this.tail = result.catch(() => {})
return result
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. */
@@ -116,11 +129,12 @@ export class JsonChannel {
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 this.tail }
async drain(): Promise<void> { await Promise.allSettled([...this.writes].map(write => write.promise)) }
}
@@ -210,14 +210,19 @@ export class NodeCodeRuntime extends CodeRuntime {
// Abort callbacks can settle execution before or during an awaited operation.
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (settled) return await result.promise
const argv = [executable, `--max-old-space-size=${this.config.maxOldGenerationSizeMb}`, ...bootstrapArgs(this.ctx.fs, this.config, this.config.maxMessageBytes)]
const packaged = 'pkg' in process && this.config.bootstrapPath === undefined
const heapFlag = `--max-old-space-size=${this.config.maxOldGenerationSizeMb}`
const argv = [executable, ...packaged ? [] : [heapFlag], ...bootstrapArgs(this.ctx.fs, this.config, this.config.maxMessageBytes)]
confined = policy.mode === 'danger-full-access' ? undefined : this.ctx.sandbox.confine(argv, { ...policy, mode: policy.mode })
if (confined !== undefined) sandbox.enforcement = confined.enforcement
// Native launchers need executable search and Windows system paths before the child installs its model environment.
const env: NodeJS.ProcessEnv = Object.fromEntries(Object.keys(process.env)
.filter(key => !STARTUP_ENVIRONMENT_NAMES.has(key.toUpperCase()))
.map(key => [key, undefined]))
if ('pkg' in process && this.config.bootstrapPath === undefined) env.DSH_CODE_RUNTIME_NODE = '1'
if (packaged) {
env.DSH_CODE_RUNTIME_NODE = '1'
env.NODE_OPTIONS = heapFlag
}
handle = this.ctx.subprocess.spawn({ argv: confined?.argv ?? argv, cwd: spec.cwd, env, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', control: 'pipe' }, graceMs: this.config.graceMs, signal })
const launched = handle
if (launched.control === undefined || launched.stdout === undefined || launched.stderr === undefined) {
@@ -328,7 +333,10 @@ export class NodeCodeRuntime extends CodeRuntime {
else void launched.done.then(processFinished, (failure: unknown) => { finish({ kind: 'worker-exit', message: messageOf(failure) }) })
})
channel = transport
void launched.done.then((outcome) => { setImmediate(() => { processFinished(outcome) }) }, (error: unknown) => { finish({ kind: confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], spec.cwd) ? 'sandbox-unavailable' : 'worker-exit', message: messageOf(error) }) })
void launched.done.then((outcome) => {
// Allow queued control-frame callbacks to run before classifying a command exit.
setImmediate(() => { processFinished(outcome) })
}, (error: unknown) => { finish({ kind: confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], spec.cwd) ? 'sandbox-unavailable' : 'worker-exit', message: messageOf(error) }) })
} catch (error: unknown) {
finish({ kind: error instanceof SandboxUnavailableError ? 'sandbox-unavailable' : parsing ? 'exception' : 'worker-exit', message: messageOf(error) })
}
@@ -124,7 +124,7 @@ it('ignores callbacks already captured by an emission when an earlier listener c
}
})
it('rejects queued writes if the owner closes before they start', async () => {
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 })
@@ -138,8 +138,10 @@ it('bounds queued frames while a receiver is not reading', async () => {
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) => {
@@ -154,3 +156,11 @@ it.each(['error', 'close'])('settles a blocked write when the stream emits %s',
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')
})
@@ -439,8 +439,9 @@ describe('Node runtime host failures', () => {
const spec = h.spawn.mock.calls[0]?.[0]
expect(spec?.env?.DSH_CODE_RUNTIME_NODE).toBe('1')
expect(Object.hasOwn(spec?.env ?? {}, 'PATH')).toBe(false)
expect(spec?.argv.at(-1)).toBe('134217728')
expect(Object.entries(spec?.env ?? {}).filter(([, value]) => value !== undefined)).toEqual([['DSH_CODE_RUNTIME_NODE', '1']])
expect(spec?.argv).toEqual([process.execPath, '134217728'])
expect(Object.fromEntries(Object.entries(spec?.env ?? {}).filter(([, value]) => value !== undefined)))
.toEqual({ DSH_CODE_RUNTIME_NODE: '1', NODE_OPTIONS: '--max-old-space-size=512' })
} finally {
if (prior === undefined) Reflect.deleteProperty(process, 'pkg')
else Object.defineProperty(process, 'pkg', prior)
@@ -100,6 +100,13 @@ describe('Node program process', () => {
expect((await run({ program, bindings: [] })).error?.kind).toBe('timeout')
})
it('retains console output emitted immediately before a non-yielding program', async () => {
const { run } = await setup({ timeoutMs: 1000, graceMs: 50 })
const result = await run({ program: 'console.log("before hot loop"); for (;;) {}', bindings: [] })
expect(result.error?.kind).toBe('timeout')
expect(result.logs).toEqual(['before hot loop'])
})
it('does not pause the deadline while a binding is pending', async () => {
const { run } = await setup({ timeoutMs: 1000, graceMs: 50 })
const result = await run({ program: 'void tools.wait({}); for (;;) {}', bindings: bindings({ wait: () => new Promise(() => {}) }) })