fix(e2b): harden cancellation and teardown boundaries

This commit is contained in:
Tianyi Cui
2026-07-29 11:23:01 +08:00
parent cf4b721a8d
commit c122d984c4
17 changed files with 351 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/e2b/README.md
README.md: ccafa9df480e3812d3d3b6d25b513e7b5d2afc9f
README.zh.md: 60eefd46a0a9a017f4db57e4dc5313d533eeba04
README.md: 6eb7d69dd6355de870db6bf05540f2629f464bc8
README.zh.md: b2e2616cf779bce659fdef58840aa51f095a73cf

View File

@@ -30,7 +30,7 @@ Set `sandboxId` to reconnect a running or paused sandbox instead of creating one
Construction starts one create/connect operation. Before resolving `getSandbox()`, the service creates `cwd` and the private `cwd/.dsh-e2b` adapter-state directory, verifies that the reserved path is a real directory rather than a symlink or another file type, then sets it to mode `0700`. `sandboxId` resolves to a branded `E2BSandboxId` after setup.
Disposal first prevents new handle acquisition, then awaits setup and applies exactly one configured disposition. A `SandboxNotFoundError` means a kill-on-timeout sandbox is already quiescent; every other disposition failure rejects teardown. A newly created sandbox is killed when initial directory setup fails; if that rollback fails, disposal retries it before releasing ownership. A reconnected sandbox is not killed on setup failure because the service did not create it. Provider plugins must load after this owner and dispose before it.
Disposal first prevents new handle acquisition, then awaits setup and applies exactly one configured disposition. A `SandboxNotFoundError` is accepted when disposal requests `kill`, or when this service created a sandbox with `onTimeout: kill`; otherwise, a not-found error from a requested `pause` rejects teardown because retention was not proved. A newly created sandbox is killed when initial directory setup fails; if that rollback fails, disposal retries it before releasing ownership. A reconnected sandbox is not killed on setup failure because the service did not create it. Provider plugins must load after this owner and dispose before it.
`pause` and `leave` retain remote filesystem and adapter artifacts for a later `sandboxId` connection, but a later harness process receives only a new SDK handle. The subprocess service still fulfills its seam contract by terminating managed groups before owner disposal; neither disposition recovers prior process objects, output cursors, or in-memory adapter locks.

View File

