fix(e2b): harden remote startup and teardown

This commit is contained in:
Tianyi Cui
2026-07-29 13:01:37 +08:00
parent ca79f4364c
commit 091af03a81
11 changed files with 182 additions and 38 deletions

View File

@@ -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/subprocess-e2b/README.md
README.md: 66481fb1c5edd4c124c2c58aaa5c86a1683e5a3b
README.zh.md: 5e4b8bbe401cf4a565ccd53270df9fb6b5de9315
README.md: aaac559e60162c3051cc6f06cbcfac011f5eb59a
README.zh.md: 904e4fd1d9a13082ddfc8183c0e42abc25bd1caf

View File

@@ -6,15 +6,15 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr
## Behavior
- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `-1` until the wrapper publishes and the adapter validates its process-group id; stdin and ordinary observation wait for that publication, while cancellation can stop the provisional SDK handle first.
- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `-1` until the wrapper publishes and the adapter validates its process-group id; stdin and ordinary observation wait for that publication. An owned startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean.
- **Execution-world coordinates** — `cwd` and private `runtimeRoot` come from the shared owner; executable lookup verifies absolute paths or resolves a bare name against the sandbox PATH plus explicit overrides.
- **Linux process groups** — a quoted wrapper starts each argv under `exec setsid --wait` and records its actual process-group id plus private status files beneath `ctx.e2b.runtimeRoot/processes`. The handle waits for that file instead of treating the SDK command PID as its published identity. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback; TERM delivery or probe failures also force that escalation. Process-table probes treat groups containing only zombie or dead entries as quiescent. A failed transaction is observable through `waitForExit()` and may be retried, while any proven quiescence permanently fences later termination against PID reuse. Before publication, cancellation uses the provisional SDK handle; if publication fails, rollback kills and verifies the provisional group before startup rejects. After publication, a monitoring failure also rolls back the group before rejecting. Service disposal rejects new starts, terminates and joins every retained process group, then awaits SDK settlement and private cleanup before the sandbox owner disposes.
- **Environment boundary** — the wrapper starts from the sandbox command environment, removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names, then restores every valid `spec.env` entry as an explicit caller opt-in; empty names, `=`, and NUL framing violations reject before launch. Host ambient variables never enter the sandbox implicitly. Private environment files are removed after consumption, and failed command or terminal setup removes its private state before rejecting.
- **Linux process groups** — a quoted wrapper starts each argv under `exec setsid --wait` and records its actual process-group id plus private status files beneath `ctx.e2b.runtimeRoot/processes`. The handle waits for that file instead of treating the SDK command PID as its published identity. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback; TERM delivery or probe failures also force that escalation. Process-table probes treat groups containing only zombie or dead entries as quiescent. A failed transaction is observable through `waitForExit()` and may be retried, while any proven quiescence permanently fences later termination against PID reuse. Before publication, cancellation uses both the provisional group and SDK handle and proves the group empty before reporting quiescence; if publication fails, rollback applies the same proof before startup rejects. After publication, a monitoring failure also rolls back the group before rejecting. Service disposal rejects new starts, terminates and joins every retained process group, then awaits SDK settlement and private cleanup before the sandbox owner disposes.
- **Environment boundary** — the sandbox command environment crosses the SDK callback boundary as base64 ASCII before one strict UTF-8 decode, then the wrapper removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names and restores every valid `spec.env` entry as an explicit caller opt-in; empty names, `=`, and NUL framing violations reject before launch. Host ambient variables never enter the sandbox implicitly. Private environment files are removed after consumption, and failed command or terminal setup removes its private state before rejecting.
- **Stdio projection** — the remote wrapper branches raw bytes into optional bounded spill files, frames each live chunk as newline-delimited base64 ASCII, and the host incrementally restores bytes across arbitrary SDK callback boundaries. Pipe mode writes those bytes to host Node streams; inherit mode writes them to the harness process streams; collect mode retains a bounded host tail with offset reads. The wrapper publishes the direct command status before waiting for inherited writers. For collect or inherit output, the adapter disconnects an incomplete SDK stream after `graceMs`, withholds its partial spill, and returns that status while retaining the remote group for `waitForExit()` and termination. Natural raw-pipe completion instead awaits lossless transport and preserves backpressure; explicit termination destroys the host pipes and releases blocked output before remote cleanup. Batch and streaming stdin use the SDK handle.
- **Terminal sessions** — `spawnTerminal()` uses E2B's byte PTY API, installs the exact argv and scrubbed environment through private mode-`0600` files, reports the foreground process group, sends real signals, and tears down every live group in the remote terminal session before settlement; zombie-only groups are already quiescent. A private random output boundary discards the E2B bootstrap shell's prompt and echoed runner command while preserving every requested-process byte, including its first prompt. PTY allocation is awaited through handle publication before cancellation is observed, so owned rollback can clean the published handle. Setup and teardown own the private state transaction, abort pending setup during service disposal, fence publication, and retain an unproven setup cleanup for disposal retry. Prompt detection, scrollback, readiness, and owner policy remain in `dsh-pty-local`.
- **Sandbox disappearance** — `SandboxNotFoundError` during process or terminal liveness, termination, rollback, or disconnect proves the remote execution world cannot retain work, so cleanup treats it as quiescent; unrelated failures remain observable.
The base E2B image supplies the runtime and Bash/GNU utilities this adapter invokes: `node`, `bash`, `setsid`, `ps`, `awk`, `tr`, `env`, `chmod`, `tee`, `head`, `rm`, and `kill`. A custom template must retain compatible commands and E2B PTY support.
The base E2B image supplies the runtime and Bash/GNU utilities this adapter invokes: `node`, `bash`, `setsid`, `ps`, `awk`, `tr`, `env`, `base64`, `chmod`, `tee`, `head`, `rm`, and `kill`. A custom template must retain compatible commands and E2B PTY support.
## Model Experience

