fix(e2b): harden remote process lifecycle

This commit is contained in:
Tianyi Cui
2026-07-29 05:31:22 +08:00
parent 8877f5d582
commit fd78d9bc58
18 changed files with 586 additions and 153 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: e0805345e708c67c6de721d95641e2366a7f990e
README.zh.md: f83969c794e50f0658aa86218a0f5f3c89d53b56
README.md: 58f13b264324d2168d4bc264b60100cdafa0a366
README.zh.md: d547e4e8d66f629edcef753651180fe1806ad9bf

View File

@@ -10,10 +10,10 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr
- **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. If publication fails, the SDK PID remains the provisional `exec setsid` group id; rollback kills and verifies that group before startup rejects. Service disposal terminates and joins every retained handle 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 `spec.env` entry as an explicit caller opt-in. Host ambient variables never enter the sandbox implicitly.
- **Stdio projection** — pipe mode forwards E2B callbacks into host Node streams; inherit mode forwards them to the harness process streams; collect mode retains a bounded host tail with offset reads. Optional complete spill files are written remotely and advertised only while within their cap. Batch and streaming stdin use the SDK handle.
- **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. 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 group in the remote terminal session before settlement. Prompt detection, scrollback, readiness, and owner policy remain in `dsh-pty-local`.
The base E2B image supplies the Bash/GNU utilities this adapter invokes: `bash`, `setsid`, `ps`, `awk`, `tr`, `env`, `chmod`, `tee`, `head`, 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`, `chmod`, `tee`, `head`, and `kill`. A custom template must retain compatible commands and E2B PTY support.
## Model Experience
@@ -25,11 +25,10 @@ No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **The SDK still retains complete command output in host memory** — E2B `CommandHandle.stdout` and `.stderr` accumulate even when this adapter exposes bounded tails, so the subprocess seam's normal host-memory bound is not achieved.
- **Command-pipe output is text-decoded by the SDK** — valid UTF-8 protocol traffic, including the exercised LSP composition and Code Runtime's ASCII/base64 frames, is supported; arbitrary binary protocols and invalid UTF-8 are not byte-faithful.
- **The SDK still retains complete command output in host memory** — E2B `CommandHandle.stdout` and `.stderr` accumulate the base64 transport even when this adapter exposes bounded raw-byte tails, so the subprocess seam's normal host-memory bound is not achieved and transport retention is larger than the source stream.
- **Synchronous-PID consumers are unsupported** — `pid` remains `-1` during remote startup; consumers that require a positive PID immediately, including the ACP child backend, cannot use this provider unchanged.
- **Reconnect does not reconstruct handles** — remote PID/status/spill files survive a retained sandbox, but a new harness process does not rebuild live `SubprocessHandle` objects or output cursors from them.
- **Remote state accumulates when retained** — process directories and valid spill files remain under `.dsh-e2b`; this POC supplies no retention sweep.
- **Signal attribution is inferred** — when termination was requested and E2B reports a nonzero exit code, the adapter reports the last requested signal because the SDK result does not identify the terminating signal.
- **E2B exposes no signal fact** — only an adapter-requested `SIGTERM` or `SIGKILL` is reported as a signal; every unrequested SDK exit remains an exit code, including values shaped like `128 + signal`.
- **Exact terminal stdin-wait inspection is unavailable** — E2B exposes the foreground process group but not the syscall evidence needed to prove it is waiting on fd 0, so the generic PTY backend falls back to controlled prompt markers and bounded silence.
- **Linux utility and E2B transport semantics are assumed** — there is no Windows, arbitrary-template, escaped-session recovery, or network-partition fidelity layer.

View File

@@ -10,10 +10,10 @@
- **执行世界坐标**`cwd` 和私有 `runtimeRoot` 来自共享所有者;可执行文件查找会验证绝对路径,或根据沙箱 PATH 加显式覆盖来解析裸名称。
- **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退。如果发布失败SDK PID 仍为临时的 `exec setsid` 进程组 ID回滚会终止并验证该进程组随后启动操作才会以拒绝结束。服务 dispose资源释放会在沙箱所有者释放前终止并等待每个保留句柄退出。
- **环境边界**:包装层从沙箱命令环境开始,移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*``*SECRET*``*TOKEN*`),再把每个 `spec.env` 条目恢复为调用方显式选择。宿主环境变量绝不会隐式进入沙箱。
- **stdio 投影**pipe 模式把 E2B 回调转发到宿主 Node 流inherit 模式把回调转发到 harness 进程流collect 模式保留有界的宿主尾部,并支持基于偏移量读取。可选的完整 spill 文件写在远程,并且只有未超过其上限时才会对外公布。批量 stdin 和流式 stdin 都使用 SDK 句柄。
- **stdio 投影**远程包装层先把原始字节分流到可选的有界 spill 文件,再把每个实时分片编码为换行分隔的 base64 ASCII 帧;宿主会跨任意 SDK 回调边界增量恢复字节。pipe 模式把这些字节写入宿主 Node 流inherit 模式把字节写入 harness 进程流collect 模式保留有界的宿主尾部,并支持基于偏移量读取。批量 stdin 和流式 stdin 都使用 SDK 句柄。
- **终端会话**`spawnTerminal()` 使用 E2B 的字节 PTY API以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境报告前台进程组发送真实信号并在结算前清理远程终端会话中的每个进程组。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。
基础 E2B 镜像提供该适配器调用的 Bash/GNU 工具:`bash``setsid``ps``awk``tr``env``chmod``tee``head``kill`。自定义模板必须保留兼容的命令和 E2B PTY 支持。
基础 E2B 镜像提供该适配器调用的运行时和 Bash/GNU 工具:`node``bash``setsid``ps``awk``tr``env``chmod``tee``head``kill`。自定义模板必须保留兼容的命令和 E2B PTY 支持。
## 模型体验
@@ -25,11 +25,10 @@
## 已知限制与延后工作
- **SDK 仍会在宿主内存中保留完整命令输出**即使本适配器公开的是有界尾部E2B `CommandHandle.stdout``.stderr` 仍会持续累积,因此无法达到进程管理 seam 通常提供的宿主内存边界。
- **命令管道输出由 SDK 解码为文本**:支持有效的 UTF-8 协议流量,包括已经过测试的 LSP 组合与 Code Runtime 的 ASCII/base64 帧;任意二进制协议和无效 UTF-8 不具备字节保真。
- **SDK 仍会在宿主内存中保留完整命令输出**:即使本适配器公开的是有界原始字节尾部E2B `CommandHandle.stdout``.stderr` 仍会累积 base64 传输内容,因此无法达到进程管理 seam 通常提供的宿主内存边界,而且传输保留量大于源数据流
- **不支持需要同步 PID 的消费方**:远程启动期间,`pid` 保持为 `-1`;包括 ACP 子进程后端在内,要求立即获得正 PID 的消费方无法原样使用本提供方。
- **重新连接不会重建句柄**:保留沙箱后,远程 PID状态spill 文件仍然存在,但新的 harness 进程不会据此重建实时 `SubprocessHandle` 对象或输出游标。
- **保留沙箱时会累积远程状态**:进程目录和有效的 spill 文件会留在 `.dsh-e2b` 下;本 POC 不提供保留清理。
- **信号归因依靠推断**:如果已经请求终止,而 E2B 报告非零退出码,适配器会报告最后请求的信号,因为 SDK 结果不标识终止信号
- **E2B 不公开信号事实**:只有适配器请求的 `SIGTERM``SIGKILL` 会报告为信号;其他未请求的 SDK 退出始终保留为退出码,包括形似 `128 + signal` 的值
- **无法精确检查终端 stdin 等待状态**E2B 会公开前台进程组,但不提供证明其正在等待 fd 0 所需的 syscall 证据,因此通用 PTY 后端会回退到受控提示符标记与有界静默机制。
- **依赖 Linux 工具与 E2B 传输语义**:没有 Windows、任意模板、逃逸会话恢复或网络分区的保真层。