@@ -30,7 +30,7 @@
构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`
资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 表示因超时终止的沙箱已经完全停稳;其他处置失败都会使 teardown 拒绝。新建沙箱的初始目录设置失败时,服务会终止该沙箱;如果该回滚失败,资源释放会在解除所有权前重试。重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose资源释放
资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 仅在资源释放请求 `kill`,或本服务创建了配置为 `onTimeout: kill` 的沙箱时才可接受;否则,`pause` 请求返回的未找到错误会导致 teardown 拒绝,因为无法证明保留成功。新建沙箱的初始目录设置失败时,服务会终止该沙箱;如果该回滚失败,资源释放会在解除所有权前重试。重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose资源释放
`pause``leave` 会保留远程文件系统及适配器产物,供稍后的 `sandboxId` 连接使用,但后续 harness 进程只会获得新的 SDK 句柄。进程管理服务仍会履行其 seam 契约,在所有者释放前终止受管进程组;这两种处置方式都不会恢复先前的进程对象、输出游标或内存中的适配器锁。

View File

@@ -181,9 +181,13 @@ export class E2BSandboxService extends Service {
return
}
} catch (error: unknown) {
// A kill-on-timeout sandbox is already quiescent; every other disposal
// failure still reports that the configured final disposition is unknown.
if (!(error instanceof SandboxNotFoundError)) throw error
// Missing proves the requested disposition only when this owner asked
// for deletion or created the sandbox with timeout deletion. A
// reconnected sandbox's creation lifecycle is unknown.
if (this.config.onDispose === 'kill') return
if (this.created && this.config.onTimeout === 'kill') return
throw error
}
}, 'e2b sandbox teardown')
}

View File

@@ -160,15 +160,59 @@ describe('E2BSandboxService', () => {
expect(fixture.pause).toHaveBeenCalledOnce()
})
it('does not classify other disposal failures as an already-gone sandbox', async () => {
it('accepts a missing sandbox when disposal itself requests deletion', async () => {
const fixture = fakeSandbox()
fixture.kill.mockRejectedValue(new Error('disposition unknown'))
fixture.kill.mockRejectedValue(new SandboxNotFoundError('already deleted'))
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const errors: unknown[] = []
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
await ctx.e2b.getSandbox()
await fiber.dispose()
expect(fixture.kill).toHaveBeenCalledOnce()
expect(errors).toEqual([])
})
it('does not classify other disposal failures as an already-gone sandbox', async () => {
const fixture = fakeSandbox()
const failure = new Error('disposition unknown')
fixture.kill.mockRejectedValue(failure)
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const errors: unknown[] = []
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
await ctx.e2b.getSandbox()
await expect(fiber.dispose()).resolves.toBeUndefined()
expect(fixture.kill).toHaveBeenCalledOnce()
expect(errors).toContain(failure)
})
it.each([
['a created pause-on-timeout sandbox', false],
['a reconnected sandbox with unknown creation policy', true],
] as const)('reports missing during pause disposal for %s', async (_label, reconnect) => {
const fixture = fakeSandbox()
const failure = new SandboxNotFoundError('sandbox unexpectedly missing')
fixture.pause.mockRejectedValue(failure)
if (reconnect) sdk.connect.mockResolvedValue(fixture.sandbox)
else sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const errors: unknown[] = []
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
const fiber = await ctx.plugin(E2BSandboxService, {
apiKey: 'test-key',
onTimeout: reconnect ? 'kill' : 'pause',
onDispose: 'pause',
...(reconnect ? { sandboxId: 'existing' } : {}),
})
await ctx.e2b.getSandbox()
await expect(fiber.dispose()).resolves.toBeUndefined()
expect(fixture.pause).toHaveBeenCalledOnce()
expect(errors).toContain(failure)
})
it('reconnects without applying creation lifecycle options and can leave state running', async () => {

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/fs-e2b/README.md
README.md: 8abf130ce16e2b79cda5fc858182159442f9e3c3
README.zh.md: 943d9603046ad9b2a65f41d5801cac882040a0ff
README.md: bb92b5785383e9703a382fddefcd1cff9b2644cb
README.zh.md: 57ebfdbb92799660e74078fcd0affcc8d5bc120a

View File

@@ -11,7 +11,7 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide
- **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing.
- **Stable bounded reads** — a dependency-free Node helper walks directory descriptors with no-follow opens and reads one held regular-file descriptor through the byte cap. Generic LSP queries therefore reject parent swaps, non-files, invalid UTF-8, and growth past the configured document limit before server startup.
- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, preserve an existing file's POSIX mode, and publish the staged file through E2B's same-filesystem atomic rename. The rename response supplies the committed version, so no fallible metadata request follows the commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. Optional create/version guards keep the base seam's observed-state semantics.
- **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at SDK request boundaries; a successful rename is the commit point.
- **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before rename. The signal is not forwarded into the rename RPC, so cancellation cannot interrupt the atomic commit; a successful rename is the commit point.
The provider does not copy, mount, or reconcile the host workspace. Giving it a host path as `cwd` creates a remote directory with the same spelling only.

View File

@@ -11,7 +11,7 @@
- **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。
- **稳定的有界读取**:一个零依赖 Node 辅助程序会以不跟随链接的方式逐级打开目录描述符,并通过一个持续持有的常规文件描述符读取至字节上限。因此,通用 LSP 查询会在服务器启动前拒绝父目录交换、非文件、无效 UTF-8以及增长后超出所配置文档上限的文件。
- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,保留现有文件的 POSIX mode并通过 E2B 的同一文件系统原子重命名发布暂存文件。重命名响应会提供已提交的版本因此提交点之后不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF存储时恢复占主导的 CRLF并在宿主进程内按规范化目标串行执行变更。可选的创建版本防护会保留基础 seam 的已观察状态语义。
- **失败与取消**E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在 SDK 请求边界上采用尽力而为语义;成功 rename 是提交点。
- **失败与取消**E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在 rename 前立即检查。信号不会传入 rename RPC因此取消无法中断原子提交;成功 rename 是提交点。
该提供方不会复制、挂载或协调宿主工作区。把宿主路径用作 `cwd`,只会在远程创建一个拼写相同的目录。

View File

@@ -518,7 +518,7 @@ export class E2BFileSystem extends FileSystem {
signalOpts(signal),
)
assertNotAborted(signal, 'write')
const committed = await sandbox.files.rename(temporary, targetPath, signalOpts(signal))
const committed = await sandbox.files.rename(temporary, targetPath)
try {
await sandbox.files.remove(stagingDirectory)
} catch (_committedStagingCleanupFailure) {

View File

@@ -209,6 +209,7 @@ class FakeRemote {
this.nodes.set(to, node)
this.renames.push({ from, to })
this.abortAfterRename?.abort('after commit')
this.checkAbort(options)
return this.info(to)
},
remove: async (path: string): Promise<void> => {

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: 1c05509308e4f8b8b07cffd85e289b3ebee70318
README.zh.md: b536d4e9d083076eccef4ca238dda1b2a503bee1
README.md: 66481fb1c5edd4c124c2c58aaa5c86a1683e5a3b
README.zh.md: 5e4b8bbe401cf4a565ccd53270df9fb6b5de9315

View File

@@ -10,8 +10,9 @@ 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; 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.
- **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; raw pipes instead await lossless transport completion and preserve backpressure. 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. 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`.
- **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.

