fix(code-runtime): support SEA startup and immediate control writes
This commit is contained in:
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(() => {}) }) })
|
||||
|
||||
Reference in new issue
Block a user