View File

@@ -6,15 +6,15 @@
## 行为
- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid``-1`stdin 和常规观察会等待该发布,而取消操作可以先停止临时 SDK 句柄。
- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid``-1`stdin 和常规观察会等待该发布。自有启动信号会在分配前中止环境和私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。
- **执行世界坐标**`cwd` 和私有 `runtimeRoot` 来自共享所有者;可执行文件查找会验证绝对路径,或根据沙箱 PATH 加显式覆盖来解析裸名称。
- **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退TERM 信号发送或探测失败也会强制触发该升级。进程表探测会把仅含僵尸或已死亡条目的进程组视为完全停稳。失败的事务可通过 `waitForExit()` 观察,并可重试;任何已证明的完全停稳都会永久防止后续终止操作命中复用的 PID。发布前取消操作使用临时 SDK 句柄;如果发布失败,回滚会终止并验证临时进程组,随后启动操作才会拒绝。发布后,监控失败也会在拒绝前回滚进程组。服务 dispose资源释放会拒绝新的启动请求、终止并等待每个保留进程组退出再等待 SDK 结算和私有清理完成,之后沙箱所有者才会释放。
- **环境边界**包装层从沙箱命令环境开始,移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*``*SECRET*``*TOKEN*`把每个有效的 `spec.env` 条目恢复为调用方显式选择空名称、`=` 和违反 NUL 分帧规则的条目会在启动前被拒绝。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。
- **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退TERM 信号发送或探测失败也会强制触发该升级。进程表探测会把仅含僵尸或已死亡条目的进程组视为完全停稳。失败的事务可通过 `waitForExit()` 观察,并可重试;任何已证明的完全停稳都会永久防止后续终止操作命中复用的 PID。发布前取消操作会同时使用临时进程组与 SDK 句柄,并在报告完全停稳前证明进程组为空;如果发布失败,回滚会在启动拒绝前执行相同证明。发布后,监控失败也会在拒绝前回滚进程组。服务 dispose资源释放会拒绝新的启动请求、终止并等待每个保留进程组退出再等待 SDK 结算和私有清理完成,之后沙箱所有者才会释放。
- **环境边界**:沙箱命令环境会先以 base64 ASCII 跨越 SDK 回调边界,再进行一次严格 UTF-8 解码;随后包装层移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*``*SECRET*``*TOKEN*`把每个有效的 `spec.env` 条目恢复为调用方显式选择空名称、`=` 和违反 NUL 分帧规则的条目会在启动前被拒绝。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。
- **stdio 投影**:远程包装层先把原始字节分流到可选的有界 spill 文件,再把每个实时分片编码为换行分隔的 base64 ASCII 帧;宿主会跨任意 SDK 回调边界增量恢复字节。pipe 模式把这些字节写入宿主 Node 流inherit 模式把字节写入 harness 进程流collect 模式保留有界的宿主尾部,并支持基于偏移量读取。包装层会在等待继承管道的写入方之前发布直接命令状态。对于 collect 或 inherit 输出,超过 `graceMs` 后,适配器会断开未完成的 SDK 流,不公开其中不完整的 spill并返回该状态同时保留远程进程组供 `waitForExit()` 和终止操作使用。原始 pipe 自然完成时,会等待无损传输完成并保留背压;显式终止则会销毁宿主 pipe并在远程清理前释放受阻的输出写入。批量 stdin 和流式 stdin 都使用 SDK 句柄。
- **终端会话**`spawnTerminal()` 使用 E2B 的字节 PTY API以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并在结算前清理远程终端会话中仍存活的每个进程组;仅含僵尸进程的进程组已经完全停稳。私有随机输出边界会丢弃 E2B 引导 shell 的提示符和回显的 runner 命令同时保留请求进程的每个字节包括其第一个提示符。PTY 分配会一直等待到句柄发布后才观察取消以便由承担清理责任的回滚清理已发布句柄。setup 与 teardown 负责私有状态事务,在服务 dispose 期间中止待处理的 setup、阻止发布并保留未证明已完成的 setup 清理事务,供 dispose 重试。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。
- **沙箱消失**:在进程或终端的存活探测、终止、回滚或断开连接期间出现 `SandboxNotFoundError`,证明远程执行环境无法保留工作,因此清理会将其视为完全停稳;其他故障仍可观察。
基础 E2B 镜像提供该适配器调用的运行时和 Bash/GNU 工具:`node``bash``setsid``ps``awk``tr``env``chmod``tee``head``rm``kill`。自定义模板必须保留兼容的命令和 E2B PTY 支持。
基础 E2B 镜像提供该适配器调用的运行时和 Bash/GNU 工具:`node``bash``setsid``ps``awk``tr``env``base64``chmod``tee``head``rm``kill`。自定义模板必须保留兼容的命令和 E2B PTY 支持。
## 模型体验