View File

@@ -3,6 +3,64 @@
import { Buffer } from 'node:buffer'
import type { SubprocessOutputRead, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
const BASE64_TEXT = /^[A-Za-z0-9+/]+={0,2}$/u
/** Reserved non-base64 frame proving that one remote encoder reached clean EOF. */
export const E2B_OUTPUT_COMPLETE_FRAME = '!dsh-e2b-output-complete!'
/** Incrementally decode newline-delimited base64 frames emitted by one remote encoder. */
export class E2BBase64Decoder {
private pending = ''
private complete = false
/**
* Decode every complete newline-delimited frame in one arbitrarily split SDK callback.
* @param text - ASCII base64 frames from E2B's decoded callback.
* @returns the complete raw bytes made available by this callback.
*/
push(text: string): Buffer {
if (text.length === 0) return Buffer.alloc(0)
this.pending += text
const decoded: Buffer[] = []
for (;;) {
const boundary = this.pending.indexOf('\n')
if (boundary < 0) break
const frame = this.pending.slice(0, boundary)
this.pending = this.pending.slice(boundary + 1)
if (frame === E2B_OUTPUT_COMPLETE_FRAME) {
if (this.complete) throw new Error('subprocess-e2b: duplicate output transport completion')
this.complete = true
continue
}
if (this.complete) throw new Error('subprocess-e2b: output transport continued after completion')
if (!BASE64_TEXT.test(frame)) {
throw new Error('subprocess-e2b: invalid base64 output transport')
}
const bytes = Buffer.from(frame, 'base64')
if (bytes.toString('base64') !== frame) {
throw new Error('subprocess-e2b: invalid base64 output transport')
}
decoded.push(bytes)
}
return Buffer.concat(decoded)
}
/**
* Validate clean encoder completion, or discard an interrupted trailing frame after requested termination.
* @param requireComplete - Whether natural completion requires the reserved EOF frame.
*/
finish(requireComplete = true): void {
if (!requireComplete) {
this.pending = ''
return
}
if (this.pending.length > 0) {
throw new Error('subprocess-e2b: truncated base64 output transport')
}
if (!this.complete) throw new Error('subprocess-e2b: incomplete output transport')
}
}
/** Offset reader used for one collect-mode E2B stream. */
export class E2BOutputReader implements SubprocessOutputReader {
private chunks: Buffer[] = []
@@ -27,12 +85,12 @@ export class E2BOutputReader implements SubprocessOutputReader {
}
/**
* Append one decoded SDK output event.
* @param text - Event text delivered by E2B.
* Append one byte-faithful decoded transport event.
* @param bytes - Raw command bytes recovered from the ASCII SDK transport.
*/
push(text: string): void {
if (text.length === 0) return
const chunk = Buffer.from(text)
push(bytes: Uint8Array): void {
if (bytes.length === 0) return
const chunk = Buffer.from(bytes)
this.totalBytes += chunk.length
this.chunks.push(chunk)
this.retainedBytes += chunk.length

View File

@@ -8,6 +8,7 @@ import {
quoteE2BShellArg,
} from '@deepseek-ai/dsh-e2b'
import type { CommandHandle, CommandResult, Sandbox } from '@deepseek-ai/dsh-e2b'
import { SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess'
import type {
SubprocessCollect,
SubprocessHandle,
@@ -16,9 +17,21 @@ import type {
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import { E2BOutputReader } from './output.ts'
import { E2BBase64Decoder, E2B_OUTPUT_COMPLETE_FRAME, E2BOutputReader } from './output.ts'
const GROUP_POLL_MS = 20
const OUTPUT_ENCODER_SOURCE = [
'(async () => {',
' for await (const chunk of process.stdin) {',
" if (!process.stdout.write(chunk.toString('base64') + '\\n')) {",
" await new Promise(resolve => process.stdout.once('drain', resolve))",
' }',
' }',
` if (!process.stdout.write(${JSON.stringify(E2B_OUTPUT_COMPLETE_FRAME)} + '\\n')) {`,
" await new Promise(resolve => process.stdout.once('drain', resolve))",
' }',
'})().catch(() => { process.exitCode = 1 })',
].join('\n')
function isCollect(mode: SubprocessOutputMode): mode is SubprocessCollect {
return mode !== 'pipe' && mode !== 'inherit'
@@ -60,40 +73,65 @@ interface RemotePaths {
stderr: string
}
function explicitEnvironment(env: Readonly<Record<string, string>> | undefined): string {
return Object.entries(env ?? {})
.map(([name, value]) => `${name}=${value}\0`)
.join('')
function remoteEnvironment(raw: string, explicit: Readonly<Record<string, string>> | undefined): string {
const environment = new Map<string, string>()
for (const entry of raw.split('\0')) {
if (entry.length === 0) continue
const separator = entry.indexOf('=')
if (separator <= 0) continue
const name = entry.slice(0, separator)
if (name.startsWith('DSH_') || SENSITIVE_ENV_PATTERN.test(name)) continue
environment.set(name, entry.slice(separator + 1))
}
for (const [name, value] of Object.entries(explicit ?? {})) environment.set(name, value)
return [...environment].map(([name, value]) => `${name}=${value}\0`).join('')
}
function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string {
const encoder = `"$dsh_e2b_env_bin" -i "$dsh_e2b_node" -e ${quoteE2BShellArg(OUTPUT_ENCODER_SOURCE)}`
const stdoutRedirect = hasSpill(spec.stdio.stdout)
? `> >(tee --output-error=warn-nopipe >(head -c ${spec.stdio.stdout.spill.maxBytes} > ${quoteE2BShellArg(paths.stdout)}))`
: ''
? `> >("$dsh_e2b_tee" --output-error=warn-nopipe >("$dsh_e2b_head" -c ${spec.stdio.stdout.spill.maxBytes} > ${quoteE2BShellArg(paths.stdout)}) | ${encoder} 2>/dev/null)`
: `> >(${encoder} 2>/dev/null)`
const stderrRedirect = hasSpill(spec.stdio.stderr)
? `2> >(tee --output-error=warn-nopipe >(head -c ${spec.stdio.stderr.spill.maxBytes} > ${quoteE2BShellArg(paths.stderr)}) >&2)`
: ''
? `2> >("$dsh_e2b_tee" --output-error=warn-nopipe >("$dsh_e2b_head" -c ${spec.stdio.stderr.spill.maxBytes} > ${quoteE2BShellArg(paths.stderr)}) | ${encoder} >&2 2>/dev/null)`
: `2> >(${encoder} >&2 2>/dev/null)`
const inner = [
'set +e',
'umask 077',
'dsh_e2b_pgid="$(ps -o pgid= -p "$$" | tr -d " ")"',
'dsh_e2b_env_bin=$1',
'dsh_e2b_node=$2',
'dsh_e2b_ps=$3',
'dsh_e2b_tr=$4',
'dsh_e2b_tee=$5',
'dsh_e2b_head=$6',
'shift 6',
'dsh_e2b_pgid="$("$dsh_e2b_ps" -o pgid= -p "$$" | "$dsh_e2b_tr" -d " ")"',
`printf '%s\\n' "$dsh_e2b_pgid" > ${quoteE2BShellArg(paths.pid)}`,
`mapfile -d '' -t dsh_e2b_explicit < ${quoteE2BShellArg(paths.environment)}`,
`mapfile -d '' -t dsh_e2b_env < ${quoteE2BShellArg(paths.environment)}`,
`: > ${quoteE2BShellArg(paths.environment)}`,
'dsh_e2b_env=()',
"while IFS= read -r -d '' dsh_e2b_entry; do",
' dsh_e2b_name="${dsh_e2b_entry%%=*}"',
' case "${dsh_e2b_name^^}" in DSH_*|*KEY*|*SECRET*|*TOKEN*) continue ;; esac',
' dsh_e2b_env+=("$dsh_e2b_entry")',
'done < <(env -0)',
`env -i "\${dsh_e2b_env[@]}" "\${dsh_e2b_explicit[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(),
`"$dsh_e2b_env_bin" -i "\${dsh_e2b_env[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(),
'dsh_e2b_status=$?',
'wait',
`printf '%s\\n' "$dsh_e2b_status" > ${quoteE2BShellArg(paths.status)}`,
'exit "$dsh_e2b_status"',
].join('\n')
const argv = spec.argv.map(quoteE2BShellArg).join(' ')
return `exec setsid --wait -- bash -c ${quoteE2BShellArg(inner)} dsh-e2b ${argv}`
const bootstrap = [
`mapfile -d '' -t dsh_e2b_env < ${quoteE2BShellArg(paths.environment)}`,
'dsh_e2b_env_bin="$(command -v env)"',
'dsh_e2b_setsid="$(command -v setsid)"',
'dsh_e2b_bash="$(command -v bash)"',
'dsh_e2b_node="$(command -v node)"',
'dsh_e2b_ps="$(command -v ps)"',
'dsh_e2b_tr="$(command -v tr)"',
'dsh_e2b_tee="$(command -v tee)"',
'dsh_e2b_head="$(command -v head)"',
'for dsh_e2b_tool in "$dsh_e2b_env_bin" "$dsh_e2b_setsid" "$dsh_e2b_bash" "$dsh_e2b_node" "$dsh_e2b_ps" "$dsh_e2b_tr" "$dsh_e2b_tee" "$dsh_e2b_head"; do',
' [[ "$dsh_e2b_tool" == /* && -x "$dsh_e2b_tool" ]] || exit 125',
'done',
`exec "$dsh_e2b_env_bin" -i "\${dsh_e2b_env[@]}" "$dsh_e2b_setsid" --wait -- "$dsh_e2b_bash" -c ${quoteE2BShellArg(inner)} dsh-e2b "$dsh_e2b_env_bin" "$dsh_e2b_node" "$dsh_e2b_ps" "$dsh_e2b_tr" "$dsh_e2b_tee" "$dsh_e2b_head" ${argv}`,
].join('\n')
return bootstrap
}
function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
@@ -128,10 +166,14 @@ export class E2BSubprocessHandle implements SubprocessHandle {
readonly done: Promise<SubprocessOutcome>
private readonly readyState = Promise.withResolvers<CommandHandle>()
private readonly stdoutDecoder = new E2BBase64Decoder()
private readonly stderrDecoder = new E2BBase64Decoder()
private readonly stdoutReader: E2BOutputReader | undefined
private readonly stderrReader: E2BOutputReader | undefined
private readonly paths: RemotePaths
private remotePid = -1
private commandHandle: CommandHandle | undefined
private outputTransportError: Error | undefined
private terminationRequested = false
private terminationSignal: NodeJS.Signals | null = null
private termination: Promise<void> | undefined
@@ -195,7 +237,8 @@ export class E2BSubprocessHandle implements SubprocessHandle {
try {
handle = await this.readyForWait(signal)
} catch {
return true
handle = this.commandHandle
if (handle === undefined) return true
}
if (handle === undefined) return false
let sandbox: Sandbox
@@ -205,7 +248,8 @@ export class E2BSubprocessHandle implements SubprocessHandle {
if (isAborted(signal)) return false
throw error
}
while (await this.groupAlive(sandbox, this.remotePid, signal)) {
const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
while (await this.groupAlive(sandbox, processGroupId, signal)) {
if (!await waitTick(signal)) return false
}
return !isAborted(signal)
@@ -248,6 +292,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
if (!Number.isSafeInteger(handle.pid) || handle.pid <= 0) {
throw new Error(`subprocess-e2b: E2B returned invalid command pid ${handle.pid}`)
}
this.commandHandle = handle
const completion = handle.wait()
void completion.catch(() => {})
try {
@@ -266,6 +311,10 @@ export class E2BSubprocessHandle implements SubprocessHandle {
this.readyState.resolve(handle)
await this.writeBatchStdin(handle)
const outcome = await this.waitForCommand(completion)
if (this.outputTransportError !== undefined) throw this.outputTransportError
const requireCompleteOutput = this.terminationSignal === null
this.stdoutDecoder.finish(requireCompleteOutput)
this.stderrDecoder.finish(requireCompleteOutput)
await this.finalizeSpills(sandbox)
return outcome
} catch (error: unknown) {
@@ -279,12 +328,13 @@ 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)
await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`)
const files = [
{ path: this.paths.pid, data: '' },
{ path: this.paths.status, data: '' },
{ path: this.paths.environment, data: explicitEnvironment(this.spec.env) },
{ path: this.paths.environment, data: remoteEnvironment(ambient.stdout, this.spec.env) },
...(hasSpill(this.spec.stdio.stdout) ? [{ path: this.paths.stdout, data: '' }] : []),
...(hasSpill(this.spec.stdio.stderr) ? [{ path: this.paths.stderr, data: '' }] : []),
]
@@ -303,25 +353,34 @@ export class E2BSubprocessHandle implements SubprocessHandle {
}
private async dispatchOutput(stream: 'stdout' | 'stderr', data: string): Promise<void> {
let bytes: Buffer
try {
bytes = stream === 'stdout' ? this.stdoutDecoder.push(data) : this.stderrDecoder.push(data)
} catch (error: unknown) {
this.outputTransportError ??= asError(error)
const target = stream === 'stdout' ? this.stdout : this.stderr
target?.destroy(this.outputTransportError)
return
}
try {
if (stream === 'stdout') {
this.stdoutReader?.push(data)
await this.writeOutput(this.stdout, this.spec.stdio.stdout === 'inherit' ? process.stdout : undefined, data)
this.stdoutReader?.push(bytes)
await this.writeOutput(this.stdout, this.spec.stdio.stdout === 'inherit' ? process.stdout : undefined, bytes)
return
}
this.stderrReader?.push(data)
await this.writeOutput(this.stderr, this.spec.stdio.stderr === 'inherit' ? process.stderr : undefined, data)
this.stderrReader?.push(bytes)
await this.writeOutput(this.stderr, this.spec.stdio.stderr === 'inherit' ? process.stderr : undefined, bytes)
} catch (error: unknown) {
const target = stream === 'stdout' ? this.stdout : this.stderr
target?.destroy(asError(error))
}
}
private async writeOutput(pipe: PassThrough | undefined, inherited: NodeJS.WriteStream | undefined, data: string): Promise<void> {
private async writeOutput(pipe: PassThrough | undefined, inherited: NodeJS.WriteStream | undefined, data: Uint8Array): Promise<void> {
const target = pipe ?? inherited
if (target === undefined || data.length === 0) return
if (target.destroyed) throw new Error('subprocess output stream is closed')
if (target.write(Buffer.from(data))) return
if (target.write(data)) return
await new Promise<void>((resolve, reject) => {
const onDrain = (): void => { cleanup(); resolve() }
const onError = (error: Error): void => { cleanup(); reject(error) }
@@ -369,10 +428,10 @@ export class E2BSubprocessHandle implements SubprocessHandle {
}
private async rollbackUnpublishedGroup(sandbox: Sandbox, handle: CommandHandle): Promise<void> {
// The background command begins with `exec setsid`, so E2B's command PID is
// the provisional group id even before the private publication file can be
// trusted. Kill that group before the SDK-PID fallback, then prove no group
// member survived before rejecting startup.
// The bootstrap ends in an exec chain through the scrubbed environment and
// `setsid`, so E2B's command PID is the provisional group id even before the
// private publication file can be trusted. Kill that group before the SDK-PID
// fallback, then prove no group member survived before rejecting startup.
try {
await this.signalGroup(sandbox, handle.pid, 'KILL')
} finally {
@@ -382,23 +441,25 @@ export class E2BSubprocessHandle implements SubprocessHandle {
}
private async terminateRemote(): Promise<void> {
let handle: CommandHandle
let handle: CommandHandle | undefined
try {
handle = await this.readyState.promise
} catch {
return
handle = this.commandHandle
}
if (handle === undefined) return
const sandbox = await this.runtime.getSandbox()
const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
this.terminationSignal = 'SIGTERM'
await this.signalGroup(sandbox, this.remotePid, 'TERM')
await this.signalGroup(sandbox, processGroupId, 'TERM')
const deadline = Date.now() + this.spec.graceMs
while (Date.now() < deadline && await this.groupAlive(sandbox, this.remotePid)) {
while (Date.now() < deadline && await this.groupAlive(sandbox, processGroupId)) {
await waitTick()
}
if (!await this.groupAlive(sandbox, this.remotePid)) return
if (!await this.groupAlive(sandbox, processGroupId)) return
this.terminationSignal = 'SIGKILL'
try {
await this.signalGroup(sandbox, this.remotePid, 'KILL')
await this.signalGroup(sandbox, processGroupId, 'KILL')
} finally {
await handle.kill().catch(() => false)
}

View File

@@ -1,7 +1,6 @@
/** E2B PTY allocation and process-session ownership for the subprocess seam. */
import { Buffer } from 'node:buffer'
import { constants } from 'node:os'
import { PassThrough } from 'node:stream'
import { posix } from 'node:path'
import {
@@ -53,13 +52,8 @@ function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function commandSignal(exitCode: number): NodeJS.Signals | null {
const number = exitCode - 128
if (number <= 0) return null
for (const [name, value] of Object.entries(constants.signals)) {
if (value === number) return name as NodeJS.Signals
}
return null
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
function parsePositiveId(value: string, message: string): number {
@@ -121,6 +115,132 @@ async function waitUntilReady(
}
}
async function sessionProcessGroups(sandbox: Sandbox, sessionId: number): Promise<number[]> {
const result = await sandbox.commands.run(
`ps -eo sid=,pgid= | awk '$1 == ${sessionId} { print $2 }'`,
)
const groups = new Set<number>()
for (const raw of result.stdout.trim().split(/\s+/)) {
if (raw.length === 0) continue
const group = parsePositiveId(
raw,
`subprocess-e2b: invalid process group ${JSON.stringify(raw)} in terminal session ${sessionId}`,
)
if (group <= 1) {
throw new Error(`subprocess-e2b: unsafe process group ${group} in terminal session ${sessionId}`)
}
groups.add(group)
}
return [...groups]
}
async function signalGroups(sandbox: Sandbox, groups: number[], signal: 'TERM' | 'KILL'): Promise<void> {
try {
await sandbox.commands.run(`kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`)
} catch (error: unknown) {
if (!(error instanceof CommandExitError)) throw error
}
}
async function awaitSessionEmpty(
sandbox: Sandbox,
sessionId: number,
graceMs: number,
kill = false,
): Promise<number[]> {
const deadline = Date.now() + graceMs
for (;;) {
const groups = await sessionProcessGroups(sandbox, sessionId)
if (groups.length === 0 || Date.now() >= deadline) return groups
if (kill) await signalGroups(sandbox, groups, 'KILL')
await delay(Math.min(POLL_MS, Math.max(1, deadline - Date.now())))
}
}
async function rollbackUnpublishedTerminal(
sandbox: Sandbox,
handle: CommandHandle,
completion: Promise<CommandResult>,
graceMs: number,
): Promise<void> {
let topLevelExited = false
void completion.then(
() => { topLevelExited = true },
() => { topLevelExited = true },
)
const validPid = Number.isSafeInteger(handle.pid) && handle.pid > 1
const attemptFailures: Error[] = []
let sessionId: number | undefined
if (validPid) {
sessionId = handle.pid
try {
sessionId = await terminalSessionId(sandbox, handle.pid)
} catch (_sessionLookupFailure) {
// E2B's PTY leader is also the provisional POSIX session leader, so its
// PID remains usable after the setup lookup itself fails or is canceled.
}
try {
let groups = await sessionProcessGroups(sandbox, sessionId)
if (groups.length > 0) {
await signalGroups(sandbox, groups, 'TERM')
groups = await awaitSessionEmpty(sandbox, sessionId, graceMs)
}
if (groups.length > 0) {
await signalGroups(sandbox, groups, 'KILL')
await awaitSessionEmpty(sandbox, sessionId, graceMs, true)
}
} catch (error: unknown) {
attemptFailures.push(asError(error))
}
}
// Completion can settle while any awaited provider cleanup above is running.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!topLevelExited) {
if (validPid) {
try {
await sandbox.pty.kill(handle.pid)
} catch (error: unknown) {
attemptFailures.push(asError(error))
}
}
// The awaited PTY fallback can settle completion before the SDK fallback.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!topLevelExited) {
try {
await handle.kill()
} catch (error: unknown) {
attemptFailures.push(asError(error))
}
}
await Promise.race([completion.catch(() => undefined), delay(graceMs)])
}
const proofFailures: Error[] = []
if (sessionId !== undefined) {
try {
const groups = await awaitSessionEmpty(sandbox, sessionId, graceMs, true)
if (groups.length > 0) {
proofFailures.push(new Error(
`subprocess-e2b: terminal setup rollback failed; surviving process groups: ${groups.join(', ')}`,
))
}
} catch (error: unknown) {
proofFailures.push(asError(error))
}
}
// The bounded completion race above updates this callback-owned state.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!topLevelExited) {
proofFailures.push(new Error(`subprocess-e2b: terminal setup rollback failed; surviving pid: ${handle.pid}`))
}
if (proofFailures.length > 0) {
throw new AggregateError(
[...attemptFailures, ...proofFailures],
'subprocess-e2b: terminal setup rollback did not reach quiescence',
)
}
await handle.disconnect()
}
/** One E2B PTY and all process groups in its remote process session. */
export class E2BTerminalHandle implements SubprocessTerminalHandle {
readonly pid: number
@@ -203,8 +323,9 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
return { exitCode: result.exitCode, signal: null }
} catch (error: unknown) {
if (error instanceof CommandExitError) {
const signal = this.terminationSignal ?? commandSignal(error.exitCode)
return signal === null ? { exitCode: error.exitCode, signal: null } : { exitCode: null, signal }
return this.terminationSignal === null
? { exitCode: error.exitCode, signal: null }
: { exitCode: null, signal: this.terminationSignal }
}
this.output.destroy(error instanceof Error ? error : new Error(String(error)))
throw error
@@ -214,49 +335,12 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
}
}
private async sessionProcessGroups(): Promise<number[]> {
const result = await this.sandbox.commands.run(
`ps -eo sid=,pgid= | awk '$1 == ${this.sessionId} { print $2 }'`,
)
const groups = new Set<number>()
for (const raw of result.stdout.trim().split(/\s+/)) {
if (raw.length === 0) continue
const group = parsePositiveId(
raw,
`subprocess-e2b: invalid process group ${JSON.stringify(raw)} in terminal session ${this.sessionId}`,
)
if (group <= 1) {
throw new Error(`subprocess-e2b: unsafe process group ${group} in terminal session ${this.sessionId}`)
}
groups.add(group)
}
return [...groups]
}
private async signalGroups(groups: number[], signal: 'TERM' | 'KILL'): Promise<void> {
try {
await this.sandbox.commands.run(`kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`)
} catch (error: unknown) {
if (!(error instanceof CommandExitError)) throw error
}
}
private async awaitSessionEmpty(kill = false): Promise<number[]> {
const deadline = Date.now() + this.graceMs
for (;;) {
const groups = await this.sessionProcessGroups()
if (groups.length === 0 || Date.now() >= deadline) return groups
if (kill) await this.signalGroups(groups, 'KILL')
await delay(Math.min(POLL_MS, Math.max(1, deadline - Date.now())))
}
}
private async closeOnce(): Promise<void> {
let groups = await this.sessionProcessGroups()
let groups = await sessionProcessGroups(this.sandbox, this.sessionId)
if (groups.length > 0) {
this.terminationSignal = 'SIGTERM'
await this.signalGroups(groups, 'TERM')
groups = await this.awaitSessionEmpty()
await signalGroups(this.sandbox, groups, 'TERM')
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.graceMs)
}
if (groups.length === 0 && !this.topLevelExited) {
await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
@@ -264,7 +348,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
if (groups.length > 0 || !this.topLevelExited) {
this.terminationSignal = 'SIGKILL'
if (!this.topLevelExited) await this.sandbox.pty.kill(this.pid)
groups = await this.awaitSessionEmpty(true)
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.graceMs, true)
if (!this.topLevelExited) await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
}
if (groups.length > 0) {
@@ -348,9 +432,20 @@ export async function spawnE2BTerminal(
)
} catch (error: unknown) {
output.destroy()
if (handle !== undefined) await handle.kill().catch(() => false)
if (completion !== undefined) await completion.catch(() => {})
let cleanupError: Error | undefined
if (handle !== undefined && completion !== undefined) {
try {
await rollbackUnpublishedTerminal(sandbox, handle, completion, spec.graceMs)
} catch (rollbackError: unknown) {
cleanupError = asError(rollbackError)
}
} else if (handle !== undefined) {
await handle.kill().catch(() => false)
}
await sandbox.files.remove(stateDir).catch(() => {})
if (cleanupError !== undefined) {
throw new AggregateError([asError(error), cleanupError], asError(error).message)
}
throw error
}
}

View File

@@ -10,7 +10,7 @@ import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
import * as E2BSubprocessInvariant from '../src/invariant.ts'
import { E2BOutputReader } from '../src/output.ts'
import { E2BBase64Decoder, E2B_OUTPUT_COMPLETE_FRAME, E2BOutputReader } from '../src/output.ts'
import { E2BSubprocessHandle } from '../src/process.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { describe, expect, it, vi } from 'vitest'
@@ -91,6 +91,7 @@ class FakeSandbox {
trapsTerm = false
delaysKill = false
alive = true
ambient = 'PATH=/ambient/bin\0KEEP=safe\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
processGroupId = '4242\n'
readonly processGroupReads: string[] = []
beforeProbe: (() => void) | undefined
@@ -110,15 +111,35 @@ class FakeSandbox {
finish(exitCode = 0): void {
this.alive = false
if (exitCode === 0) this.handle.succeed(0)
else this.handle.fail(exitCode)
void this.completeOutput().then(
() => {
if (exitCode === 0) this.handle.succeed(0)
else this.handle.fail(exitCode)
},
(error: unknown) => { this.handle.crash(error) },
)
}
async completeOutput(): Promise<void> {
await Promise.all([
this.stdoutWire(`${E2B_OUTPUT_COMPLETE_FRAME}\n`),
this.stderrWire(`${E2B_OUTPUT_COMPLETE_FRAME}\n`),
])
}
async stdout(data: string): Promise<void> {
await this.startOptions?.onStdout?.(data)
await this.stdoutWire(data.length === 0 ? '' : `${Buffer.from(data).toString('base64')}\n`)
}
async stderr(data: string): Promise<void> {
await this.stderrWire(data.length === 0 ? '' : `${Buffer.from(data).toString('base64')}\n`)
}
async stdoutWire(data: string): Promise<void> {
await this.startOptions?.onStdout?.(data)
}
async stderrWire(data: string): Promise<void> {
await this.startOptions?.onStderr?.(data)
}
@@ -147,6 +168,7 @@ class FakeSandbox {
commands: {
run: async (command: string, options?: StartOptions | { signal?: AbortSignal }): Promise<CommandHandle | CommandResult> => {
this.commandsSeen.push(command)
if (command === 'env -0') return { exitCode: 0, stdout: this.ambient, stderr: '' }
if (command.startsWith('kill -0 ')) {
this.beforeProbe?.()
if (options?.signal?.aborted === true) throw new DOMException('aborted', 'AbortError')
@@ -221,11 +243,35 @@ async function flush(): Promise<void> {
}
describe('E2BOutputReader', () => {
it('decodes base64 across arbitrary callback boundaries and rejects malformed framing', () => {
const decoder = new E2BBase64Decoder()
expect(decoder.push('')).toEqual(Buffer.alloc(0))
expect(decoder.push('5')).toEqual(Buffer.alloc(0))
expect(decoder.push('L2')).toEqual(Buffer.alloc(0))
expect(decoder.push('g\n').toString()).toBe('你')
expect(decoder.push('YQ==\nYg==\n').toString()).toBe('ab')
expect(decoder.push(`${Buffer.from([0, 255]).toString('base64')}\n`)).toEqual(Buffer.from([0, 255]))
expect(decoder.push(`${E2B_OUTPUT_COMPLETE_FRAME}\n`)).toEqual(Buffer.alloc(0))
decoder.finish()
expect(() => new E2BBase64Decoder().push('%\n')).toThrow('invalid base64')
expect(() => new E2BBase64Decoder().push('AB==\n')).toThrow('invalid base64')
expect(() => decoder.push(`${E2B_OUTPUT_COMPLETE_FRAME}\n`)).toThrow('duplicate output transport completion')
expect(() => decoder.push('YQ==\n')).toThrow('continued after completion')
const truncated = new E2BBase64Decoder()
truncated.push('YQ')
expect(() => { truncated.finish() }).toThrow('truncated base64')
expect(() => { new E2BBase64Decoder().finish() }).toThrow('incomplete output transport')
const interrupted = new E2BBase64Decoder()
interrupted.push('YQ')
expect(() => { interrupted.finish(false) }).not.toThrow()
})
it('keeps a byte-exact tail with independent whole-stream cursors', () => {
const reader = new E2BOutputReader(4, 10, '/remote/spill')
reader.push('')
reader.push('ab')
reader.push('cdef')
reader.push(Buffer.alloc(0))
reader.push(Buffer.from('ab'))
reader.push(Buffer.from('cdef'))
expect(reader.size).toBe(6)
expect(reader.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true, spillPath: '/remote/spill' })
expect(reader.readFrom(2)).toEqual({ text: 'cdef', nextOffset: 6, lossy: false })
@@ -235,11 +281,11 @@ describe('E2BOutputReader', () => {
it('drops whole head chunks and withholds absent or over-cap spills', () => {
const withoutSpill = new E2BOutputReader(2, undefined, '/unused')
withoutSpill.push('ab')
withoutSpill.push('cd')
withoutSpill.push(Buffer.from('ab'))
withoutSpill.push(Buffer.from('cd'))
expect(withoutSpill.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
const overCap = new E2BOutputReader(2, 3, '/too-small')
overCap.push('abcd')
overCap.push(Buffer.from('abcd'))
expect(overCap.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
expect(() => overCap.readFrom(-1)).toThrow(/non-negative safe integer/)
expect(() => overCap.readFrom(1.5)).toThrow(/non-negative safe integer/)
@@ -265,16 +311,22 @@ describe('E2BSubprocessHandle', () => {
expect(fake.handle.sent.map(value => String(value))).toEqual(['hello'])
expect(fake.handle.closes).toBe(1)
expect(fake.startOptions?.envs).toBeUndefined()
const command = fake.commandsSeen.find(value => value.startsWith('exec setsid'))!
expect(command).toContain('exec setsid --wait -- bash -c')
const command = fake.commandsSeen.find(value => value.includes('exec "$dsh_e2b_env_bin" -i'))!
expect(command).toContain('"$dsh_e2b_setsid" --wait -- "$dsh_e2b_bash" -c')
expect(command).not.toContain('DEEPSEEK_API_KEY')
expect(command).not.toContain('DSH_MODE')
expect(command).not.toContain('FOO-BAR')
expect(command).not.toContain('explicit-secret')
expect(command).not.toContain('hyphen-value')
expect(command).not.toContain('${!dsh_e2b_name}')
expect(command).toContain('env -0')
expect(fake.commandsSeen).toContain('env -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')
expect(command).toContain('exec "$dsh_e2b_env_bin" -i "${dsh_e2b_env[@]}"')
expect(command).toContain('>&2 2>/dev/null')
expect(command).not.toContain('2>/dev/null >&2')
expect(command).toContain('base64')
expect(fake.writtenFiles[0]).toEqual([
'/workspace/.dsh-e2b/processes/one/pid',
'/workspace/.dsh-e2b/processes/one/exit-code',
@@ -282,7 +334,7 @@ describe('E2BSubprocessHandle', () => {
'/workspace/.dsh-e2b/processes/one/stderr.log',
])
expect(fake.writtenFileData.get('/workspace/.dsh-e2b/processes/one/environment')).toBe(
'PATH=/bin\0FOO-BAR=hyphen-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0',
'PATH=/bin\0KEEP=safe\0FOO-BAR=hyphen-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0',
)
let piped = ''
@@ -297,6 +349,47 @@ describe('E2BSubprocessHandle', () => {
await expect(handle.waitForExit()).resolves.toBe(true)
})
it('preserves UTF-8 bytes when the ASCII transport is split across callbacks', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
}), '/runtime/split-utf8')
await flush()
const chunks: Buffer[] = []
handle.stdout!.on('data', (chunk: Buffer) => { chunks.push(chunk) })
for (const character of `${Buffer.from('A你好B').toString('base64')}\n`) {
await fake.stdoutWire(character)
}
fake.finish()
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
expect(Buffer.concat(chunks).toString('utf8')).toBe('A你好B')
})
it('rejects malformed output transport without confusing it with a consumer sink failure', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/malformed-output')
await flush()
await fake.stdoutWire('%\n')
fake.finish()
await expect(handle.done).rejects.toThrow('invalid base64 output transport')
const stderrFake = new FakeSandbox()
const stderrHandle = new E2BSubprocessHandle(runtime(stderrFake), spec(), '/runtime/malformed-stderr')
await flush()
await stderrFake.stderrWire('%\n')
stderrFake.finish()
await expect(stderrHandle.done).rejects.toThrow('invalid base64 output transport')
})
it('rejects a naturally completed command whose encoder omits its completion frame', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/incomplete-output')
await flush()
fake.alive = false
fake.handle.succeed(0)
await expect(handle.done).rejects.toThrow('incomplete output transport')
})
it('surfaces deferred piped-stdin write and close failures as stream errors', async () => {
const writeFake = new FakeSandbox()
writeFake.deferStart()
@@ -362,10 +455,10 @@ describe('E2BSubprocessHandle', () => {
await handle.done
expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
expect(fake.removed).toContain('/runtime/oversize/stdout.log')
const command = fake.commandsSeen.find(value => value.startsWith('exec setsid'))!
expect(command).toContain('head -c 3')
const command = fake.commandsSeen.find(value => value.includes('dsh_e2b_tee='))!
expect(command).toContain('"$dsh_e2b_head" -c 3')
expect(command).toContain('/runtime/oversize/stdout.log')
expect(command).toContain('tee --output-error=warn-nopipe')
expect(command).toContain('"$dsh_e2b_tee" --output-error=warn-nopipe')
expect(command).not.toContain('tee -a')
})
@@ -436,6 +529,7 @@ describe('E2BSubprocessHandle', () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/surviving-group')
await flush()
await fake.completeOutput()
fake.handle.succeed(0)
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
expect(fake.alive).toBe(true)
@@ -640,7 +734,13 @@ describe('E2BSubprocessHandle', () => {
expect(failures[0].message).toContain('invalid process-group id')
expect(failures[1].message).toBe('rollback signal failed')
expect(fake.handle.kills).toBe(1)
fake.finish()
const bounded = new AbortController()
const waiting = handle.waitForExit(bounded.signal)
bounded.abort()
await expect(waiting).resolves.toBe(false)
handle.terminate()
await expect(handle.waitForExit()).resolves.toBe(true)
expect(fake.commandsSeen).toContain('kill -TERM -- -4242')
})
it('waits for delayed process-group publication', async () => {

View File

@@ -30,10 +30,13 @@ class FakeTerminalCommandHandle {
sdkKills = 0
disconnectError: unknown
sdkKillError: unknown
waitError: unknown
settleOnSdkKill = true
private readonly result = Promise.withResolvers<CommandResult>()
private settled = false
wait(): Promise<CommandResult> {
if (this.waitError !== undefined) throw this.waitError
return this.result.promise
}
@@ -46,10 +49,10 @@ class FakeTerminalCommandHandle {
this.sdkKills += 1
if (this.sdkKillError !== undefined) {
const error = this.sdkKillError
this.fail(137)
if (this.settleOnSdkKill) this.fail(137)
throw error
}
this.fail(137)
if (this.settleOnSdkKill) this.fail(137)
return true
}
@@ -94,8 +97,10 @@ class FakeTerminalSandbox {
createError: unknown
sendError: unknown
commandFailure: unknown
sessionGroupsFailure: unknown
foregroundFailure: unknown
termFailure: unknown
ptyKillError: unknown
removeError: unknown
clearOnTerm = true
clearOnKill = true
@@ -146,6 +151,7 @@ class FakeTerminalSandbox {
return { exitCode: 0, stdout: this.foreground, stderr: '' }
}
if (command.startsWith('ps -eo sid=')) {
if (this.sessionGroupsFailure !== undefined) throw this.sessionGroupsFailure
return { exitCode: 0, stdout: this.groups.map(group => `${group}\n`).join(''), stderr: '' }
}
if (command.startsWith('kill -TERM -- ')) {
@@ -173,6 +179,7 @@ class FakeTerminalSandbox {
},
kill: async (pid: number): Promise<boolean> => {
this.ptyKills += 1
if (this.ptyKillError !== undefined) throw this.ptyKillError
if (this.settleOnPtyKill) this.handle.fail(137)
return pid === this.handle.pid
},
@@ -282,7 +289,8 @@ describe('E2B terminal allocation', () => {
failedInput.sendError = new Error('bootstrap failed')
await expect(spawnE2BTerminal(runtime(failedInput), spec(), '/runtime/input'))
.rejects.toThrow('bootstrap failed')
expect(failedInput.handle.sdkKills).toBe(1)
expect(failedInput.commands).toContain('kill -TERM -- -123')
expect(failedInput.groups).toEqual([])
const exited = new FakeTerminalSandbox()
exited.ready = new FileNotFoundError('not ready')
@@ -292,13 +300,62 @@ describe('E2B terminal allocation', () => {
const invalidSession = new FakeTerminalSandbox()
invalidSession.sessionId = 'not-a-session\n'
invalidSession.clearOnTerm = false
await expect(spawnE2BTerminal(runtime(invalidSession), spec(), '/runtime/session'))
.rejects.toThrow('cannot resolve process session')
expect(invalidSession.handle.sdkKills).toBe(1)
expect(invalidSession.commands).toContain('kill -TERM -- -123')
expect(invalidSession.commands).toContain('kill -KILL -- -123')
expect(invalidSession.groups).toEqual([])
expect(invalidSession.ptyKills).toBe(1)
const lateData = invalidSession.createOptions?.onData
if (lateData === undefined) throw new Error('missing captured terminal callback')
expect(lateData(Buffer.from('late bytes'))).toBeUndefined()
const termFailed = new FakeTerminalSandbox()
termFailed.sendError = new Error('bootstrap failed')
termFailed.termFailure = new Error('TERM transport failed')
await expect(spawnE2BTerminal(runtime(termFailed), spec(), '/runtime/term-failed'))
.rejects.toThrow('bootstrap failed')
expect(termFailed.commands).toContain('kill -KILL -- -123')
expect(termFailed.ptyKills).toBe(1)
const uninspectable = new FakeTerminalSandbox()
uninspectable.sendError = new Error('bootstrap failed')
uninspectable.sessionGroupsFailure = 'session enumeration failed'
uninspectable.ptyKillError = new Error('PTY kill failed')
let uninspectableFailure: unknown
try {
await spawnE2BTerminal(runtime(uninspectable), spec(), '/runtime/uninspectable')
} catch (error: unknown) {
uninspectableFailure = error
}
expect(uninspectableFailure).toBeInstanceOf(AggregateError)
expect(uninspectable.ptyKills).toBe(1)
expect(uninspectable.handle.sdkKills).toBe(1)
const survivingGroups = new FakeTerminalSandbox()
survivingGroups.sendError = new Error('bootstrap failed')
survivingGroups.clearOnTerm = false
survivingGroups.clearOnKill = false
await expect(spawnE2BTerminal(runtime(survivingGroups), spec({ graceMs: 1 }), '/runtime/surviving-groups'))
.rejects.toThrow('bootstrap failed')
const survivingPid = new FakeTerminalSandbox()
survivingPid.sendError = new Error('bootstrap failed')
survivingPid.groups = []
survivingPid.settleOnPtyKill = false
survivingPid.handle.settleOnSdkKill = false
await expect(spawnE2BTerminal(runtime(survivingPid), spec({ graceMs: 1 }), '/runtime/surviving-pid'))
.rejects.toThrow('bootstrap failed')
const waitFailed = new FakeTerminalSandbox()
waitFailed.handle.waitError = new Error('wait failed')
waitFailed.handle.settleOnSdkKill = false
waitFailed.handle.sdkKillError = new Error('kill failed')
await expect(spawnE2BTerminal(runtime(waitFailed), spec(), '/runtime/wait-failed'))
.rejects.toThrow('wait failed')
expect(waitFailed.handle.sdkKills).toBe(1)
const cleanupFailed = new FakeTerminalSandbox()
cleanupFailed.handle.pid = 0
cleanupFailed.handle.sdkKillError = new Error('kill transport failed')
@@ -362,7 +419,7 @@ describe('E2B terminal lifecycle', () => {
it.each([
[7, { exitCode: 7, signal: null }],
[143, { exitCode: null, signal: 'SIGTERM' }],
[143, { exitCode: 143, signal: null }],
[255, { exitCode: 255, signal: null }],
] as const)('classifies an unrequested command exit %i', async (exitCode, expected) => {
const fake = new FakeTerminalSandbox()