View File

@@ -10,8 +10,9 @@
- **执行世界坐标**`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 分帧规则的条目会在启动前被拒绝。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。
- **stdio 投影**:远程包装层先把原始字节分流到可选的有界 spill 文件,再把每个实时分片编码为换行分隔的 base64 ASCII 帧;宿主会跨任意 SDK 回调边界增量恢复字节。pipe 模式把这些字节写入宿主 Node 流inherit 模式把字节写入 harness 进程流collect 模式保留有界的宿主尾部,并支持基于偏移量读取。包装层会在等待继承管道的写入方之前发布直接命令状态。对于 collect 或 inherit 输出,超过 `graceMs` 后,适配器会断开未完成的 SDK 流,不公开其中不完整的 spill并返回该状态同时保留远程进程组供 `waitForExit()` 和终止操作使用原始 pipe 会等待无损传输完成并保留背压。批量 stdin 和流式 stdin 都使用 SDK 句柄。
- **终端会话**`spawnTerminal()` 使用 E2B 的字节 PTY API以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并在结算前清理远程终端会话中仍存活的每个进程组;仅含僵尸进程的进程组已经完全停稳。私有随机输出边界会丢弃 E2B 引导 shell 的提示符和回显的 runner 命令同时保留请求进程的每个字节包括其第一个提示符。setup 与 teardown 负责私有状态事务,在服务 dispose 期间中止待处理的 setup、阻止发布并保留未证明已完成的 setup 清理事务,供 dispose 重试。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。
- **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 支持。

View File

@@ -6,6 +6,7 @@ import { posix } from 'node:path'
import {
CommandExitError,
FileNotFoundError,
SandboxNotFoundError,
quoteE2BShellArg,
} from '@deepseek-ai/dsh-e2b'
import type { CommandHandle, CommandResult, Sandbox } from '@deepseek-ai/dsh-e2b'
@@ -115,7 +116,7 @@ function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string {
`printf '%s\\n' "$dsh_e2b_pgid" > ${quoteE2BShellArg(paths.pid)}`,
`mapfile -d '' -t dsh_e2b_env < ${quoteE2BShellArg(paths.environment)}`,
`"$dsh_e2b_rm" -f -- ${quoteE2BShellArg(paths.environment)}`,
`"$dsh_e2b_env_bin" -i "\${dsh_e2b_env[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(),
`"$dsh_e2b_env_bin" -i -- "\${dsh_e2b_env[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(),
'dsh_e2b_status=$?',
`printf '%s\\n' "$dsh_e2b_status" > ${quoteE2BShellArg(paths.status)}`,
'wait',
@@ -136,7 +137,7 @@ function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string {
'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" "$dsh_e2b_rm"; 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" "$dsh_e2b_rm" ${argv}`,
`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" "$dsh_e2b_rm" ${argv}`,
].join('\n')
return bootstrap
}
@@ -196,6 +197,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private readonly readyState = Promise.withResolvers<CommandHandle>()
private readonly stdoutDecoder = new E2BBase64Decoder()
private readonly stderrDecoder = new E2BBase64Decoder()
private readonly outputTermination = new AbortController()
private readonly stdoutReader: E2BOutputReader | undefined
private readonly stderrReader: E2BOutputReader | undefined
private readonly paths: RemotePaths
@@ -262,6 +264,9 @@ export class E2BSubprocessHandle implements SubprocessHandle {
terminate(): void {
if (this.terminationFenced || this.quiescenceProven || this.terminationAttempt !== undefined) return
this.terminationStarted = true
this.outputTermination.abort()
this.stdout?.destroy()
this.stderr?.destroy()
this.terminationFailure = undefined
const attempt = this.terminateRemote()
this.terminationAttempt = attempt
@@ -317,6 +322,10 @@ export class E2BSubprocessHandle implements SubprocessHandle {
sandbox = await this.runtime.getSandbox()
} catch (error: unknown) {
if (isAborted(signal)) return false
if (error instanceof SandboxNotFoundError) {
this.markQuiescent()
return true
}
throw error
}
const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
@@ -469,21 +478,25 @@ export class E2BSubprocessHandle implements SubprocessHandle {
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 === undefined || data.length === 0 || this.outputTermination.signal.aborted) return
if (target.destroyed) throw new Error('subprocess output stream is closed')
if (target.write(data)) return
await new Promise<void>((resolve, reject) => {
const onDrain = (): void => { cleanup(); resolve() }
const onClose = (): void => { cleanup(); resolve() }
const onTermination = (): void => { cleanup(); resolve() }
const onError = (error: Error): void => { cleanup(); reject(error) }
const cleanup = (): void => {
target.removeListener('drain', onDrain)
target.removeListener('close', onClose)
target.removeListener('error', onError)
this.outputTermination.signal.removeEventListener('abort', onTermination)
}
target.once('drain', onDrain)
target.once('close', onClose)
target.once('error', onError)
this.outputTermination.signal.addEventListener('abort', onTermination, { once: true })
if (this.outputTermination.signal.aborted) onTermination()
})
}
@@ -580,6 +593,18 @@ export class E2BSubprocessHandle implements SubprocessHandle {
}
private async terminateRemote(): Promise<void> {
try {
await this.terminateRemoteInSandbox()
} catch (error: unknown) {
if (error instanceof SandboxNotFoundError) {
this.markQuiescent()
return
}
throw error
}
}
private async terminateRemoteInSandbox(): Promise<void> {
const handle = await this.commandState.promise
if (handle === undefined) return
if (!isValidProcessId(handle.pid) && this.remotePid <= 0) {
@@ -671,7 +696,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
await sandbox.commands.run(`kill -${signal} -- -${pid}`)
return true
} catch (error: unknown) {
if (error instanceof CommandExitError) return false
if (error instanceof CommandExitError || error instanceof SandboxNotFoundError) return false
throw error
}
}
@@ -682,6 +707,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
signalOpts(signal),
).catch((error: unknown) => {
if (signal?.aborted === true) return undefined
if (error instanceof SandboxNotFoundError) return { exitCode: 0, stdout: '', stderr: '' }
throw error
})
return result?.stdout.trim() === 'live'