View File

@@ -1,7 +1,31 @@
/** Shared remote-environment scrubbing for E2B process and terminal launchers. */
import { Buffer } from 'node:buffer'
import type { Sandbox } from '@deepseek-ai/dsh-e2b'
import { SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess'
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
/**
* Read the remote environment through ASCII base64 so SDK callback chunking cannot corrupt UTF-8.
* @param sandbox - shared E2B execution world.
* @param signal - optional cancellation for the control-plane request.
* @returns the complete NUL-delimited UTF-8 environment.
*/
export async function readRemoteEnvironment(sandbox: Sandbox, signal?: AbortSignal): Promise<string> {
const result = await sandbox.commands.run(
'set -o pipefail; env -0 | base64 -w 0',
signal === undefined ? {} : { signal },
)
const encoded = result.stdout.trim()
if (!BASE64.test(encoded)) throw new Error('subprocess-e2b: remote environment transport returned invalid base64')
try {
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(encoded, 'base64'))
} catch (error: unknown) {
throw new Error('subprocess-e2b: remote environment is not valid UTF-8', { cause: error })
}
}
/**
* Parse an E2B NUL-delimited environment while removing harness-private and credential-shaped names.
* @param raw - The complete NUL-delimited remote environment.

View File

@@ -18,7 +18,7 @@ import type {
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import { serializeRemoteEnvironment } from './environment.ts'
import { readRemoteEnvironment, serializeRemoteEnvironment } from './environment.ts'
import { E2BBase64Decoder, E2B_OUTPUT_COMPLETE_FRAME, E2BOutputReader } from './output.ts'
const GROUP_POLL_MS = 20
@@ -198,6 +198,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private readonly stdoutDecoder = new E2BBase64Decoder()
private readonly stderrDecoder = new E2BBase64Decoder()
private readonly outputTermination = new AbortController()
private readonly startupController = new AbortController()
private readonly stdoutReader: E2BOutputReader | undefined
private readonly stderrReader: E2BOutputReader | undefined
private readonly paths: RemotePaths
@@ -206,6 +207,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private outputTransportError: Error | undefined
private outputDrainExpired = false
private stateDirectoryCreated = false
private preparing = true
private invalidHandleQuiescent = false
private provisionalHandleQuiescent = false
private terminationStarted = false
@@ -264,6 +266,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
terminate(): void {
if (this.terminationFenced || this.quiescenceProven || this.terminationAttempt !== undefined) return
this.terminationStarted = true
if (this.preparing) this.startupController.abort(new Error('subprocess-e2b: command terminated during startup'))
this.outputTermination.abort()
this.stdout?.destroy()
this.stderr?.destroy()
@@ -298,6 +301,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
const attempt = this.terminationAttempt
if (attempt !== undefined && await waitWithSignal(attempt, signal) === WAIT_ABORTED) return false
this.throwTerminationFailure()
/* v8 ignore else -- successful provisional cleanup always records one proof; failures throw above. */
if (this.invalidHandleQuiescent || this.provisionalHandleQuiescent) {
this.markQuiescent()
return true
@@ -351,6 +355,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
try {
sandbox = await this.runtime.getSandbox()
await this.prepareState(sandbox)
this.preparing = false
const handle = await sandbox.commands.run(
commandText(this.spec, this.paths),
{
@@ -405,7 +410,9 @@ export class E2BSubprocessHandle implements SubprocessHandle {
await this.finalizeSpills(sandbox)
return outcome
} catch (error: unknown) {
this.commandState.resolve(undefined)
const canceledPreparation = this.preparing
&& this.terminationStarted
&& this.startupController.signal.aborted
let failure = await this.rollbackPublishedFailure(error)
if (sandbox !== undefined && this.stateDirectoryCreated) {
try {
@@ -417,9 +424,12 @@ export class E2BSubprocessHandle implements SubprocessHandle {
)
}
}
this.commandState.resolve(undefined)
this.readyState.reject(failure)
if (canceledPreparation && failure === error) return { exitCode: null, signal: 'SIGTERM' }
throw failure
} finally {
this.preparing = false
this.spec.signal?.removeEventListener('abort', this.onAbort)
this.stdout?.end()
this.stderr?.end()
@@ -427,19 +437,21 @@ export class E2BSubprocessHandle implements SubprocessHandle {
}
private async prepareState(sandbox: Sandbox): Promise<void> {
const ambient = await sandbox.commands.run('env -0')
await sandbox.files.makeDir(this.stateDir)
const signal = this.startupController.signal
const ambient = await readRemoteEnvironment(sandbox, signal)
await sandbox.files.makeDir(this.stateDir, { signal })
this.stateDirectoryCreated = true
await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`)
await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`, { signal })
const files = [
{ path: this.paths.pid, data: '' },
{ path: this.paths.status, data: '' },
{ path: this.paths.environment, data: serializeRemoteEnvironment(ambient.stdout, this.spec.env) },
{ path: this.paths.environment, data: serializeRemoteEnvironment(ambient, this.spec.env) },
...(hasSpill(this.spec.stdio.stdout) ? [{ path: this.paths.stdout, data: '' }] : []),
...(hasSpill(this.spec.stdio.stderr) ? [{ path: this.paths.stderr, data: '' }] : []),
]
await sandbox.files.write(files)
await sandbox.commands.run(`chmod 600 -- ${files.map(file => quoteE2BShellArg(file.path)).join(' ')}`)
await sandbox.files.write(files, { signal })
await sandbox.commands.run(`chmod 600 -- ${files.map(file => quoteE2BShellArg(file.path)).join(' ')}`, { signal })
signal.throwIfAborted()
}
private async writeBatchStdin(handle: CommandHandle): Promise<void> {
@@ -639,17 +651,23 @@ export class E2BSubprocessHandle implements SubprocessHandle {
}
let handleFailure: unknown
try {
await handle.kill()
this.provisionalHandleQuiescent = true
if (!await handle.kill()) handleFailure = new Error('E2B SDK kill did not report command termination')
} catch (error: unknown) {
handleFailure = error
}
if (!groupDelivered && handleFailure !== undefined) {
if (!groupDelivered && await this.groupAlive(sandbox, handle.pid)) {
throw new AggregateError(
[...(groupFailure === undefined ? [] : [groupFailure]), handleFailure],
[
...(groupFailure === undefined ? [] : [groupFailure]),
...(handleFailure === undefined
? [new Error('E2B SDK kill did not quiesce the provisional process group')]
: [handleFailure]),
],
'subprocess-e2b: force termination failed through both process-group and SDK transports',
)
}
while (await this.groupAlive(sandbox, handle.pid)) await waitTick()
this.provisionalHandleQuiescent = true
return
}
const sandbox = await this.runtime.getSandbox()

View File

@@ -20,7 +20,7 @@ import type {
SubprocessTerminalSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import { serializeRemoteEnvironment } from './environment.ts'
import { readRemoteEnvironment, serializeRemoteEnvironment } from './environment.ts'
const POLL_MS = 20
@@ -355,7 +355,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
inputWaiting: false,
}
} catch (error: unknown) {
if (error instanceof CommandExitError && this.topLevelExited) return undefined
if (error instanceof CommandExitError && (error.exitCode === 1 || this.topLevelExited)) return undefined
throw error
}
}
@@ -470,8 +470,8 @@ export async function spawnE2BTerminal(
let completion: Promise<CommandResult> | undefined
let stateDirectoryCreated = false
try {
const ambient = await sandbox.commands.run('env -0', signalOpts(spec.signal))
const environment = serializeRemoteEnvironment(ambient.stdout, spec.env)
const ambient = await readRemoteEnvironment(sandbox, spec.signal)
const environment = serializeRemoteEnvironment(ambient, spec.env)
const argv = serializeValues(spec.argv, 'argv')
await sandbox.files.makeDir(stateDir)
stateDirectoryCreated = true

View File

@@ -39,6 +39,7 @@ class FakeCommandHandle {
kills = 0
disconnects = 0
killError: unknown
killResult = true
disconnectError: unknown
private readonly result = Promise.withResolvers<CommandResult>()
private settled = false
@@ -61,7 +62,7 @@ class FakeCommandHandle {
this.kills += 1
if (this.killError !== undefined) throw this.killError
this.onKill()
return true
return this.killResult
}
async disconnect(): Promise<void> {
@@ -109,7 +110,9 @@ class FakeSandbox {
sdkKillStops = true
alive = true
zombieOnly = false
ambient = 'PATH=/ambient/bin\0KEEP=safe\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
ambient = 'PATH=/ambient/bin\0KEEP=safe\0UNICODE=你好\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
environmentWire: string | undefined
environmentRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
processGroupId = '4242\n'
exitStatus = ''
readonly processGroupReads: string[] = []
@@ -233,9 +236,14 @@ class FakeSandbox {
commands: {
run: async (command: string, options?: StartOptions | { signal?: AbortSignal }): Promise<CommandHandle | CommandResult> => {
this.commandsSeen.push(command)
if (command === 'env -0') {
if (command.includes('env -0 | base64')) {
await this.environmentRequest?.(options?.signal)
if (this.envError !== undefined) throw this.envError
return { exitCode: 0, stdout: this.ambient, stderr: '' }
return {
exitCode: 0,
stdout: this.environmentWire ?? Buffer.from(this.ambient).toString('base64'),
stderr: '',
}
}
if (command.startsWith('set -o pipefail; ps -eo pgid=,stat=')) {
this.beforeProbe?.()
@@ -397,7 +405,7 @@ describe('E2BSubprocessHandle', () => {
expect(command).not.toContain('explicit-secret')
expect(command).not.toContain('hyphen-value')
expect(command).not.toContain('${!dsh_e2b_name}')
expect(fake.commandsSeen).toContain('env -0')
expect(fake.commandsSeen).toContain('set -o pipefail; env -0 | base64 -w 0')
expect(command).toContain('mapfile -d')
expect(command).toContain('dsh_e2b_node="$(command -v node)"')
expect(command).toContain('"$dsh_e2b_env_bin" -i "$dsh_e2b_node" -e')
@@ -413,7 +421,7 @@ describe('E2BSubprocessHandle', () => {
'/workspace/.dsh-e2b/processes/one/stderr.log',
])
expect(fake.writtenFileData.get('/workspace/.dsh-e2b/processes/one/environment')).toBe(
'PATH=/bin\0KEEP=safe\0FOO-BAR=hyphen-value\0--split-string=literal-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0',
'PATH=/bin\0KEEP=safe\0UNICODE=你好\0FOO-BAR=hyphen-value\0--split-string=literal-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0',
)
let piped = ''
@@ -806,6 +814,33 @@ describe('E2BSubprocessHandle', () => {
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
})
it('aborts a stalled preparation request before reporting startup quiescence', async () => {
const fake = new FakeSandbox()
let preparationSignal: AbortSignal | undefined
fake.environmentRequest = async (signal) => {
preparationSignal = signal
await new Promise<never>((_resolve, reject) => {
const rejectAbort = (): void => {
const reason: unknown = signal?.reason
reject(reason instanceof Error ? reason : new Error(String(reason)))
}
if (signal?.aborted === true) {
rejectAbort()
return
}
signal?.addEventListener('abort', rejectAbort, { once: true })
})
}
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/stalled-preparation')
await vi.waitFor(() => { expect(preparationSignal).toBeDefined() })
handle.terminate()
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
await expect(handle.waitForExit()).resolves.toBe(true)
expect(preparationSignal?.aborted).toBe(true)
expect(fake.startOptions).toBeUndefined()
})
it('kills through the provisional SDK handle before process-group publication', async () => {
const fake = new FakeSandbox()
fake.deferProcessGroupRead()
@@ -822,6 +857,32 @@ describe('E2BSubprocessHandle', () => {
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
})
it('does not treat an unsuccessful SDK fallback as provisional group quiescence', async () => {
const fake = new FakeSandbox()
fake.deferProcessGroupRead()
fake.trapsTerm = true
fake.delaysKill = true
fake.delaysKillCompletion = true
fake.sdkKillStops = false
fake.handle.killResult = false
const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/provisional-sdk-false')
await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() })
handle.terminate()
await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) })
let quiescent = false
const waiting = handle.waitForExit().then((value) => { quiescent = value })
await new Promise(resolve => setTimeout(resolve, 10))
expect(quiescent).toBe(false)
fake.alive = false
await waiting
expect(quiescent).toBe(true)
fake.releaseProcessGroupRead()
fake.finish()
await handle.done
})
it('bounds a quiescence observer while provisional termination is awaiting the controller', async () => {
const fake = new FakeSandbox()
fake.deferProcessGroupRead()
@@ -895,6 +956,23 @@ describe('E2BSubprocessHandle', () => {
await expect(absentHandle.waitForExit()).resolves.toBe(true)
absentGroup.releaseProcessGroupRead()
await absentHandle.done
const optimisticSdk = new FakeSandbox()
optimisticSdk.deferProcessGroupRead()
optimisticSdk.signalErrors.push(commandError(1), commandError(1))
optimisticSdk.sdkKillStops = false
const optimisticHandle = new E2BSubprocessHandle(
runtime(optimisticSdk),
spec({ graceMs: 1 }),
'/runtime/pre-publication-optimistic-sdk',
)
await vi.waitFor(() => { expect(optimisticSdk.startOptions).toBeDefined() })
optimisticHandle.terminate()
await expect(optimisticHandle.waitForExit()).rejects.toThrow('force termination failed through both')
optimisticHandle.terminate()
await expect(optimisticHandle.waitForExit()).resolves.toBe(true)
optimisticSdk.releaseProcessGroupRead()
await optimisticHandle.done
})
it('honors an already-aborted signal when constructing the asynchronous handle directly', async () => {
@@ -1024,6 +1102,24 @@ describe('E2BSubprocessHandle', () => {
await expect(envHandle.done).rejects.toThrow('ambient lookup failed')
expect(envFailure.removed).toEqual([])
const malformedEnvironment = new FakeSandbox()
malformedEnvironment.environmentWire = '%'
const malformedEnvironmentHandle = new E2BSubprocessHandle(
runtime(malformedEnvironment),
spec(),
'/runtime/malformed-environment',
)
await expect(malformedEnvironmentHandle.done).rejects.toThrow('invalid base64')
const invalidUtf8Environment = new FakeSandbox()
invalidUtf8Environment.environmentWire = Buffer.from([0xff]).toString('base64')
const invalidUtf8EnvironmentHandle = new E2BSubprocessHandle(
runtime(invalidUtf8Environment),
spec(),
'/runtime/invalid-utf8-environment',
)
await expect(invalidUtf8EnvironmentHandle.done).rejects.toThrow('not valid UTF-8')
const cleanupFailure = new FakeSandbox()
cleanupFailure.backgroundError = new Error('start failed before credential consumption')
cleanupFailure.nextRemoveError = new Error('credential cleanup failed')
@@ -1509,6 +1605,7 @@ describe('E2BSubprocessService', () => {
const { ctx, fiber } = await service(fake)
const subprocess = ctx.subprocess
const handle = subprocess.spawn(spec())
await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() })
const disposing = fiber.dispose()
await flush()
expect(() => subprocess.spawn(spec())).toThrow('service is disposing')

View File

@@ -89,7 +89,7 @@ class FakeTerminalSandbox {
readonly directories: string[] = []
readonly writes = new Map<string, string>()
createOptions: Parameters<Sandbox['pty']['create']>[0] | undefined
ambient = 'KEEP=visible\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
ambient = 'KEEP=visible\0UNICODE=你好\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
ready: string | Error = 'ready\n'
readyMisses = 0
readyReads = 0
@@ -162,7 +162,9 @@ class FakeTerminalSandbox {
this.commandFailure = undefined
throw error
}
if (command === 'env -0') return { exitCode: 0, stdout: this.ambient, stderr: '' }
if (command.includes('env -0 | base64')) {
return { exitCode: 0, stdout: Buffer.from(this.ambient).toString('base64'), stderr: '' }
}
if (command.includes('command -v -- ')) {
return { exitCode: 0, stdout: this.resolvedExecutable, stderr: '' }
}
@@ -263,6 +265,7 @@ describe('E2B terminal allocation', () => {
expect(fake.createOptions).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace', timeoutMs: 0, envs: { TERM: 'dumb' } })
expect(fake.inputs[0]?.data.toString()).toContain("exec /bin/bash '/runtime/terminal-one/runner.bash'")
expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('KEEP=visible\0')
expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('UNICODE=你好\0')
expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('TOKEN_EXPLICIT=kept\0')
expect(fake.writes.get('/runtime/terminal-one/environment')).not.toContain('secret')
expect(fake.writes.get('/runtime/terminal-one/environment')).not.toContain('DSH_STALE')
@@ -653,6 +656,8 @@ describe('E2B terminal lifecycle', () => {
fake.foreground = 'invalid\n'
await expect(terminal.inspectForeground()).rejects.toThrow('cannot resolve foreground')
fake.foregroundFailure = commandError(1)
await expect(terminal.inspectForeground()).resolves.toBeUndefined()
fake.foregroundFailure = commandError(2)
await expect(terminal.inspectForeground()).rejects.toBeInstanceOf(CommandExitError)
fake.clearOnTerm = true
terminal.terminate()