fix(e2b): close remaining remote lifecycle races

This commit is contained in:
Tianyi Cui
2026-08-08 22:19:11 +08:00
parent 64b4669a74
commit 97496c3d00
26 files changed
+193 -1498

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/e2b/code-runtime-e2b/README.md
README.md: 171e63a8c1ca70deb63e9860f5088b83401a37d0
README.zh.md: b70984e765d32985436c15dd543263cc06a25715
README.md: a8623f95d16b54b29e53bb9cf2c528b36f121283
README.zh.md: 2b4a37864e3c6755a74f0d2ef6ce38aa39aae24b
+1 -1
View File
@@ -19,7 +19,7 @@ Every value is a positive safe integer. `maxOutputBytes` is at least four bytes,
## Execution and bridge contract
Setup uploads one dependency-free runner under `ctx.e2b.runtimeRoot` and resolves remote Node. For each run, the host wraps and type-strips erasable TypeScript with Node's `stripTypeScriptTypes`, then starts the runner in `ctx.e2b.cwd`. The runner keeps the framed host protocol in a launcher process, forks a controller whose stdout and stderr are bounded data pipes, and creates a fresh worker thread with an empty environment and heap limit. Model writes to native descriptors and inherited child output therefore cannot enter the frame stream; worker and controller pipes drain before the terminal frame. The worker measures active event-loop time and is destroyed after one completion. The enclosing E2B subprocess group is terminated and awaited after every result, timeout, abort, or disposal, so ordinary child processes in that group stop with the run.
Setup uploads one dependency-free runner under `ctx.e2b.runtimeRoot` and resolves remote Node. For each run, the host wraps and type-strips erasable TypeScript with Node's `stripTypeScriptTypes`, then starts the runner in `ctx.e2b.cwd`. The runner keeps the framed host protocol in a launcher process, forks a controller process group whose stdout and stderr are bounded data pipes, and creates a fresh worker thread with an empty environment and heap limit. Model writes to native descriptors and inherited child output therefore cannot enter the frame stream; completion kills the controller group before draining its pipes and emitting the terminal frame. The worker measures active event-loop time and is destroyed after one completion. The enclosing E2B subprocess group is terminated and awaited after every result, timeout, abort, or disposal, so ordinary child processes in either managed group stop with the run.
The bridge uses validated newline-delimited base64 JSON frames because E2B subprocess callbacks expose decoded text. Binding arguments and resolutions use the worker runtime's iterative lossless-JSON wire shape; binding functions execute on the host and typed rejection classes are materialized inside the remote worker. The worker captures the JavaScript intrinsics that its adapter boundary invokes before model code runs, hardening binding transport, output accounting, and completion validation against mutation of those references. The host repeats message validation, call-id deduplication, lossless-JSON checks, and the outer-output ledger.
+1 -1
View File
@@ -19,7 +19,7 @@
## 执行与桥接契约
设置阶段会在 `ctx.e2b.runtimeRoot` 下上传一个无依赖的 runner,并解析远程 Node。每次运行时,宿主会包装仅使用可擦除语法的 TypeScript,再用 Node 的 `stripTypeScriptTypes` 剥离类型,然后在 `ctx.e2b.cwd` 中启动 runner。runner 会把面向宿主的分帧协议保留在 launcher 进程内,派生一个以 stdout 和 stderr 作为有界数据管道的 controller,再创建一个具有空环境与堆上限的全新 worker 线程。因此,模型对原生描述符的写入和继承的子进程输出无法进入分帧流;worker 与 controller 管道会在发出终结帧前排空。worker 会测量事件循环活跃时间,并在一次运行结算后销毁。每当运行返回结果、超时、中止或因资源释放终止时,系统都会终止外围的 E2B 进程组并等待其退出,因此组内的普通子进程会随本次运行一同停止。
设置阶段会在 `ctx.e2b.runtimeRoot` 下上传一个无依赖的 runner,并解析远程 Node。每次运行时,宿主会包装仅使用可擦除语法的 TypeScript,再用 Node 的 `stripTypeScriptTypes` 剥离类型,然后在 `ctx.e2b.cwd` 中启动 runner。runner 会把面向宿主的分帧协议保留在 launcher 进程内,派生一个以 stdout 和 stderr 作为有界数据管道的 controller 进程组,再创建一个具有空环境与堆上限的全新 worker 线程。因此,模型对原生描述符的写入和继承的子进程输出无法进入分帧流;运行结算会先终止 controller 进程组,再排空其管道并发出终结帧。worker 会测量事件循环活跃时间,并在一次运行结算后销毁。每当运行返回结果、超时、中止或因资源释放终止时,系统都会终止外围的 E2B 进程组并等待其退出,因此任一受管组内的普通子进程会随本次运行一同停止。
由于 E2B 进程管理回调公开的是已解码文本,桥接层使用经过验证、以换行分隔的 base64 JSON 帧。绑定参数与 resolve 值使用 worker 运行时的迭代式无损 JSON wire 形状;绑定函数在宿主执行,类型化的 reject 类则在远程 worker 内物化。worker 会在模型代码运行前捕获其适配器边界调用的 JavaScript intrinsic,从而增强绑定传输、输出记账与完成值验证对这些引用修改的抵御能力。宿主会再次执行消息验证、调用 id 去重和无损 JSON 检查,并用外层输出账本再次计量。
@@ -35,6 +35,18 @@ const waitForChildExit = child => {
return new Promise(resolve => { child.once('exit', resolve) })
}
const killControllerGroup = child => {
if (process.platform !== 'win32' && Number.isSafeInteger(child.pid)) {
try {
process.kill(-child.pid, 'SIGKILL')
} catch (error) {
if (!error || typeof error !== 'object' || error.code !== 'ESRCH') throw error
}
return
}
child.kill('SIGKILL')
}
const jsonStringBytes = text => Buffer.byteLength(JSON.stringify(text))
const truncateLog = (text, available) => {
@@ -74,7 +86,7 @@ const runLauncher = () => {
const stdoutDrained = waitForPipeDrain(current.stdout)
const stderrDrained = waitForPipeDrain(current.stderr)
const exited = waitForChildExit(current)
current.kill('SIGKILL')
killControllerGroup(current)
await Promise.all([exited, stdoutDrained, stderrDrained])
})
: Promise.resolve()
@@ -112,6 +124,7 @@ const runLauncher = () => {
maxOutputBytes = message.maxOutputBytes
controller = fork(fileURLToPath(import.meta.url), [], {
env: { DSH_CODE_RUNTIME_CONTROLLER: '1' },
detached: process.platform !== 'win32',
execArgv: [],
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
})
@@ -173,7 +186,7 @@ const runLauncher = () => {
})
}
})
input.on('close', () => { if (controller && !settling) controller.kill('SIGKILL') })
input.on('close', () => { if (controller && !settling) killControllerGroup(controller) })
}
const runController = () => {
@@ -1,8 +1,9 @@
import { spawn } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough, Writable } from 'node:stream'
import { setTimeout as delay } from 'node:timers/promises'
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import type { Sandbox } from '@deepseek-ai/dsh-e2b'
@@ -271,6 +272,55 @@ describe('E2BCodeRuntime', () => {
expect(Buffer.byteLength(nativeOutput)).toBe(expectedBytes)
})
it.skipIf(process.platform === 'win32')('reaps descendant-held controller pipes before completion', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-e2b-code-descendant-'))
const marker = join(directory, 'started')
const release = join(directory, 'release')
const childSource = `
const fs = require('node:fs')
fs.writeFileSync(${JSON.stringify(marker)}, 'started')
const timer = setInterval(() => {
if (fs.existsSync(${JSON.stringify(release)})) clearInterval(timer)
}, 10)
`
let running: ReturnType<typeof runInstalledRunner> | undefined
try {
running = runInstalledRunner(`
const fs = await import('node:fs')
const childProcess = await import('node:child_process')
childProcess.spawn(process.execPath, ['-e', ${JSON.stringify(childSource)}], {
stdio: ['ignore', 'inherit', 'inherit'],
})
while (!fs.existsSync(${JSON.stringify(marker)})) await new Promise(resolve => setTimeout(resolve, 5))
return true
`)
const deadline = Date.now() + 2_000
for (;;) {
try {
await access(marker)
break
} catch (error: unknown) {
if (Date.now() >= deadline) throw error
await delay(10)
}
}
const completed = await Promise.race([
running.then(() => true),
delay(500).then(() => false),
])
await writeFile(release, '')
const { messages, stderr } = await running
expect(completed).toBe(true)
expect(stderr).toBe('')
expect(messages.at(-1)).toEqual({ type: 'done', value: [true] })
} finally {
await writeFile(release, '').catch(() => undefined)
await running?.catch(() => undefined)
await rm(directory, { recursive: true, force: true })
}
})
it('prepares the remote runner and returns logs and a lossless completion', async () => {
const handle = new FakeHandle((message, current) => {
if ((message as { type?: string }).type !== 'boot') return