diff --git a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.i18n.yaml index b5e8e6e47c..079924ac74 100644 --- a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.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 .agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md -2026-07-27-e2b-remote-runtime-poc.md: 029950cb2dae47bcf1ce819156135e58d46a4bb6 -2026-07-27-e2b-remote-runtime-poc.zh.md: 4029cc85d206e0dd0b7dbd91ec03674062df8531 +2026-07-27-e2b-remote-runtime-poc.md: 02de8df021094fa326431a996cd183d13b9c0c8c +2026-07-27-e2b-remote-runtime-poc.zh.md: 3b7ddf761b2cbf950898a0083cf9da653d20e723 diff --git a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md index 029950cb2d..02de8df021 100644 --- a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md +++ b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md @@ -38,7 +38,7 @@ The POC has no session-persistence backend, template builder, volume, snapshot, Focused package suites pin owner lifecycle cleanup, filesystem paths/containment/bounded descriptor reads and commit metadata, subprocess executable lookup/process groups/publication rollback, terminal byte I/O/signal identity/default-environment scrubbing/session cleanup, output limits, abort ordering, disposal to quiescence, and package-owned invariant registrations. The generic PTY, LSP, and subprocess Code Runtime suites pin their provider-neutral readiness, cross-namespace `processId`, binding bridge, descriptor isolation, hostile traffic, and worker/descendant cleanup behavior. -A credential-gated Loader composition creates real E2B sandboxes and exercises FS-to-Bash and Bash-to-FS visibility, process-publication rollback, byte-split UTF-8 command output, bounded raw spill output, PTY default-secret scrubbing, stale-interrupt identity, and process-tree cleanup, parent-swap-safe bounded LSP source reads, Code Runtime host bindings, descriptor-isolated output accounting, descendant-held pipe cleanup, wall timeout, abort, runner cleanup, host-workspace isolation, and final sandbox deletion. The same composition runs through source imports and built package exports. +A credential-gated Loader composition creates real E2B sandboxes and exercises FS-to-Bash and Bash-to-FS visibility, real-directory runtime-state setup, process-publication rollback, byte-split UTF-8 command output, bounded raw spill and inherited-output draining, PTY default-secret scrubbing, stale-interrupt identity, and process-tree cleanup, parent-swap-safe bounded LSP source reads, Code Runtime host bindings, descriptor-isolated output accounting, descendant-held pipe cleanup, wall timeout, abort, runner cleanup, host-workspace isolation, and final sandbox deletion. The same composition runs through source imports and built package exports. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.zh.md b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.zh.md index 4029cc85d2..3b7ddf761b 100644 --- a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.zh.md @@ -38,7 +38,7 @@ E2B 拥有可变文件系统、受管命令与 Bash 进程、终端分配与终 聚焦包测试套件固定所有者生命周期清理、文件系统路径/containment/有界描述符读取与提交元数据、子进程可执行文件查找/进程组/发布回滚、终端字节 I/O/信号身份/默认环境清理/会话清理、输出上限、中止顺序、等待完全停稳的资源释放,以及包自有不变式注册。通用 PTY、LSP 与子进程 Code Runtime 测试套件固定其提供方无关的就绪判定、跨命名空间 `processId`、绑定桥接、描述符隔离、恶意通信,以及 worker/后代进程清理行为。 -凭据门控的 Loader 组合会创建真实 E2B 沙箱,并演练 FS-to-Bash 与 Bash-to-FS 可见性、进程发布回滚、按字节切分的 UTF-8 命令输出、有界原始 spill 输出、PTY 默认秘密清理、陈旧中断身份与进程树清理、可抵御父目录替换的有界 LSP 源码读取、Code Runtime 宿主绑定、描述符隔离的输出记账、后代进程所持管道的清理、墙钟超时、中止、runner 清理、宿主工作区隔离,以及最终删除沙箱。同一组合分别通过源代码导入与已构建包导出运行。 +凭据门控的 Loader 组合会创建真实 E2B 沙箱,并演练 FS-to-Bash 与 Bash-to-FS 可见性、真实目录形式的运行时状态设置、进程发布回滚、按字节切分的 UTF-8 命令输出、有界原始 spill 与继承输出排空、PTY 默认秘密清理、陈旧中断身份与进程树清理、可抵御父目录替换的有界 LSP 源码读取、Code Runtime 宿主绑定、描述符隔离的输出记账、后代进程所持管道的清理、墙钟超时、中止、runner 清理、宿主工作区隔离,以及最终删除沙箱。同一组合分别通过源代码导入与已构建包导出运行。 ## 曾考虑的替代方案 diff --git a/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts b/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts index 7c5fce5535..6fa95c24b6 100644 --- a/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts +++ b/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts @@ -85,6 +85,30 @@ try { throw new Error(`E2B subprocess corrupted split UTF-8 output: ${JSON.stringify({ splitUtf8Outcome, splitUtf8Output })}`) } + const outputDrainStarted = Date.now() + const outputDrainHandle = ctx.subprocess.spawn({ + argv: ['bash', '-c', "bash -c 'exec -a dsh-output-drain-descendant sleep 30' & printf 'leader-done\\n'"], + cwd: process.cwd(), + stdio: { stdin: 'ignore', stdout: { maxBytes: 64 }, stderr: { maxBytes: 4_096 } }, + graceMs: 250, + env: {}, + }) + const outputDrainOutcome = await outputDrainHandle.done + const outputDrainText = outputDrainHandle.collected.stdout?.readFrom(0).text + const outputDrainElapsedMs = Date.now() - outputDrainStarted + outputDrainHandle.terminate() + const outputDrainExited = await outputDrainHandle.waitForExit(AbortSignal.timeout(5_000)) + const outputDrainProcesses = await sandbox.commands.list() + const outputDrainClean = !outputDrainProcesses.some(processInfo => + JSON.stringify([processInfo.cmd, processInfo.args]).includes('dsh-output-drain-descendant'), + ) + if (outputDrainOutcome.exitCode !== 0 || outputDrainText !== 'leader-done\n' + || outputDrainElapsedMs >= 10_000 || !outputDrainExited || !outputDrainClean) { + throw new Error(`E2B subprocess output drain was not bounded: ${JSON.stringify({ + outputDrainOutcome, outputDrainText, outputDrainElapsedMs, outputDrainExited, outputDrainClean, + })}`) + } + const remoteFiles = sandbox.files as unknown as { read(path: string, options?: unknown): Promise } @@ -384,6 +408,7 @@ try { fsRead, explicitEnvironment, splitUtf8Output, + outputDrain: { outcome: outputDrainOutcome, text: outputDrainText, exited: outputDrainExited, clean: outputDrainClean }, publicationRollback, spill: { liveBytes: liveSpillBytes, outcome: spillOutcome, read: spillRead }, hover, diff --git a/packages/e2b/e2b/README.i18n.yaml b/packages/e2b/e2b/README.i18n.yaml index 229a9e9343..d8f1dcdc3f 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: 56606a22c36e65bcc53f2b8cce27f0739da1b8e2 -README.zh.md: 2e3213e431f15f1b22d2b8a429b27ab8a6a78671 +README.md: 01c7ade2f5f31e34091ad0ae910287e659f15549 +README.zh.md: fb95a12cbbbd431ccb3283a14bb63b324f9bdea6 diff --git a/packages/e2b/e2b/README.md b/packages/e2b/e2b/README.md index 56606a22c3..01c7ade2f5 100644 --- a/packages/e2b/e2b/README.md +++ b/packages/e2b/e2b/README.md @@ -28,7 +28,7 @@ Set `sandboxId` to reconnect a running or paused sandbox instead of creating one ## Lifecycle and ownership -Construction starts one create/connect operation. Before resolving `getSandbox()`, the service creates `cwd` and the private `cwd/.dsh-e2b` adapter-state directory, then sets that directory to mode `0700`. `sandboxId` resolves to a branded `E2BSandboxId` after setup. +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. diff --git a/packages/e2b/e2b/README.zh.md b/packages/e2b/e2b/README.zh.md index 2e3213e431..fb95a12cbb 100644 --- a/packages/e2b/e2b/README.zh.md +++ b/packages/e2b/e2b/README.zh.md @@ -28,7 +28,7 @@ ## 生命周期与所有权 -构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,再把该目录的 mode 设为 `0700`。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`。 +构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`。 资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 表示因超时终止的沙箱已经完全停稳;其他处置失败都会使 teardown 拒绝。新建沙箱的初始目录设置失败时,服务会终止该沙箱;重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。 diff --git a/packages/e2b/e2b/src/index.ts b/packages/e2b/e2b/src/index.ts index 96da912033..2c0b3c635c 100644 --- a/packages/e2b/e2b/src/index.ts +++ b/packages/e2b/e2b/src/index.ts @@ -7,7 +7,7 @@ import { posix } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' -import { Sandbox, SandboxNotFoundError } from 'e2b' +import { FileType, Sandbox, SandboxNotFoundError } from 'e2b' import type { Branded } from '@deepseek-ai/dsh-brand' export { @@ -232,6 +232,10 @@ export class E2BSandboxService extends Service { try { await sandbox.files.makeDir(this.cwd) await sandbox.files.makeDir(this.runtimeRoot) + const runtimeRoot = await sandbox.files.getInfo(this.runtimeRoot) + if (runtimeRoot.type !== FileType.DIR || runtimeRoot.symlinkTarget !== undefined) { + throw new Error(`dsh-e2b: runtime root must be a real directory: ${this.runtimeRoot}`) + } await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(this.runtimeRoot)}`) return sandbox } catch (error: unknown) { diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index 4e7be57c45..1ece44bb6a 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -5,7 +5,7 @@ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { Sandbox, SandboxNotFoundError } from '@deepseek-ai/dsh-e2b' +import E2BSandboxService, { Sandbox, SandboxNotFoundError } from '@deepseek-ai/dsh-e2b' import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty' import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local' import { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -92,6 +92,27 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { await session.close('environment test complete') await subprocessFiber.dispose() await ptyFiber.dispose() + + await sandbox.commands.run([ + 'rm -rf -- /home/user/.dsh-e2b /home/user/dsh-e2b-runtime-target', + 'mkdir -p -- /home/user/dsh-e2b-runtime-target', + 'chmod 755 -- /home/user/dsh-e2b-runtime-target', + 'ln -s -- /home/user/dsh-e2b-runtime-target /home/user/.dsh-e2b', + ].join('\n')) + const linkedCtx = new Context() + const linkedFiber = await linkedCtx.plugin(E2BSandboxService, { + apiKey, + sandboxId: sandbox.sandboxId, + cwd: '/home/user', + onDispose: 'leave', + }) + try { + await expect(linkedCtx.e2b.getSandbox()).rejects.toThrow('runtime root must be a real directory') + const target = await sandbox.files.getInfo('/home/user/dsh-e2b-runtime-target') + expect(target.mode & 0o777).toBe(0o755) + } finally { + await linkedFiber.dispose() + } } finally { await sandbox.kill().catch(() => false) } @@ -123,6 +144,12 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { fsRead: 'written-by-bash\n', explicitEnvironment: true, splitUtf8Output: '你好', + outputDrain: { + outcome: { exitCode: 0, signal: null }, + text: 'leader-done\n', + exited: true, + clean: true, + }, publicationRollback: true, spill: { liveBytes: 6, diff --git a/packages/e2b/e2b/tests/e2b.spec.ts b/packages/e2b/e2b/tests/e2b.spec.ts index bb96ca806a..ff90c37b3a 100644 --- a/packages/e2b/e2b/tests/e2b.spec.ts +++ b/packages/e2b/e2b/tests/e2b.spec.ts @@ -3,6 +3,7 @@ import { Context } from 'cordis' import type { Sandbox as SandboxType } from 'e2b' import E2BSandboxService, { E2BSandboxId, + FileType, SandboxNotFoundError, quoteE2BShellArg, } from '@deepseek-ai/dsh-e2b' @@ -33,6 +34,7 @@ vi.mock('e2b', async (importOriginal) => { interface SandboxFixture { sandbox: SandboxType makeDir: ReturnType + getInfo: ReturnType run: ReturnType kill: ReturnType pause: ReturnType @@ -40,17 +42,18 @@ interface SandboxFixture { function fakeSandbox(id = 'sandbox-1'): SandboxFixture { const makeDir = vi.fn().mockResolvedValue(true) + const getInfo = vi.fn().mockResolvedValue({ type: FileType.DIR }) const run = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) const kill = vi.fn().mockResolvedValue(undefined) const pause = vi.fn().mockResolvedValue(true) const sandbox = { sandboxId: id, - files: { makeDir }, + files: { makeDir, getInfo }, commands: { run }, kill, pause, } as unknown as SandboxType - return { sandbox, makeDir, run, kill, pause } + return { sandbox, makeDir, getInfo, run, kill, pause } } beforeEach(() => { @@ -82,6 +85,7 @@ describe('E2BSandboxService', () => { }) expect(fixture.makeDir).toHaveBeenNthCalledWith(1, '/home/user/workspace') expect(fixture.makeDir).toHaveBeenNthCalledWith(2, '/home/user/workspace/.dsh-e2b') + expect(fixture.getInfo).toHaveBeenCalledWith('/home/user/workspace/.dsh-e2b') expect(fixture.run).toHaveBeenCalledWith("chmod 700 -- '/home/user/workspace/.dsh-e2b'") await fiber.dispose() @@ -220,6 +224,21 @@ describe('E2BSandboxService', () => { expect(fixture.kill).not.toHaveBeenCalled() }) + it.each([ + ['symbolic link', { type: FileType.DIR, symlinkTarget: '/tmp/redirected' }], + ['regular file', { type: FileType.FILE }], + ])('rejects a reserved runtime root that is a %s', async (_label, info) => { + const fixture = fakeSandbox() + fixture.getInfo.mockResolvedValueOnce(info) + sdk.connect.mockResolvedValue(fixture.sandbox) + const ctx = new Context() + await ctx.plugin(E2BSandboxService, { apiKey: 'test-key', sandboxId: 'existing' }) + + await expect(ctx.e2b.getSandbox()).rejects.toThrow('runtime root must be a real directory') + expect(fixture.run).not.toHaveBeenCalled() + expect(fixture.kill).not.toHaveBeenCalled() + }) + it.each([ [{ apiKey: '' }, /configure apiKey/], [{ apiKey: 'x', cwd: 'relative' }, /absolute Linux path/], diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index 33a36141d0..091a6972cf 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: 58f13b264324d2168d4bc264b60100cdafa0a366 -README.zh.md: d547e4e8d66f629edcef753651180fe1806ad9bf +README.md: b1aa480305fef9ca485340b6bdae50f9fcffd88f +README.zh.md: b4824c90e7fb908d9c12e77c19d2b952055dcb40 diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index 58f13b2643..b1aa480305 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -6,14 +6,14 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr ## Behavior -- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `-1` until the wrapper publishes and the adapter validates its process-group id; `done`, stdin, termination, and `waitForExit()` wait for readiness internally. +- **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. 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** — 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`. +- **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. Before publication, cancellation uses the provisional SDK handle; if publication fails, rollback kills and verifies the provisional 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. 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; after `graceMs`, the adapter disconnects an incomplete SDK stream, withholds its partial spill, and returns that status while retaining the remote group for `waitForExit()` and termination. 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. Setup and teardown own the private state transaction, including failure cleanup. 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`, 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`, `rm`, and `kill`. A custom template must retain compatible commands and E2B PTY support. ## Model Experience diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index d547e4e8d6..b4824c90e7 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -6,14 +6,14 @@ ## 行为 -- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid` 为 `-1`;`done`、stdin、终止和 `waitForExit()` 会在内部等待就绪。 +- **异步远程启动**:同步 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 回退。如果发布失败,SDK PID 仍为临时的 `exec setsid` 进程组 ID;回滚会终止并验证该进程组,随后启动操作才会以拒绝结束。服务 dispose(资源释放)会在沙箱所有者释放前终止并等待每个保留句柄退出。 -- **环境边界**:包装层从沙箱命令环境开始,移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),再把每个 `spec.env` 条目恢复为调用方显式选择。宿主环境变量绝不会隐式进入沙箱。 -- **stdio 投影**:远程包装层先把原始字节分流到可选的有界 spill 文件,再把每个实时分片编码为换行分隔的 base64 ASCII 帧;宿主会跨任意 SDK 回调边界增量恢复字节。pipe 模式把这些字节写入宿主 Node 流;inherit 模式把字节写入 harness 进程流;collect 模式保留有界的宿主尾部,并支持基于偏移量读取。批量 stdin 和流式 stdin 都使用 SDK 句柄。 -- **终端会话**:`spawnTerminal()` 使用 E2B 的字节 PTY API,以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并在结算前清理远程终端会话中的每个进程组。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。 +- **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv,并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退;TERM 信号发送或探测失败也会强制触发该升级。失败的事务可通过 `waitForExit()` 观察,并可重试。发布前,取消操作使用临时 SDK 句柄;如果发布失败,回滚会终止并验证临时进程组,随后启动操作才会拒绝。服务 dispose(资源释放)会在沙箱所有者释放前终止并等待每个保留句柄退出。 +- **环境边界**:包装层从沙箱命令环境开始,移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),再把每个 `spec.env` 条目恢复为调用方显式选择。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。 +- **stdio 投影**:远程包装层先把原始字节分流到可选的有界 spill 文件,再把每个实时分片编码为换行分隔的 base64 ASCII 帧;宿主会跨任意 SDK 回调边界增量恢复字节。pipe 模式把这些字节写入宿主 Node 流;inherit 模式把字节写入 harness 进程流;collect 模式保留有界的宿主尾部,并支持基于偏移量读取。包装层会在等待继承管道的写入方之前发布直接命令状态;超过 `graceMs` 后,适配器会断开未完成的 SDK 流,不公开其中不完整的 spill,并返回该状态,同时保留远程进程组供 `waitForExit()` 和终止操作使用。批量 stdin 和流式 stdin 都使用 SDK 句柄。 +- **终端会话**:`spawnTerminal()` 使用 E2B 的字节 PTY API,以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并在结算前清理远程终端会话中的每个进程组。setup 与 teardown 负责私有状态事务,包括失败清理。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。 -基础 E2B 镜像提供该适配器调用的运行时和 Bash/GNU 工具:`node`、`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`、`rm` 和 `kill`。自定义模板必须保留兼容的命令和 E2B PTY 支持。 ## 模型体验 diff --git a/packages/e2b/subprocess-e2b/src/index.ts b/packages/e2b/subprocess-e2b/src/index.ts index 86ca5ceada..27efb216da 100644 --- a/packages/e2b/subprocess-e2b/src/index.ts +++ b/packages/e2b/subprocess-e2b/src/index.ts @@ -46,14 +46,12 @@ export class E2BSubprocessService extends SubprocessService { const pending: Promise[] = [] for (const handle of handles) { handle.terminate() - pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit())) + pending.push(handle.waitForExit().then(() => { this.live.delete(handle) })) } for (const terminal of terminals) { terminal.terminate() - pending.push(terminal.waitForExit()) + pending.push(terminal.waitForExit().then(() => { this.terminals.delete(terminal) })) } - this.live.clear() - this.terminals.clear() await Promise.all(pending) }, 'e2b subprocess teardown') } diff --git a/packages/e2b/subprocess-e2b/src/output.ts b/packages/e2b/subprocess-e2b/src/output.ts index 6cb61e4f09..8afcd138dc 100644 --- a/packages/e2b/subprocess-e2b/src/output.ts +++ b/packages/e2b/subprocess-e2b/src/output.ts @@ -66,6 +66,7 @@ export class E2BOutputReader implements SubprocessOutputReader { private chunks: Buffer[] = [] private retainedBytes = 0 private totalBytes = 0 + private spillValid = true /** * Create a bounded reader over one remote spill path. @@ -84,6 +85,11 @@ export class E2BOutputReader implements SubprocessOutputReader { return this.totalBytes } + /** Stop advertising a remote spill whose writer did not reach clean EOF. */ + invalidateSpill(): void { + this.spillValid = false + } + /** * Append one byte-faithful decoded transport event. * @param bytes - Raw command bytes recovered from the ASCII SDK transport. @@ -120,7 +126,7 @@ export class E2BOutputReader implements SubprocessOutputReader { text: retained.subarray(start).toString('utf8'), nextOffset: this.totalBytes, lossy, - ...(lossy && this.maxSpillBytes !== undefined && this.totalBytes <= this.maxSpillBytes + ...(lossy && this.spillValid && this.maxSpillBytes !== undefined && this.totalBytes <= this.maxSpillBytes ? { spillPath: this.spillPath } : {}), } diff --git a/packages/e2b/subprocess-e2b/src/process.ts b/packages/e2b/subprocess-e2b/src/process.ts index 5f8a107cf6..3a8047eb54 100644 --- a/packages/e2b/subprocess-e2b/src/process.ts +++ b/packages/e2b/subprocess-e2b/src/process.ts @@ -5,6 +5,7 @@ import { PassThrough, Writable } from 'node:stream' import { posix } from 'node:path' import { CommandExitError, + FileNotFoundError, quoteE2BShellArg, } from '@deepseek-ai/dsh-e2b' import type { CommandHandle, CommandResult, Sandbox } from '@deepseek-ai/dsh-e2b' @@ -41,6 +42,10 @@ function hasSpill(mode: SubprocessOutputMode): mode is SubprocessCollect & { spi return isCollect(mode) && mode.spill !== undefined } +function isValidProcessId(value: number): boolean { + return Number.isSafeInteger(value) && value > 0 +} + function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } @@ -73,6 +78,20 @@ interface RemotePaths { stderr: string } +type CommandSettlement = + | { kind: 'result'; result: CommandResult } + | { kind: 'error'; error: unknown } + +function withinMs(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { resolve(undefined) }, timeoutMs) + void promise.then((value) => { + clearTimeout(timer) + resolve(value) + }) + }) +} + function remoteEnvironment(raw: string, explicit: Readonly> | undefined): string { const environment = scrubRemoteEnvironment(raw) for (const [name, value] of Object.entries(explicit ?? {})) environment.set(name, value) @@ -96,15 +115,16 @@ function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string { 'dsh_e2b_tr=$4', 'dsh_e2b_tee=$5', 'dsh_e2b_head=$6', - 'shift 6', + 'dsh_e2b_rm=$7', + 'shift 7', '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_env < ${quoteE2BShellArg(paths.environment)}`, - `: > ${quoteE2BShellArg(paths.environment)}`, + `"$dsh_e2b_rm" -f -- ${quoteE2BShellArg(paths.environment)}`, `"$dsh_e2b_env_bin" -i "\${dsh_e2b_env[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(), 'dsh_e2b_status=$?', - 'wait', `printf '%s\\n' "$dsh_e2b_status" > ${quoteE2BShellArg(paths.status)}`, + 'wait', 'exit "$dsh_e2b_status"', ].join('\n') const argv = spec.argv.map(quoteE2BShellArg).join(' ') @@ -118,10 +138,11 @@ function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string { '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_rm="$(command -v rm)"', + '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" ${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 } @@ -149,6 +170,26 @@ function waitTick(signal?: AbortSignal): Promise { }) } +const WAIT_ABORTED = Symbol('wait aborted') + +function waitWithSignal(promise: Promise, signal: AbortSignal | undefined): Promise { + if (signal === undefined) return promise + if (signal.aborted) return Promise.resolve(WAIT_ABORTED) + return new Promise((resolve, reject) => { + const onAbort = (): void => { cleanup(); resolve(WAIT_ABORTED) } + const cleanup = (): void => { signal.removeEventListener('abort', onAbort) } + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + return + } + void promise.then( + (value) => { cleanup(); resolve(value) }, + (error: unknown) => { cleanup(); reject(asError(error)) }, + ) + }) +} + /** E2B-backed subprocess handle with deferred remote PID acquisition. */ export class E2BSubprocessHandle implements SubprocessHandle { readonly stdin: Writable | undefined @@ -157,6 +198,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { readonly collected: SubprocessHandle['collected'] readonly done: Promise + private readonly commandState = Promise.withResolvers() private readonly readyState = Promise.withResolvers() private readonly stdoutDecoder = new E2BBase64Decoder() private readonly stderrDecoder = new E2BBase64Decoder() @@ -166,9 +208,14 @@ export class E2BSubprocessHandle implements SubprocessHandle { private remotePid = -1 private commandHandle: CommandHandle | undefined private outputTransportError: Error | undefined - private terminationRequested = false + private outputDrainExpired = false + private stateDirectoryCreated = false + private invalidHandleQuiescent = false + private provisionalHandleQuiescent = false + private terminationStarted = false + private terminationAttempt: Promise | undefined + private terminationFailure: Error | undefined private terminationSignal: NodeJS.Signals | null = null - private termination: Promise | undefined /** * Begin an E2B command without blocking the synchronous subprocess spawn seam. @@ -217,22 +264,47 @@ export class E2BSubprocessHandle implements SubprocessHandle { /** @inheritdoc */ terminate(): void { - if (this.terminationRequested) return - this.terminationRequested = true - this.termination = this.terminateRemote() - void this.termination.catch(() => {}) + if (this.terminationAttempt !== undefined) return + this.terminationStarted = true + this.terminationFailure = undefined + const attempt = this.terminateRemote() + this.terminationAttempt = attempt + void attempt.then( + () => { + this.terminationAttempt = undefined + }, + (error: unknown) => { + this.terminationFailure = asError(error) + this.terminationAttempt = undefined + }, + ) } /** @inheritdoc */ async waitForExit(signal?: AbortSignal): Promise { let handle: CommandHandle | undefined - try { - handle = await this.readyForWait(signal) - } catch { - handle = this.commandHandle + if (this.terminationStarted) { + const observed = await waitWithSignal(this.commandState.promise, signal) + if (observed === WAIT_ABORTED) return false + handle = observed if (handle === undefined) return true + if (this.remotePid <= 0) { + const attempt = this.terminationAttempt + if (attempt !== undefined && await waitWithSignal(attempt, signal) === WAIT_ABORTED) return false + this.throwTerminationFailure() + if (this.invalidHandleQuiescent || this.provisionalHandleQuiescent) return true + } + } else { + try { + const observed = await waitWithSignal(this.readyState.promise, signal) + if (observed === WAIT_ABORTED) return false + handle = observed + } catch { + handle = this.commandHandle + if (handle === undefined) return true + } } - if (handle === undefined) return false + this.throwTerminationFailure() let sandbox: Sandbox try { sandbox = await this.runtime.getSandbox() @@ -242,33 +314,19 @@ export class E2BSubprocessHandle implements SubprocessHandle { } const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid while (await this.groupAlive(sandbox, processGroupId, signal)) { + this.throwTerminationFailure() if (!await waitTick(signal)) return false } + this.throwTerminationFailure() return !isAborted(signal) } - private readyForWait(signal: AbortSignal | undefined): Promise { - if (signal === undefined) return this.readyState.promise - return new Promise((resolve, reject) => { - const onAbort = (): void => { cleanup(); resolve(undefined) } - const cleanup = (): void => { signal.removeEventListener('abort', onAbort) } - signal.addEventListener('abort', onAbort, { once: true }) - if (signal.aborted) { - onAbort() - return - } - void this.readyState.promise.then( - (handle) => { cleanup(); resolve(handle) }, - (error: unknown) => { cleanup(); reject(asError(error)) }, - ) - }) - } - private readonly onAbort = (): void => { this.terminate() } private async run(): Promise { + let sandbox: Sandbox | undefined try { - const sandbox = await this.runtime.getSandbox() + sandbox = await this.runtime.getSandbox() await this.prepareState(sandbox) const handle = await sandbox.commands.run( commandText(this.spec, this.paths), @@ -281,12 +339,26 @@ export class E2BSubprocessHandle implements SubprocessHandle { onStderr: async (data) => { await this.dispatchOutput('stderr', data) }, }, ) - 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(() => {}) + if (!isValidProcessId(handle.pid)) { + const invalidPid = new Error(`subprocess-e2b: E2B returned invalid command pid ${handle.pid}`) + try { + await handle.kill() + this.invalidHandleQuiescent = true + this.commandHandle = undefined + } catch (cleanupError: unknown) { + this.terminationFailure = asError(cleanupError) + this.commandState.resolve(handle) + throw new AggregateError( + [invalidPid, cleanupError], + 'subprocess-e2b: invalid command pid rollback did not reach quiescence', + ) + } + throw invalidPid + } + this.commandState.resolve(handle) try { this.remotePid = await this.waitForProcessGroupId(sandbox, completion) } catch (error: unknown) { @@ -302,16 +374,28 @@ export class E2BSubprocessHandle implements SubprocessHandle { } this.readyState.resolve(handle) await this.writeBatchStdin(handle) - const outcome = await this.waitForCommand(completion) + const outcome = await this.waitForCommand(sandbox, handle, completion) if (this.outputTransportError !== undefined) throw this.outputTransportError - const requireCompleteOutput = this.terminationSignal === null + const requireCompleteOutput = this.terminationSignal === null && !this.outputDrainExpired this.stdoutDecoder.finish(requireCompleteOutput) this.stderrDecoder.finish(requireCompleteOutput) await this.finalizeSpills(sandbox) return outcome } catch (error: unknown) { - this.readyState.reject(error) - throw error + this.commandState.resolve(undefined) + let failure = error + if (sandbox !== undefined && this.stateDirectoryCreated) { + try { + await this.removeFailedState(sandbox) + } catch (cleanupError: unknown) { + failure = new AggregateError( + [error, cleanupError], + 'subprocess-e2b: command failed and private state cleanup failed', + ) + } + } + this.readyState.reject(failure) + throw failure } finally { this.spec.signal?.removeEventListener('abort', this.onAbort) this.stdout?.end() @@ -322,6 +406,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { private async prepareState(sandbox: Sandbox): Promise { const ambient = await sandbox.commands.run('env -0') await sandbox.files.makeDir(this.stateDir) + this.stateDirectoryCreated = true await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`) const files = [ { path: this.paths.pid, data: '' }, @@ -405,20 +490,45 @@ export class E2BSubprocessHandle implements SubprocessHandle { } } - private async waitForCommand(completion: Promise): Promise { - try { - const result = await completion - return { exitCode: result.exitCode, signal: null } - } catch (error: unknown) { - if (error instanceof CommandExitError) { - return this.terminationSignal === null - ? { exitCode: error.exitCode, signal: null } - : { exitCode: null, signal: this.terminationSignal } + private async waitForCommand( + sandbox: Sandbox, + handle: CommandHandle, + completion: Promise, + ): Promise { + const settlement = completion.then( + result => ({ kind: 'result', result }), + (error: unknown) => ({ kind: 'error', error }), + ) + while (true) { + const rawStatus = (await sandbox.files.read(this.paths.status)).trim() + if (rawStatus.length > 0) { + const exitCode = Number(rawStatus) + if (!/^(?:0|[1-9][0-9]*)$/.test(rawStatus) || !Number.isSafeInteger(exitCode) || exitCode > 255) { + throw new Error(`subprocess-e2b: remote wrapper published invalid exit code ${JSON.stringify(rawStatus)}`) + } + const completed = await withinMs(settlement, this.spec.graceMs) + if (completed !== undefined) return this.commandOutcome(completed) + this.outputDrainExpired = true + this.stdoutReader?.invalidateSpill() + this.stderrReader?.invalidateSpill() + await handle.disconnect() + return { exitCode, signal: null } } - throw error + const completed = await Promise.race([settlement, waitTick().then(() => undefined)]) + if (completed !== undefined) return this.commandOutcome(completed) } } + private commandOutcome(settlement: CommandSettlement): SubprocessOutcome { + if (settlement.kind === 'result') return { exitCode: settlement.result.exitCode, signal: null } + if (settlement.error instanceof CommandExitError) { + return this.terminationSignal === null + ? { exitCode: settlement.error.exitCode, signal: null } + : { exitCode: null, signal: this.terminationSignal } + } + throw settlement.error + } + private async rollbackUnpublishedGroup(sandbox: Sandbox, handle: CommandHandle): Promise { // 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 @@ -433,35 +543,99 @@ export class E2BSubprocessHandle implements SubprocessHandle { } private async terminateRemote(): Promise { - let handle: CommandHandle | undefined - try { - handle = await this.readyState.promise - } catch { - handle = this.commandHandle - } + const handle = await this.commandState.promise 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, processGroupId, 'TERM') - const deadline = Date.now() + this.spec.graceMs - while (Date.now() < deadline && await this.groupAlive(sandbox, processGroupId)) { - await waitTick() + if (!isValidProcessId(handle.pid) && this.remotePid <= 0) { + await handle.kill() + this.invalidHandleQuiescent = true + this.commandHandle = undefined + return } - if (!await this.groupAlive(sandbox, processGroupId)) return - this.terminationSignal = 'SIGKILL' + if (this.remotePid <= 0) { + const sandbox = await this.runtime.getSandbox() + this.terminationSignal = 'SIGTERM' + try { + const delivered = await this.signalGroup(sandbox, handle.pid, 'TERM') + if (delivered) { + const deadline = Date.now() + this.spec.graceMs + while (Date.now() < deadline && await this.groupAlive(sandbox, handle.pid)) await waitTick() + if (!await this.groupAlive(sandbox, handle.pid)) { + this.provisionalHandleQuiescent = true + return + } + } + } catch (_gracefulTerminationFailure) { + // A missing or unobservable provisional group still has the SDK handle fallback. + } + this.terminationSignal = 'SIGKILL' + let groupDelivered = false + let groupFailure: unknown + try { + groupDelivered = await this.signalGroup(sandbox, handle.pid, 'KILL') + } catch (error: unknown) { + groupFailure = error + } + let handleFailure: unknown + try { + await handle.kill() + this.provisionalHandleQuiescent = true + } catch (error: unknown) { + handleFailure = error + } + if (!groupDelivered && handleFailure !== undefined) { + throw new AggregateError( + [...(groupFailure === undefined ? [] : [groupFailure]), handleFailure], + 'subprocess-e2b: force termination failed through both process-group and SDK transports', + ) + } + return + } + const sandbox = await this.runtime.getSandbox() + const processGroupId = this.remotePid + this.terminationSignal = 'SIGTERM' try { - await this.signalGroup(sandbox, processGroupId, 'KILL') - } finally { - await handle.kill().catch(() => false) + await this.signalGroup(sandbox, processGroupId, 'TERM') + const deadline = Date.now() + this.spec.graceMs + while (Date.now() < deadline && await this.groupAlive(sandbox, processGroupId)) { + await waitTick() + } + if (!await this.groupAlive(sandbox, processGroupId)) return + } catch (_gracefulTerminationFailure) { + // Failed TERM delivery or observation cannot prove exit; force cleanup still owns the group. + } + this.terminationSignal = 'SIGKILL' + let groupFailure: unknown + let groupDelivered = false + try { + groupDelivered = await this.signalGroup(sandbox, processGroupId, 'KILL') + } catch (error: unknown) { + groupFailure = error + } + let handleFailure: unknown + try { + await handle.kill() + } catch (error: unknown) { + handleFailure = error + } + if (!groupDelivered && handleFailure !== undefined && await this.groupAlive(sandbox, processGroupId)) { + throw new AggregateError( + [...(groupFailure === undefined ? [] : [groupFailure]), handleFailure], + 'subprocess-e2b: force termination failed through both process-group and SDK transports', + ) } } - private async signalGroup(sandbox: Sandbox, pid: number, signal: 'TERM' | 'KILL'): Promise { + private throwTerminationFailure(): void { + if (this.terminationFailure !== undefined) throw this.terminationFailure + } + + private async signalGroup(sandbox: Sandbox, pid: number, signal: 'TERM' | 'KILL'): Promise { try { await sandbox.commands.run(`kill -${signal} -- -${pid}`) + return true } catch (error: unknown) { - if (!(error instanceof CommandExitError)) throw error + if (error instanceof CommandExitError) return false + throw error } } @@ -482,7 +656,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { if (!hasSpill(mode)) return // A spill mode is a collect mode, so construction always created its reader. const size = (reader as E2BOutputReader).size - if (size <= mode.maxBytes || size > mode.spill.maxBytes) { + if (this.outputDrainExpired || size <= mode.maxBytes || size > mode.spill.maxBytes) { removals.push(sandbox.files.remove(path).catch(() => {})) } } @@ -490,4 +664,18 @@ export class E2BSubprocessHandle implements SubprocessHandle { collect(this.spec.stdio.stderr, this.stderrReader, this.paths.stderr) await Promise.all(removals) } + + private async removeFailedState(sandbox: Sandbox): Promise { + const failures: Error[] = [] + for (const path of [this.paths.environment, this.stateDir]) { + try { + await sandbox.files.remove(path) + } catch (error: unknown) { + if (!(error instanceof FileNotFoundError)) failures.push(asError(error)) + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'subprocess-e2b: failed to remove private command state') + } + } } diff --git a/packages/e2b/subprocess-e2b/src/terminal.ts b/packages/e2b/subprocess-e2b/src/terminal.ts index 7185899f79..fb8dac6f49 100644 --- a/packages/e2b/subprocess-e2b/src/terminal.ts +++ b/packages/e2b/subprocess-e2b/src/terminal.ts @@ -376,25 +376,26 @@ export async function spawnE2BTerminal( argv: posix.join(stateDir, 'argv'), ready: posix.join(stateDir, 'ready'), } - const ambient = await sandbox.commands.run('env -0', signalOpts(spec.signal)) - const environment = remoteEnvironment(ambient.stdout, spec.env) - const argv = serializeValues(spec.argv, 'argv') - await sandbox.files.makeDir(stateDir) - await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(stateDir)}`, signalOpts(spec.signal)) - await sandbox.files.write([ - { path: paths.runner, data: TERMINAL_RUNNER_SOURCE }, - { path: paths.environment, data: environment }, - { path: paths.argv, data: argv }, - ], signalOpts(spec.signal)) - await sandbox.commands.run( - `chmod 600 -- ${quoteE2BShellArg(paths.runner)} ${quoteE2BShellArg(paths.environment)} ${quoteE2BShellArg(paths.argv)}`, - signalOpts(spec.signal), - ) - const output = new PassThrough() let handle: CommandHandle | undefined let completion: Promise | undefined + let stateDirectoryCreated = false try { + const ambient = await sandbox.commands.run('env -0', signalOpts(spec.signal)) + const environment = remoteEnvironment(ambient.stdout, spec.env) + const argv = serializeValues(spec.argv, 'argv') + await sandbox.files.makeDir(stateDir) + stateDirectoryCreated = true + await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(stateDir)}`, signalOpts(spec.signal)) + await sandbox.files.write([ + { path: paths.runner, data: TERMINAL_RUNNER_SOURCE }, + { path: paths.environment, data: environment }, + { path: paths.argv, data: argv }, + ], signalOpts(spec.signal)) + await sandbox.commands.run( + `chmod 600 -- ${quoteE2BShellArg(paths.runner)} ${quoteE2BShellArg(paths.environment)} ${quoteE2BShellArg(paths.argv)}`, + signalOpts(spec.signal), + ) handle = await sandbox.pty.create({ rows: spec.rows, cols: spec.cols, @@ -425,19 +426,25 @@ export async function spawnE2BTerminal( ) } catch (error: unknown) { output.destroy() - let cleanupError: Error | undefined + const cleanupErrors: Error[] = [] if (handle !== undefined && completion !== undefined) { try { await rollbackUnpublishedTerminal(sandbox, handle, completion, spec.graceMs) } catch (rollbackError: unknown) { - cleanupError = asError(rollbackError) + cleanupErrors.push(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) + if (stateDirectoryCreated) { + try { + await sandbox.files.remove(stateDir) + } catch (stateError: unknown) { + if (!(stateError instanceof FileNotFoundError)) cleanupErrors.push(asError(stateError)) + } + } + if (cleanupErrors.length > 0) { + throw new AggregateError([asError(error), ...cleanupErrors], asError(error).message) } throw error } diff --git a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts index 32adf872b3..d81b49a600 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -2,6 +2,7 @@ import { once } from 'node:events' import { Context } from 'cordis' import { CommandExitError, + FileNotFoundError, type CommandHandle, type CommandResult, type Sandbox, @@ -35,10 +36,14 @@ class FakeCommandHandle { readonly sent: Array = [] closes = 0 kills = 0 + disconnects = 0 killError: unknown + disconnectError: unknown private readonly result = Promise.withResolvers() private settled = false + constructor(private readonly onKill: () => void = () => {}) {} + wait(): Promise { return this.result.promise } @@ -54,9 +59,15 @@ class FakeCommandHandle { async kill(): Promise { this.kills += 1 if (this.killError !== undefined) throw this.killError + this.onKill() return true } + async disconnect(): Promise { + this.disconnects += 1 + if (this.disconnectError !== undefined) throw this.disconnectError + } + succeed(exitCode = 0): void { if (this.settled) return this.settled = true @@ -77,7 +88,7 @@ class FakeCommandHandle { } class FakeSandbox { - readonly handle = new FakeCommandHandle() + readonly handle: FakeCommandHandle readonly commandsSeen: string[] = [] readonly writtenFiles: string[][] = [] readonly writtenFileData = new Map() @@ -85,19 +96,35 @@ class FakeSandbox { readonly directories: string[] = [] startOptions: StartOptions | undefined backgroundError: unknown + envError: unknown nextRemoveError: unknown probeError: unknown signalError: unknown + readonly signalErrors: unknown[] = [] trapsTerm = false delaysKill = false + sdkKillStops = true alive = true ambient = 'PATH=/ambient/bin\0KEEP=safe\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0' processGroupId = '4242\n' + exitStatus = '' readonly processGroupReads: string[] = [] + afterStatusRead: (() => void) | undefined beforeProbe: (() => void) | undefined afterProbe: (() => void) | undefined private startGate: Promise | undefined private openStart: (() => void) | undefined + private processGroupReadGate: Promise | undefined + private openProcessGroupRead: (() => void) | undefined + + constructor() { + this.handle = new FakeCommandHandle(() => { + if (this.sdkKillStops) { + this.alive = false + this.handle.fail(137) + } + }) + } deferStart(): void { const gate = Promise.withResolvers() @@ -109,6 +136,16 @@ class FakeSandbox { this.openStart?.() } + deferProcessGroupRead(): void { + const gate = Promise.withResolvers() + this.processGroupReadGate = gate.promise + this.openProcessGroupRead = () => { gate.resolve(undefined) } + } + + releaseProcessGroupRead(): void { + this.openProcessGroupRead?.() + } + finish(exitCode = 0): void { this.alive = false void this.completeOutput().then( @@ -155,7 +192,14 @@ class FakeSandbox { for (const file of files) this.writtenFileData.set(file.path, file.data) return files.map(() => ({})) }, - read: async (): Promise => this.processGroupReads.shift() ?? this.processGroupId, + read: async (path: string): Promise => { + if (!path.endsWith('/exit-code')) { + await this.processGroupReadGate + return this.processGroupReads.shift() ?? this.processGroupId + } + this.afterStatusRead?.() + return this.exitStatus + }, remove: async (path: string): Promise => { this.removed.push(path) if (this.nextRemoveError !== undefined) { @@ -168,7 +212,10 @@ class FakeSandbox { commands: { run: async (command: string, options?: StartOptions | { signal?: AbortSignal }): Promise => { this.commandsSeen.push(command) - if (command === 'env -0') return { exitCode: 0, stdout: this.ambient, stderr: '' } + if (command === 'env -0') { + if (this.envError !== undefined) throw this.envError + return { exitCode: 0, stdout: this.ambient, stderr: '' } + } if (command.startsWith('kill -0 ')) { this.beforeProbe?.() if (options?.signal?.aborted === true) throw new DOMException('aborted', 'AbortError') @@ -182,9 +229,9 @@ class FakeSandbox { return { exitCode: 0, stdout: '', stderr: '' } } if (command.startsWith('kill -TERM ')) { - if (this.signalError !== undefined) { - const error = this.signalError - this.signalError = undefined + const error = this.signalErrors.shift() ?? this.signalError + if (error !== undefined) { + if (this.signalErrors.length === 0) this.signalError = undefined throw error } if (!this.trapsTerm) { @@ -194,9 +241,9 @@ class FakeSandbox { return { exitCode: 0, stdout: '', stderr: '' } } if (command.startsWith('kill -KILL ')) { - if (this.signalError !== undefined) { - const error = this.signalError - this.signalError = undefined + const error = this.signalErrors.shift() ?? this.signalError + if (error !== undefined) { + if (this.signalErrors.length === 0) this.signalError = undefined throw error } if (!this.delaysKill) this.alive = false @@ -277,6 +324,8 @@ describe('E2BOutputReader', () => { expect(reader.readFrom(2)).toEqual({ text: 'cdef', nextOffset: 6, lossy: false }) expect(reader.readFrom(5)).toEqual({ text: 'f', nextOffset: 6, lossy: false }) expect(reader.readFrom(99)).toEqual({ text: '', nextOffset: 6, lossy: false }) + reader.invalidateSpill() + expect(reader.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true }) }) it('drops whole head chunks and withholds absent or over-cap spills', () => { @@ -390,6 +439,50 @@ describe('E2BSubprocessHandle', () => { await expect(handle.done).rejects.toThrow('incomplete output transport') }) + it('bounds descendant-held output draining and withholds the incomplete spill', async () => { + const fake = new FakeSandbox() + const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 5 }), '/runtime/drain-bound') + await flush() + await fake.stdout('leader-output') + fake.exitStatus = '0\n' + + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + expect(fake.handle.disconnects).toBe(1) + expect(handle.collected.stdout?.readFrom(0)).toEqual({ + text: 'tput', + nextOffset: 13, + lossy: true, + }) + expect(fake.removed).toContain('/runtime/drain-bound/stdout.log') + + handle.terminate() + await expect(handle.waitForExit()).resolves.toBe(true) + }) + + it('accepts clean encoder completion inside the output-drain grace', async () => { + const fake = new FakeSandbox() + const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 100 }), '/runtime/drain-complete') + await flush() + fake.exitStatus = '0\n' + fake.afterStatusRead = () => { + fake.afterStatusRead = undefined + setTimeout(() => { fake.finish() }, 0) + } + + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + expect(fake.handle.disconnects).toBe(0) + }) + + it('rejects an invalid direct-command exit status', async () => { + const fake = new FakeSandbox() + const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/invalid-status') + await flush() + fake.exitStatus = '999\n' + await expect(handle.done).rejects.toThrow('invalid exit code') + handle.terminate() + await expect(handle.waitForExit()).resolves.toBe(true) + }) + it('surfaces deferred piped-stdin write and close failures as stream errors', async () => { const writeFake = new FakeSandbox() writeFake.deferStart() @@ -510,6 +603,97 @@ describe('E2BSubprocessHandle', () => { await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' }) }) + it('kills through the provisional SDK handle before process-group publication', async () => { + const fake = new FakeSandbox() + fake.deferProcessGroupRead() + fake.signalErrors.push(commandError(1), commandError(1)) + const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/pre-publication-kill') + await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() }) + + handle.terminate() + await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) }) + expect(fake.alive).toBe(false) + await expect(handle.waitForExit()).resolves.toBe(true) + + fake.releaseProcessGroupRead() + await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) + }) + + it('bounds a quiescence observer while provisional termination is awaiting the controller', async () => { + const fake = new FakeSandbox() + fake.deferProcessGroupRead() + const reconnect = Promise.withResolvers() + let calls = 0 + const delayedRuntime = runtime(fake, async () => { + calls += 1 + return calls === 1 ? fake.sandbox : await reconnect.promise + }) + const handle = new E2BSubprocessHandle(delayedRuntime, spec(), '/runtime/pre-publication-observer') + await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() }) + handle.terminate() + + const controller = new AbortController() + const waiting = handle.waitForExit(controller.signal) + await flush() + controller.abort() + await expect(waiting).resolves.toBe(false) + + reconnect.resolve(fake.sandbox) + await expect(handle.waitForExit()).resolves.toBe(true) + fake.releaseProcessGroupRead() + await handle.done + }) + + it('proves a provisional group exit when the SDK kill fallback fails', async () => { + const fake = new FakeSandbox() + fake.deferProcessGroupRead() + fake.trapsTerm = true + fake.handle.killError = new Error('SDK kill unavailable') + const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/pre-publication-group-kill') + await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() }) + + handle.terminate() + await expect(handle.waitForExit()).resolves.toBe(true) + fake.releaseProcessGroupRead() + await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) + }) + + it('reports failed provisional group and SDK force transports', async () => { + const fake = new FakeSandbox() + fake.deferProcessGroupRead() + fake.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed')) + fake.handle.killError = new Error('SDK kill failed') + const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/pre-publication-failure') + await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() }) + + handle.terminate() + await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both') + + fake.handle.killError = undefined + handle.terminate() + await expect(handle.waitForExit()).resolves.toBe(true) + fake.releaseProcessGroupRead() + await handle.done + + const absentGroup = new FakeSandbox() + absentGroup.deferProcessGroupRead() + absentGroup.signalErrors.push(commandError(1), commandError(1)) + absentGroup.handle.killError = new Error('SDK kill failed without a provisional group') + const absentHandle = new E2BSubprocessHandle( + runtime(absentGroup), + spec({ graceMs: 1 }), + '/runtime/pre-publication-absent-group', + ) + await vi.waitFor(() => { expect(absentGroup.startOptions).toBeDefined() }) + absentHandle.terminate() + await expect(absentHandle.waitForExit()).rejects.toThrow('force termination failed through both') + absentGroup.handle.killError = undefined + absentHandle.terminate() + await expect(absentHandle.waitForExit()).resolves.toBe(true) + absentGroup.releaseProcessGroupRead() + await absentHandle.done + }) + it('honors an already-aborted signal when constructing the asynchronous handle directly', async () => { const fake = new FakeSandbox() const handle = new E2BSubprocessHandle(runtime(fake), spec({ signal: AbortSignal.abort('stop') }), '/runtime/pre-aborted') @@ -559,6 +743,17 @@ describe('E2BSubprocessHandle', () => { await expect(liveWait).resolves.toBe(false) fake.finish() await handle.done + + const terminatingFake = new FakeSandbox() + terminatingFake.deferStart() + const terminating = new E2BSubprocessHandle(runtime(terminatingFake), spec(), '/runtime/wait-termination-start') + terminating.terminate() + const beforeHandle = new AbortController() + const handlePending = terminating.waitForExit(beforeHandle.signal) + beforeHandle.abort() + await expect(handlePending).resolves.toBe(false) + terminatingFake.releaseStart() + await terminating.done }) it('bounds both sides of the liveness-poll abort race', async () => { @@ -580,6 +775,14 @@ describe('E2BSubprocessHandle', () => { const duringProbe = new AbortController() fake.beforeProbe = () => { duringProbe.abort(); fake.beforeProbe = undefined } await expect(handle.waitForExit(duringProbe.signal)).resolves.toBe(false) + + let racedAbort = false + const raceSignal = { + get aborted() { return racedAbort }, + addEventListener: () => { racedAbort = true }, + removeEventListener: () => {}, + } as unknown as AbortSignal + await expect(handle.waitForExit(raceSignal)).resolves.toBe(false) fake.finish() await handle.done }) @@ -599,8 +802,36 @@ describe('E2BSubprocessHandle', () => { const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/fail') await expect(handle.done).rejects.toThrow('start failed') expect(handle.pid).toBe(-1) + expect(fake.removed).toContain('/runtime/fail/environment') + expect(fake.removed).toContain('/runtime/fail') await expect(handle.waitForExit()).resolves.toBe(true) handle.terminate() + + const unavailableHandle = new E2BSubprocessHandle( + runtime(new FakeSandbox(), async () => { throw new Error('sandbox unavailable') }), + spec(), + '/runtime/unavailable-start', + ) + await expect(unavailableHandle.done).rejects.toThrow('sandbox unavailable') + await expect(unavailableHandle.waitForExit()).resolves.toBe(true) + + const envFailure = new FakeSandbox() + envFailure.envError = new Error('ambient lookup failed') + const envHandle = new E2BSubprocessHandle(runtime(envFailure), spec(), '/runtime/env-failure') + await expect(envHandle.done).rejects.toThrow('ambient lookup failed') + expect(envFailure.removed).toEqual([]) + + const cleanupFailure = new FakeSandbox() + cleanupFailure.backgroundError = new Error('start failed before credential consumption') + cleanupFailure.nextRemoveError = new Error('credential cleanup failed') + const cleanupHandle = new E2BSubprocessHandle(runtime(cleanupFailure), spec(), '/runtime/cleanup-failure') + await expect(cleanupHandle.done).rejects.toThrow('command failed and private state cleanup failed') + + const absentState = new FakeSandbox() + absentState.backgroundError = new Error('start failed after external cleanup') + absentState.nextRemoveError = new FileNotFoundError('already removed') + const absentHandle = new E2BSubprocessHandle(runtime(absentState), spec(), '/runtime/absent-state') + await expect(absentHandle.done).rejects.toThrow('start failed after external cleanup') }) it('bounds a readiness rejection with a still-live caller signal', async () => { @@ -678,8 +909,20 @@ describe('E2BSubprocessHandle', () => { invalidPid.handle.pid = 0 const invalid = new E2BSubprocessHandle(runtime(invalidPid), spec(), '/runtime/invalid-pid') await expect(invalid.done).rejects.toThrow(/invalid command pid 0/) + expect(invalidPid.handle.kills).toBe(1) + expect(invalidPid.removed).toContain('/runtime/invalid-pid/environment') await expect(invalid.waitForExit()).resolves.toBe(true) + const failedRollback = new FakeSandbox() + failedRollback.handle.pid = 0 + failedRollback.handle.killError = new Error('invalid handle kill failed') + const retained = new E2BSubprocessHandle(runtime(failedRollback), spec(), '/runtime/invalid-pid-retained') + await expect(retained.done).rejects.toThrow('invalid command pid rollback did not reach quiescence') + await expect(retained.waitForExit()).rejects.toThrow('invalid handle kill failed') + failedRollback.handle.killError = undefined + retained.terminate() + await expect(retained.waitForExit()).resolves.toBe(true) + const crashedFake = new FakeSandbox() const crashed = new E2BSubprocessHandle(runtime(crashedFake), spec(), '/runtime/crashed') await flush() @@ -692,6 +935,7 @@ describe('E2BSubprocessHandle', () => { const invalidGroup = new FakeSandbox() invalidGroup.processGroupId = 'not-a-pid\n' invalidGroup.delaysKill = true + invalidGroup.sdkKillStops = false invalidGroup.afterProbe = () => { invalidGroup.alive = false } const invalid = new E2BSubprocessHandle(runtime(invalidGroup), spec(), '/runtime/invalid-group') await expect(invalid.done).rejects.toThrow(/invalid process-group id/) @@ -791,7 +1035,7 @@ describe('E2BSubprocessHandle', () => { await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) }) - it('contains an already-gone group signal and observes non-command signal failures', async () => { + it('contains an already-gone group signal and escalates after a TERM transport failure', async () => { const gone = new FakeSandbox() gone.trapsTerm = true gone.signalError = commandError(1) @@ -805,9 +1049,35 @@ describe('E2BSubprocessHandle', () => { const failedHandle = new E2BSubprocessHandle(runtime(failed), spec(), '/runtime/failed-signal') await flush() failedHandle.terminate() + await expect(failedHandle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) + expect(failed.commandsSeen).toContain('kill -KILL -- -4242') + }) + + it('allows termination retry after both force transports fail', async () => { + const fake = new FakeSandbox() + fake.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed')) + fake.handle.killError = new Error('SDK kill failed') + const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/retry-signal') await flush() - failed.finish() - await expect(failedHandle.done).resolves.toEqual({ exitCode: 0, signal: null }) + + handle.terminate() + await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both') + fake.handle.killError = undefined + handle.terminate() + await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' }) + expect(fake.commandsSeen.filter(command => command.startsWith('kill -TERM '))).toHaveLength(2) + + const missingGroup = new FakeSandbox() + missingGroup.trapsTerm = true + missingGroup.signalErrors.push(undefined, commandError(1)) + missingGroup.handle.killError = new Error('SDK kill failed after group exit race') + const raced = new E2BSubprocessHandle(runtime(missingGroup), spec({ graceMs: 1 }), '/runtime/group-exit-race') + await flush() + raced.terminate() + await expect(raced.waitForExit()).rejects.toThrow('force termination failed through both') + missingGroup.handle.killError = undefined + raced.terminate() + await expect(raced.waitForExit()).resolves.toBe(true) }) }) @@ -833,6 +1103,23 @@ describe('E2BSubprocessService', () => { expect(fake.alive).toBe(false) }) + it('reports a failed termination transaction from disposal instead of waiting on done', async () => { + const fake = new FakeSandbox() + fake.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed')) + fake.handle.killError = new Error('SDK kill failed') + const { ctx, fiber } = await service(fake) + const handle = ctx.subprocess.spawn(spec({ graceMs: 1 })) + await flush() + + await expect(fiber.dispose()).resolves.toBeUndefined() + await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both') + + fake.handle.killError = undefined + handle.terminate() + await expect(handle.waitForExit()).resolves.toBe(true) + await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' }) + }) + it('releases naturally settled handles before later service disposal', async () => { const fake = new FakeSandbox() const { ctx, fiber } = await service(fake) diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index cefa432acd..bda01a5a61 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 { foreground = '456\n' groups = [123] createError: unknown + writeError: unknown sendError: unknown commandFailure: unknown sessionGroupsFailure: unknown @@ -116,6 +117,7 @@ class FakeTerminalSandbox { }, write: async (files: Array<{ path: string; data: string }>): Promise => { for (const file of files) this.writes.set(file.path, file.data) + if (this.writeError !== undefined) throw this.writeError return files.map(() => ({})) }, read: async (): Promise => { @@ -278,6 +280,20 @@ describe('E2B terminal allocation', () => { }) it('cleans malformed handles, bootstrap failures, and readiness failures', async () => { + const failedState = new FakeTerminalSandbox() + failedState.writeError = new Error('state write failed') + await expect(spawnE2BTerminal(runtime(failedState), spec(), '/runtime/state-write')) + .rejects.toThrow('state write failed') + expect(failedState.writes.get('/runtime/state-write/environment')).toContain('KEEP=visible\0') + expect(failedState.removed).toContain('/runtime/state-write') + expect(failedState.createOptions).toBeUndefined() + + const stateAlreadyGone = new FakeTerminalSandbox() + stateAlreadyGone.writeError = new Error('state write failed after external cleanup') + stateAlreadyGone.removeError = new FileNotFoundError('state already gone') + await expect(spawnE2BTerminal(runtime(stateAlreadyGone), spec(), '/runtime/state-gone')) + .rejects.toThrow('state write failed after external cleanup') + const invalidPid = new FakeTerminalSandbox() invalidPid.handle.pid = 0 await expect(spawnE2BTerminal(runtime(invalidPid), spec(), '/runtime/invalid-pid'))