View File

@@ -7,6 +7,7 @@ import { posix } from 'node:path'
import {
CommandExitError,
FileNotFoundError,
SandboxNotFoundError,
quoteE2BShellArg,
} from '@deepseek-ai/dsh-e2b'
import type { CommandHandle, CommandResult, Sandbox } from '@deepseek-ai/dsh-e2b'
@@ -37,7 +38,7 @@ const TERMINAL_RUNNER_SOURCE = [
'fi',
'printf \'%s\' "$dsh_output_marker"',
"printf 'ready\\n' > \"$dsh_state/ready\"",
'exec env -i "${dsh_env[@]}" "${dsh_argv[@]}"',
'exec env -i -- "${dsh_env[@]}" "${dsh_argv[@]}"',
'',
].join('\n')
@@ -169,9 +170,15 @@ async function waitUntilReady(
}
async function sessionProcessGroups(sandbox: Sandbox, sessionId: number): Promise<number[]> {
const result = await sandbox.commands.run(
`set -o pipefail; ps -eo sid=,pgid=,stat= | awk '$1 == ${sessionId} && $3 !~ /^[ZXx]/ { print $2 }'`,
)
let result: CommandResult
try {
result = await sandbox.commands.run(
`set -o pipefail; ps -eo sid=,pgid=,stat= | awk '$1 == ${sessionId} && $3 !~ /^[ZXx]/ { print $2 }'`,
)
} catch (error: unknown) {
if (error instanceof SandboxNotFoundError) return []
throw error
}
const groups = new Set<number>()
for (const raw of result.stdout.trim().split(/\s+/)) {
if (raw.length === 0) continue
@@ -191,7 +198,7 @@ async function signalGroups(sandbox: Sandbox, groups: number[], signal: 'TERM' |
try {
await sandbox.commands.run(`kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`)
} catch (error: unknown) {
if (!(error instanceof CommandExitError)) throw error
if (!(error instanceof CommandExitError) && !(error instanceof SandboxNotFoundError)) throw error
}
}
@@ -253,6 +260,7 @@ async function rollbackUnpublishedTerminal(
try {
await sandbox.pty.kill(handle.pid)
} catch (error: unknown) {
if (error instanceof SandboxNotFoundError) return
attemptFailures.push(asError(error))
}
}
@@ -262,6 +270,7 @@ async function rollbackUnpublishedTerminal(
try {
await handle.kill()
} catch (error: unknown) {
if (error instanceof SandboxNotFoundError) return
attemptFailures.push(asError(error))
}
}
@@ -291,7 +300,11 @@ async function rollbackUnpublishedTerminal(
'subprocess-e2b: terminal setup rollback did not reach quiescence',
)
}
await handle.disconnect()
try {
await handle.disconnect()
} catch (error: unknown) {
if (!(error instanceof SandboxNotFoundError)) throw error
}
}
/** One E2B PTY and all process groups in its remote process session. */
@@ -400,7 +413,14 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
}
if (groups.length > 0 || !this.topLevelExited) {
this.terminationSignal = 'SIGKILL'
if (!this.topLevelExited) await this.sandbox.pty.kill(this.pid)
if (!this.topLevelExited) {
try {
await this.sandbox.pty.kill(this.pid)
} catch (error: unknown) {
if (error instanceof SandboxNotFoundError) return
throw error
}
}
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.graceMs, true)
if (!this.topLevelExited) await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
}
@@ -410,7 +430,11 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
if (!this.topLevelExited) {
throw new Error(`subprocess-e2b: terminal cleanup failed; surviving pid: ${this.pid}`)
}
await this.handle.disconnect()
try {
await this.handle.disconnect()
} catch (error: unknown) {
if (!(error instanceof SandboxNotFoundError)) throw error
}
await this.sandbox.files.remove(this.stateDir).catch(() => {})
}
}
@@ -468,11 +492,11 @@ export async function spawnE2BTerminal(
cwd: spec.cwd,
envs: { TERM: 'dumb' },
timeoutMs: 0,
...signalOpts(spec.signal),
onData: (data) => { outputFilter.push(data) },
})
completion = handle.wait()
void completion.catch(() => {})
spec.signal?.throwIfAborted()
if (!Number.isSafeInteger(handle.pid) || handle.pid <= 0) {
throw new Error(`subprocess-e2b: E2B returned invalid terminal pid ${handle.pid}`)
}
@@ -503,7 +527,8 @@ export async function spawnE2BTerminal(
else await rollbackUnpublishedTerminal(sandbox, handle, completion, spec.graceMs)
terminalQuiescent = true
} catch (cleanupError: unknown) {
failures.push(asError(cleanupError))
if (cleanupError instanceof SandboxNotFoundError) terminalQuiescent = true
else failures.push(asError(cleanupError))
}
}
if (!stateRemoved) {
@@ -511,7 +536,7 @@ export async function spawnE2BTerminal(
await sandbox.files.remove(stateDir)
stateRemoved = true
} catch (stateError: unknown) {
if (stateError instanceof FileNotFoundError) stateRemoved = true
if (stateError instanceof FileNotFoundError || stateError instanceof SandboxNotFoundError) stateRemoved = true
else failures.push(asError(stateError))
}
}

