diff --git a/packages/e2b/e2b/README.i18n.yaml b/packages/e2b/e2b/README.i18n.yaml index d8f1dcdc3f..46ff37b222 100644 --- a/packages/e2b/e2b/README.i18n.yaml +++ b/packages/e2b/e2b/README.i18n.yaml @@ -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: 01c7ade2f5f31e34091ad0ae910287e659f15549 -README.zh.md: fb95a12cbbbd431ccb3283a14bb63b324f9bdea6 +README.md: ccafa9df480e3812d3d3b6d25b513e7b5d2afc9f +README.zh.md: 60eefd46a0a9a017f4db57e4dc5313d533eeba04 diff --git a/packages/e2b/e2b/README.md b/packages/e2b/e2b/README.md index 01c7ade2f5..ccafa9df48 100644 --- a/packages/e2b/e2b/README.md +++ b/packages/e2b/e2b/README.md @@ -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; 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` 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. `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. diff --git a/packages/e2b/e2b/README.zh.md b/packages/e2b/e2b/README.zh.md index fb95a12cbb..60eefd46a0 100644 --- a/packages/e2b/e2b/README.zh.md +++ b/packages/e2b/e2b/README.zh.md @@ -30,7 +30,7 @@ 构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`。 -资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 表示因超时终止的沙箱已经完全停稳;其他处置失败都会使 teardown 拒绝。新建沙箱的初始目录设置失败时,服务会终止该沙箱;重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。 +资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 表示因超时终止的沙箱已经完全停稳;其他处置失败都会使 teardown 拒绝。新建沙箱的初始目录设置失败时,服务会终止该沙箱;如果该回滚失败,资源释放会在解除所有权前重试。重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。 `pause` 和 `leave` 会保留远程文件系统及适配器产物,供稍后的 `sandboxId` 连接使用,但后续 harness 进程只会获得新的 SDK 句柄。进程管理服务仍会履行其 seam 契约,在所有者释放前终止受管进程组;这两种处置方式都不会恢复先前的进程对象、输出游标或内存中的适配器锁。 diff --git a/packages/e2b/e2b/src/index.ts b/packages/e2b/e2b/src/index.ts index 2c0b3c635c..31e4f911a2 100644 --- a/packages/e2b/e2b/src/index.ts +++ b/packages/e2b/e2b/src/index.ts @@ -120,6 +120,7 @@ export class E2BSandboxService extends Service { private readonly config: ResolvedConfig private readonly ready: Promise + private failedSetupSandbox: Sandbox | undefined private disposed = false constructor(ctx: Context, config: Config) { @@ -155,8 +156,16 @@ export class E2BSandboxService extends Service { try { sandbox = await this.ready } catch { - // Connection creation already failed and is exposed by getSandbox(); - // there is no remote resource for teardown to own. + const failedSetupSandbox = this.failedSetupSandbox + if (failedSetupSandbox === undefined) return + sandbox = failedSetupSandbox + try { + await sandbox.kill() + this.failedSetupSandbox = undefined + } catch (error: unknown) { + if (!(error instanceof SandboxNotFoundError)) throw error + this.failedSetupSandbox = undefined + } return } try { @@ -243,7 +252,9 @@ export class E2BSandboxService extends Service { try { await sandbox.kill() } catch (_cleanupFailure) { - // The setup failure remains authoritative; E2B will still apply the configured lifetime. + // Preserve the setup failure as the public error while retaining the + // created handle for the service disposer to retry this rollback. + this.failedSetupSandbox = sandbox } } throw error diff --git a/packages/e2b/e2b/tests/e2b.spec.ts b/packages/e2b/e2b/tests/e2b.spec.ts index ff90c37b3a..fea24fa1cd 100644 --- a/packages/e2b/e2b/tests/e2b.spec.ts +++ b/packages/e2b/e2b/tests/e2b.spec.ts @@ -210,8 +210,28 @@ describe('E2BSandboxService', () => { fixture.kill.mockRejectedValueOnce(new Error('cleanup failed')) sdk.create.mockResolvedValue(fixture.sandbox) const ctx = new Context() - await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' }) + const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' }) await expect(ctx.e2b.getSandbox()).rejects.toThrow('chmod failed') + expect(fixture.kill).toHaveBeenCalledOnce() + + await fiber.dispose() + expect(fixture.kill).toHaveBeenCalledTimes(2) + }) + + it.each([ + ['retries a still-failing rollback', new Error('retry failed')], + ['accepts a setup sandbox that expired before retry', new SandboxNotFoundError('sandbox expired')], + ])('%s during disposal', async (_label, retryError) => { + const fixture = fakeSandbox() + fixture.run.mockRejectedValueOnce(new Error('chmod failed')) + fixture.kill.mockRejectedValueOnce(new Error('cleanup failed')).mockRejectedValueOnce(retryError) + sdk.create.mockResolvedValue(fixture.sandbox) + const ctx = new Context() + const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' }) + + await expect(ctx.e2b.getSandbox()).rejects.toThrow('chmod failed') + await fiber.dispose() + expect(fixture.kill).toHaveBeenCalledTimes(2) }) it('does not kill a reconnected sandbox when setup fails', async () => { diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index 41d964a58a..0a9baf6d27 100644 --- a/packages/e2b/subprocess-e2b/README.i18n.yaml +++ b/packages/e2b/subprocess-e2b/README.i18n.yaml @@ -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: 42cd4951edb101dc75980e114e704c4aab50ae25 -README.zh.md: c4b318184cb7616a378e3336f4f4a7c366a98807 +README.md: 1c05509308e4f8b8b07cffd85e289b3ebee70318 +README.zh.md: b536d4e9d083076eccef4ca238dda1b2a503bee1 diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index 42cd4951ed..1c05509308 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -8,10 +8,10 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr - **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. - **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. 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. +- **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 group in the remote terminal session before settlement. 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`. +- **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`. 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. diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index c4b318184c..b536d4e9d0 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -8,10 +8,10 @@ - **异步远程启动**:同步 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 结算和私有清理完成,之后沙箱所有者才会释放。 +- **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` 所有。 +- **终端会话**:`spawnTerminal()` 使用 E2B 的字节 PTY API,以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并在结算前清理远程终端会话中仍存活的每个进程组;仅含僵尸进程的进程组已经完全停稳。私有随机输出边界会丢弃 E2B 引导 shell 的提示符和回显的 runner 命令,同时保留请求进程的每个字节,包括其第一个提示符。setup 与 teardown 负责私有状态事务,在服务 dispose 期间中止待处理的 setup、阻止发布,并保留未证明已完成的 setup 清理事务,供 dispose 重试。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。 基础 E2B 镜像提供该适配器调用的运行时和 Bash/GNU 工具:`node`、`bash`、`setsid`、`ps`、`awk`、`tr`、`env`、`chmod`、`tee`、`head`、`rm` 和 `kill`。自定义模板必须保留兼容的命令和 E2B PTY 支持。 diff --git a/packages/e2b/subprocess-e2b/src/process.ts b/packages/e2b/subprocess-e2b/src/process.ts index 4855b27b81..5387966578 100644 --- a/packages/e2b/subprocess-e2b/src/process.ts +++ b/packages/e2b/subprocess-e2b/src/process.ts @@ -474,12 +474,15 @@ export class E2BSubprocessHandle implements SubprocessHandle { if (target.write(data)) return await new Promise((resolve, reject) => { const onDrain = (): void => { cleanup(); resolve() } + const onClose = (): 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) } target.once('drain', onDrain) + target.once('close', onClose) target.once('error', onError) }) } @@ -671,14 +674,14 @@ export class E2BSubprocessHandle implements SubprocessHandle { } private async groupAlive(sandbox: Sandbox, pid: number, signal?: AbortSignal): Promise { - try { - await sandbox.commands.run(`kill -0 -- -${pid}`, signalOpts(signal)) - return true - } catch (error: unknown) { - if (signal?.aborted === true) return false - if (error instanceof CommandExitError) return false + const result = await sandbox.commands.run( + `set -o pipefail; ps -eo pgid=,stat= | awk '$1 == ${pid} && $2 !~ /^[ZXx]/ { live=1 } END { if (live) print "live" }'`, + signalOpts(signal), + ).catch((error: unknown) => { + if (signal?.aborted === true) return undefined throw error - } + }) + return result?.stdout.trim() === 'live' } private async finalizeSpills(sandbox: Sandbox): Promise { diff --git a/packages/e2b/subprocess-e2b/src/terminal.ts b/packages/e2b/subprocess-e2b/src/terminal.ts index 0f5809fa44..be79199b58 100644 --- a/packages/e2b/subprocess-e2b/src/terminal.ts +++ b/packages/e2b/subprocess-e2b/src/terminal.ts @@ -170,7 +170,7 @@ async function waitUntilReady( async function sessionProcessGroups(sandbox: Sandbox, sessionId: number): Promise { const result = await sandbox.commands.run( - `ps -eo sid=,pgid= | awk '$1 == ${sessionId} { print $2 }'`, + `set -o pipefail; ps -eo sid=,pgid=,stat= | awk '$1 == ${sessionId} && $3 !~ /^[ZXx]/ { print $2 }'`, ) const groups = new Set() for (const raw of result.stdout.trim().split(/\s+/)) { diff --git a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts index 462e943a1b..b7d1642bd6 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -107,6 +107,7 @@ class FakeSandbox { delaysKillCompletion = false sdkKillStops = true alive = true + zombieOnly = false ambient = 'PATH=/ambient/bin\0KEEP=safe\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0' processGroupId = '4242\n' exitStatus = '' @@ -235,7 +236,7 @@ class FakeSandbox { if (this.envError !== undefined) throw this.envError return { exitCode: 0, stdout: this.ambient, stderr: '' } } - if (command.startsWith('kill -0 ')) { + if (command.startsWith('set -o pipefail; ps -eo pgid=,stat=')) { this.beforeProbe?.() if (options?.signal?.aborted === true) throw new DOMException('aborted', 'AbortError') if (this.probeError !== undefined) { @@ -243,9 +244,9 @@ class FakeSandbox { this.probeError = undefined throw error } - if (!this.alive) throw commandError(1) + const stdout = this.alive && !this.zombieOnly ? 'live\n' : '' this.afterProbe?.() - return { exitCode: 0, stdout: '', stderr: '' } + return { exitCode: 0, stdout, stderr: '' } } if (command.startsWith('kill -TERM ')) { await this.signalGate @@ -701,6 +702,20 @@ describe('E2BSubprocessHandle', () => { expect(fake.commandsSeen.filter(command => command.startsWith('kill -'))).toHaveLength(signals) }) + it('treats a zombie-only process group as quiescent', async () => { + const fake = new FakeSandbox() + fake.zombieOnly = true + const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/zombie-quiescence') + await flush() + + await expect(handle.waitForExit()).resolves.toBe(true) + expect(fake.commandsSeen).toContain( + 'set -o pipefail; ps -eo pgid=,stat= | awk \'$1 == 4242 && $2 !~ /^[ZXx]/ { live=1 } END { if (live) print "live" }\'', + ) + fake.finish() + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + }) + it('keeps proven quiescence after a concurrent termination transport fails', async () => { const fake = new FakeSandbox() fake.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed')) @@ -1187,6 +1202,23 @@ describe('E2BSubprocessHandle', () => { await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) }) + it('settles output backpressure when the consumer closes the pipe', async () => { + const fake = new FakeSandbox() + const handle = new E2BSubprocessHandle(runtime(fake), spec({ + stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } }, + }), '/runtime/backpressure-close') + await flush() + + const stdoutWrite = vi.spyOn(handle.stdout!, 'write').mockReturnValueOnce(false) + const pending = fake.stdout('discarded') + queueMicrotask(() => { handle.stdout!.destroy() }) + await pending + stdoutWrite.mockRestore() + + fake.finish() + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + }) + it('contains a pipe callback failure instead of rejecting command settlement', async () => { const fake = new FakeSandbox() const handle = new E2BSubprocessHandle(runtime(fake), spec({ diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index e77646e586..b47cb8ffb1 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -95,6 +95,7 @@ class FakeTerminalSandbox { sessionId = '123\n' foreground = '456\n' groups = [123] + zombieGroups: number[] = [] createError: unknown writeError: unknown sendError: unknown @@ -160,9 +161,12 @@ class FakeTerminalSandbox { if (this.foregroundFailure !== undefined) throw this.foregroundFailure return { exitCode: 0, stdout: this.foreground, stderr: '' } } - if (command.startsWith('ps -eo sid=')) { + if (command.startsWith('set -o pipefail; ps -eo sid=')) { if (this.sessionGroupsFailure !== undefined) throw this.sessionGroupsFailure - return { exitCode: 0, stdout: this.groups.map(group => `${group}\n`).join(''), stderr: '' } + const groups = command.includes('stat=') && command.includes('$3 !~ /^[ZXx]/') + ? this.groups + : [...this.groups, ...this.zombieGroups] + return { exitCode: 0, stdout: groups.map(group => `${group}\n`).join(''), stderr: '' } } if (command.startsWith('kill -TERM -- ')) { if (this.termFailure !== undefined) throw this.termFailure @@ -508,6 +512,20 @@ describe('E2B terminal lifecycle', () => { await expect(quiescence).resolves.toBe(true) }) + it('treats a terminal session containing only zombies as quiescent', async () => { + const fake = new FakeTerminalSandbox() + fake.groups = [] + fake.zombieGroups = [123] + const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/zombie-session') + + fake.handle.succeed(0) + await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(terminal.waitForExit()).resolves.toBe(true) + expect(fake.commands).toContain( + "set -o pipefail; ps -eo sid=,pgid=,stat= | awk '$1 == 123 && $3 !~ /^[ZXx]/ { print $2 }'", + ) + }) + it('rejects killing the terminal shell and propagates live foreground failures', async () => { const fake = new FakeTerminalSandbox() fake.foreground = '123\n'