View File

@@ -3,6 +3,7 @@ import { Context } from 'cordis'
import {
CommandExitError,
FileNotFoundError,
SandboxNotFoundError,
type CommandHandle,
type CommandResult,
type Sandbox,
@@ -371,7 +372,13 @@ describe('E2BSubprocessHandle', () => {
const handle = new E2BSubprocessHandle(runtime(fake), spec({
argv: ['tool', 'argument with spaces'],
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 8, spill: { maxBytes: 32 } } },
env: { PATH: '/bin', 'FOO-BAR': 'hyphen-value', DEEPSEEK_API_KEY: 'explicit-secret', DSH_MODE: 'test' },
env: {
PATH: '/bin',
'FOO-BAR': 'hyphen-value',
'--split-string': 'literal-value',
DEEPSEEK_API_KEY: 'explicit-secret',
DSH_MODE: 'test',
},
}), '/workspace/.dsh-e2b/processes/one')
expect(handle.pid).toBe(-1)
handle.stdin!.write('hello')
@@ -394,7 +401,8 @@ describe('E2BSubprocessHandle', () => {
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('"$dsh_e2b_env_bin" -i -- "${dsh_e2b_env[@]}" "$@"')
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')
@@ -405,7 +413,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\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0',
'PATH=/bin\0KEEP=safe\0FOO-BAR=hyphen-value\0--split-string=literal-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0',
)
let piped = ''
@@ -1088,6 +1096,52 @@ describe('E2BSubprocessHandle', () => {
await handle.done
})
it('treats a timeout-killed sandbox as quiescent during liveness probing', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/expired-sandbox')
await flush()
fake.finish()
await handle.done
fake.probeError = new SandboxNotFoundError('sandbox expired')
await expect(handle.waitForExit()).resolves.toBe(true)
})
it('treats a missing sandbox handle as quiescent during liveness acquisition', async () => {
const fake = new FakeSandbox()
let calls = 0
const handle = new E2BSubprocessHandle(runtime(fake, async () => {
calls += 1
if (calls === 1) return fake.sandbox
throw new SandboxNotFoundError('sandbox expired')
}), spec(), '/runtime/expired-acquisition')
await flush()
await expect(handle.waitForExit()).resolves.toBe(true)
await fake.completeOutput()
fake.alive = false
fake.handle.succeed(0)
await handle.done
})
it('treats sandbox loss during termination as quiescent', async () => {
const fake = new FakeSandbox()
let calls = 0
const handle = new E2BSubprocessHandle(runtime(fake, async () => {
calls += 1
if (calls === 1) return fake.sandbox
throw new SandboxNotFoundError('sandbox expired')
}), spec(), '/runtime/expired-termination')
await flush()
await fake.completeOutput()
handle.terminate()
await expect(handle.waitForExit()).resolves.toBe(true)
fake.alive = false
fake.handle.succeed(0)
await handle.done
})
it('makes batch stdin close failures best-effort', async () => {
const fake = new FakeSandbox()
vi.spyOn(fake.handle, 'sendStdin').mockRejectedValueOnce(new Error('closed'))
@@ -1233,6 +1287,44 @@ describe('E2BSubprocessHandle', () => {
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
})
it('breaks output backpressure when termination owns the command', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
}), '/runtime/backpressure-termination')
await flush()
const stdoutWrite = vi.spyOn(handle.stdout!, 'write').mockReturnValueOnce(false)
let released = false
const pending = fake.stdout('blocked').then(() => { released = true })
await Promise.resolve()
handle.terminate()
await flush()
const releasedByTermination = released
if (!released) handle.stdout!.emit('drain')
await pending
stdoutWrite.mockRestore()
await handle.done
expect(releasedByTermination).toBe(true)
})
it('settles backpressure when a synchronous pipe write starts termination', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
}), '/runtime/backpressure-synchronous-termination')
await flush()
const stdoutWrite = vi.spyOn(handle.stdout!, 'write').mockImplementationOnce(() => {
handle.terminate()
return false
})
await expect(fake.stdout('blocked')).resolves.toBeUndefined()
stdoutWrite.mockRestore()
await handle.done
})
it('contains a pipe callback failure instead of rejecting command settlement', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({

View File

@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
CommandExitError,
FileNotFoundError,
SandboxNotFoundError,
type CommandHandle,
type CommandResult,
type Sandbox,
@@ -113,6 +114,18 @@ class FakeTerminalSandbox {
requestedOutput = 'requested-shell$ '
emitOutputMarker = true
afterSessionLookup: (() => void) | undefined
private createGate: Promise<undefined> | undefined
private releaseCreateGate: (() => void) | undefined
deferCreate(): void {
const gate = Promise.withResolvers<undefined>()
this.createGate = gate.promise
this.releaseCreateGate = () => { gate.resolve(undefined) }
}
releaseCreate(): void {
this.releaseCreateGate?.()
}
readonly sandbox = {
files: {
@@ -183,6 +196,8 @@ class FakeTerminalSandbox {
create: async (options: Parameters<Sandbox['pty']['create']>[0]): Promise<CommandHandle> => {
this.createOptions = options
if (this.createError !== undefined) throw this.createError
await this.createGate
options.signal?.throwIfAborted()
await options.onData(Buffer.from('buffered banner\n'))
return this.handle.asHandle()
},
@@ -258,7 +273,7 @@ describe('E2B terminal allocation', () => {
const runner = fake.writes.get('/runtime/terminal-one/runner.bash') ?? ''
expect(runner).toContain('if (( ${#dsh_argv[@]} == 0 )); then')
expect(runner).toContain('printf \'%s\' "$dsh_output_marker"')
expect(runner).toContain('exec env -i "${dsh_env[@]}" "${dsh_argv[@]}"')
expect(runner).toContain('exec env -i -- "${dsh_env[@]}" "${dsh_argv[@]}"')
expect(runner).not.toContain('\u007f')
terminal.output.destroy()
await fake.createOptions?.onData(Buffer.from('late bootstrap callback'))
@@ -296,6 +311,25 @@ describe('E2B terminal allocation', () => {
await expect(terminal.waitForExit()).resolves.toBe(true)
})
it('publishes the PTY handle before honoring allocation cancellation', async () => {
const fake = new FakeTerminalSandbox()
fake.deferCreate()
const controller = new AbortController()
const spawning = spawnE2BTerminal(
runtime(fake),
spec({ signal: controller.signal }),
'/runtime/allocation-cancel',
)
await vi.waitFor(() => { expect(fake.createOptions).toBeDefined() })
controller.abort(new Error('allocation cancelled'))
fake.releaseCreate()
await expect(spawning).rejects.toThrow('allocation cancelled')
expect(fake.createOptions?.signal).toBeUndefined()
expect(fake.groups).toEqual([])
expect(fake.handle.disconnects).toBe(1)
})
it('rejects malformed environment and argv values before PTY allocation', async () => {
const invalidName = new FakeTerminalSandbox()
await expect(spawnE2BTerminal(runtime(invalidName), spec({ env: { 'BAD=NAME': 'x' } }), '/runtime/name'))
@@ -410,6 +444,44 @@ describe('E2B terminal allocation', () => {
cleanupFailed.removeError = new Error('remove transport failed')
await expect(spawnE2BTerminal(runtime(cleanupFailed), spec(), '/runtime/cleanup-failed'))
.rejects.toThrow('invalid terminal pid 0')
const expiredDuringRollback = new FakeTerminalSandbox()
expiredDuringRollback.sendError = new Error('bootstrap failed before timeout')
expiredDuringRollback.groups = []
expiredDuringRollback.settleOnPtyKill = false
expiredDuringRollback.ptyKillError = new SandboxNotFoundError('sandbox expired')
expiredDuringRollback.removeError = new SandboxNotFoundError('sandbox expired')
await expect(spawnE2BTerminal(runtime(expiredDuringRollback), spec(), '/runtime/expired-rollback'))
.rejects.toThrow('bootstrap failed before timeout')
expect(expiredDuringRollback.ptyKills).toBe(1)
const expiredBeforeSdkRollback = new FakeTerminalSandbox()
expiredBeforeSdkRollback.handle.waitError = new Error('wait failed after timeout')
expiredBeforeSdkRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
expiredBeforeSdkRollback.handle.settleOnSdkKill = false
await expect(spawnE2BTerminal(runtime(expiredBeforeSdkRollback), spec(), '/runtime/expired-sdk-rollback'))
.rejects.toThrow('wait failed after timeout')
const expiredDuringSdkFallback = new FakeTerminalSandbox()
expiredDuringSdkFallback.sendError = new Error('bootstrap failed before SDK fallback')
expiredDuringSdkFallback.groups = []
expiredDuringSdkFallback.settleOnPtyKill = false
expiredDuringSdkFallback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
expiredDuringSdkFallback.handle.settleOnSdkKill = false
await expect(spawnE2BTerminal(runtime(expiredDuringSdkFallback), spec(), '/runtime/expired-sdk-fallback'))
.rejects.toThrow('bootstrap failed before SDK fallback')
const missingDuringDisconnect = new FakeTerminalSandbox()
missingDuringDisconnect.sendError = new Error('bootstrap failed before disconnect')
missingDuringDisconnect.handle.disconnectError = new SandboxNotFoundError('sandbox expired')
await expect(spawnE2BTerminal(runtime(missingDuringDisconnect), spec(), '/runtime/missing-disconnect'))
.rejects.toThrow('bootstrap failed before disconnect')
const failedDisconnect = new FakeTerminalSandbox()
failedDisconnect.sendError = new Error('bootstrap failed with disconnect failure')
failedDisconnect.handle.disconnectError = new Error('disconnect transport failed')
await expect(spawnE2BTerminal(runtime(failedDisconnect), spec(), '/runtime/failed-disconnect'))
.rejects.toThrow('bootstrap failed with disconnect failure')
})
it('propagates setup cancellation and provider failures', async () => {
@@ -526,6 +598,53 @@ describe('E2B terminal lifecycle', () => {
)
})
it('treats a timeout-killed sandbox as quiescent during terminal cleanup', async () => {
const fake = new FakeTerminalSandbox()
const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/expired-sandbox')
fake.sessionGroupsFailure = new SandboxNotFoundError('sandbox expired')
fake.handle.succeed(0)
await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null })
await expect(terminal.waitForExit()).resolves.toBe(true)
})
it('treats sandbox disappearance during PTY kill as quiescent', async () => {
const fake = new FakeTerminalSandbox()
fake.groups = []
fake.settleOnPtyKill = false
fake.ptyKillError = new SandboxNotFoundError('sandbox expired')
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/expired-pty-kill')
terminal.terminate()
await expect(terminal.waitForExit()).resolves.toBe(true)
expect(fake.ptyKills).toBe(1)
})
it('propagates a non-missing PTY kill failure', async () => {
const fake = new FakeTerminalSandbox()
fake.groups = []
fake.settleOnPtyKill = false
fake.ptyKillError = new Error('PTY kill transport failed')
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/failed-pty-kill')
terminal.terminate()
await expect(terminal.waitForExit()).rejects.toThrow('PTY kill transport failed')
})
it.each([
['accepts sandbox loss', new SandboxNotFoundError('sandbox expired'), true],
['propagates another failure', new Error('disconnect failed'), false],
] as const)('%s while disconnecting a settled terminal', async (_label, failure, accepted) => {
const fake = new FakeTerminalSandbox()
const terminal = await spawnE2BTerminal(runtime(fake), spec(), `/runtime/disconnect-${accepted}`)
fake.handle.disconnectError = failure
fake.groups = []
fake.handle.succeed(0)
if (accepted) await expect(terminal.waitForExit()).resolves.toBe(true)
else await expect(terminal.waitForExit()).rejects.toThrow('disconnect failed')
})
it('rejects killing the terminal shell and propagates live foreground failures', async () => {
const fake = new FakeTerminalSandbox()
fake.foreground = '123\n'