diff --git a/AGENTS.md b/AGENTS.md index 1d2a6d977c..87f545ee86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,12 +14,12 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// core/ product API spine: session, system-prompt, tools, agent, agent-loop typert/ type graph generator, loader, and runtime registry llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin) - e2b/ E2B POC: sandbox owner + FS/subprocess adapters + e2b/ E2B POC: sandbox + FS/subprocess adapters bash/ bash executor seam + local impl + model-facing bash tools subprocess/ subprocess seam + local process-tree impl pty/ persistent PTY seam/backend/tools - fs/ filesystem seam + local impl + policy gate + read/write/edit tools - lsp/ language-server seam + local stdio provider + model-facing lsp tool + fs/ filesystem seam/backends/policy/tools + lsp/ language-server seam/local backend/tool skill/ skill provider registry + local impl + catalog/loader tool web/ web seam + search/fetch providers + model-facing web tools compact/ compaction seam + basic backend diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index db66ca5799..8af0e6ca30 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -591,7 +591,7 @@ Creates one lazily consumable E2B SDK handle and deletes the sandbox at timeout async getSandbox(): Promise ``` -Source: [`packages/e2b/e2b/src/index.ts:76`](../../packages/e2b/e2b/src/index.ts) +Source: [`packages/e2b/e2b/src/index.ts:74`](../../packages/e2b/e2b/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/e2b/e2b/README.i18n.yaml b/packages/e2b/e2b/README.i18n.yaml index c338fb0263..3587067d31 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: 6045b313a91edafc25361eba3f3c4dacf56b4eb0 -README.zh.md: dc157d9b5167488530c7d63b59aed793ab6aa4c7 +README.md: 7ade7c3d6522d8fa6d54d7011766238451b17c9a +README.zh.md: b683f33d2211106cf422e780b2b37904b0c640ad diff --git a/packages/e2b/e2b/README.md b/packages/e2b/e2b/README.md index 6045b313a9..7ade7c3d65 100644 --- a/packages/e2b/e2b/README.md +++ b/packages/e2b/e2b/README.md @@ -26,7 +26,7 @@ Shared lifecycle owner for one E2B sandbox. The filesystem and subprocess adapte Construction starts one sandbox creation. 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`. Each adapter-internal E2B command shell receives a fresh randomized root-level `HOME`, so the SDK's fixed login shell does not resolve profile files from the mutable user home before the control command. -Disposal first prevents new handle acquisition, then awaits setup and deletes the sandbox. A `SandboxNotFoundError` means expiry or another owner already deleted it and is accepted as quiescence. Initial directory setup failure also deletes the newly created sandbox; if that rollback fails, disposal retries it before releasing ownership. Provider plugins must load after this owner and dispose before it. +Disposal first prevents new handle acquisition, then awaits setup and deletes the sandbox. A `SandboxNotFoundError` means expiry or another owner already deleted it and is accepted as quiescence. Initial directory setup failure makes one deletion attempt; the configured E2B timeout bounds a second failure. Provider plugins must load after this owner and dispose before it. ## Model Experience diff --git a/packages/e2b/e2b/README.zh.md b/packages/e2b/e2b/README.zh.md index dc157d9b51..b683f33d22 100644 --- a/packages/e2b/e2b/README.zh.md +++ b/packages/e2b/e2b/README.zh.md @@ -26,7 +26,7 @@ 构造阶段会启动一次沙箱创建。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。每个适配器内部的 E2B 命令 shell 都会获得一个位于根目录下、全新随机生成的 `HOME`,因此 SDK 固定使用的登录 shell 不会在控制命令之前解析可变用户主目录中的配置文件。 -资源释放会先阻止继续获取新句柄,再等待初始化完成,然后删除沙箱。`SandboxNotFoundError` 表示沙箱已因超时或被另一个所有者删除,因此可视为完全停稳。初始目录设置失败时也会删除新建沙箱;如果该回滚失败,资源释放会在解除所有权前重试。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。 +资源释放会先阻止继续获取新句柄,再等待初始化完成,然后删除沙箱。`SandboxNotFoundError` 表示沙箱已因超时或被另一个所有者删除,因此可视为完全停稳。初始目录设置失败时会尝试删除一次;若该尝试也失败,则由已配置的 E2B 超时约束沙箱的存活时间。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。 ## 模型体验 diff --git a/packages/e2b/e2b/src/index.ts b/packages/e2b/e2b/src/index.ts index d6797b9147..906941d421 100644 --- a/packages/e2b/e2b/src/index.ts +++ b/packages/e2b/e2b/src/index.ts @@ -85,7 +85,6 @@ 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) { @@ -108,21 +107,18 @@ export class E2BSandboxService extends Service { ctx.effect(() => async () => { this.disposed = true - let sandbox = this.failedSetupSandbox - if (sandbox === undefined) { - try { - sandbox = await this.ready - } catch { - sandbox = this.failedSetupSandbox - } + let sandbox: Sandbox + try { + sandbox = await this.ready + } catch (_sandboxSetupFailure) { + // open() either acquired no sandbox or already made the POC's one rollback attempt. + return } - if (sandbox === undefined) return try { await sandbox.kill() } catch (error: unknown) { if (!(error instanceof SandboxNotFoundError)) throw error } - this.failedSetupSandbox = undefined }, 'e2b sandbox teardown') } @@ -174,10 +170,9 @@ export class E2BSandboxService extends Service { } catch (error: unknown) { try { await sandbox.kill() - } catch (_cleanupFailure) { - // Preserve the setup failure as the public error while retaining the - // created handle for the service disposer to retry this rollback. - this.failedSetupSandbox = sandbox + } catch (_sandboxSetupRollbackFailure) { + // TODO(e2b-setup-rollback): Add retry state only if a real double failure + // outlives E2B's configured sandbox timeout. } throw error } diff --git a/packages/e2b/e2b/tests/e2b.spec.ts b/packages/e2b/e2b/tests/e2b.spec.ts index e3a6c09039..b108bc68b0 100644 --- a/packages/e2b/e2b/tests/e2b.spec.ts +++ b/packages/e2b/e2b/tests/e2b.spec.ts @@ -180,7 +180,7 @@ describe('E2BSandboxService', () => { await fiber.dispose() }) - it('preserves the setup failure even when cleanup also fails', async () => { + it('preserves the setup failure after its one rollback attempt fails', async () => { const fixture = fakeSandbox() fixture.run.mockRejectedValueOnce(new Error('chmod failed')) fixture.kill.mockRejectedValueOnce(new Error('cleanup failed')) @@ -191,23 +191,7 @@ describe('E2BSandboxService', () => { 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) + expect(fixture.kill).toHaveBeenCalledOnce() }) it.each([ diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index 96fb24e21b..381ada5198 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: 4e47566d55cf9400459993185f03482761c92096 -README.zh.md: 21ef231af700f72c2c5ec1fc651297ebdd17d49b +README.md: 3ddfe5bc47309ba56d3f8af7720fcbe71ec8a302 +README.zh.md: c4db7bf8bb8a0f4b2f147dd22868978be5d1fcf4 diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index 4e47566d55..3ddfe5bc47 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -11,7 +11,7 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr - **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. Force cleanup succeeds only after a bounded probe finds the group empty; otherwise `waitForExit()` exposes a retryable failure, while proven quiescence makes later termination a no-op. Publication and monitoring failures apply the same cleanup transaction 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** — one trusted control-shell probe resolves the sandbox user's login home from its passwd entry and transports the sandbox environment as base64 ASCII for one strict UTF-8 decode; the wrapper then removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names and restores every valid `spec.env` entry as an explicit caller opt-in. Empty names, `=`, and NUL framing violations reject before launch. Subsequent E2B command and PTY login shells receive a fresh randomized root-level `HOME` plus empty overrides for every scrubbed ambient name before user profiles can run; the requested argv receives the serialized environment afterward without changing the sandbox user's umask. Host ambient variables never enter the sandbox implicitly. Private environment files are removed after consumption, and failed command or terminal setup removes its private state before rejecting. - **Stdio projection** — the remote wrapper branches raw bytes into optional bounded spill files, frames each live chunk as newline-delimited base64 ASCII, and the host incrementally restores bytes across arbitrary SDK callback boundaries. Pipe mode writes those bytes to host Node streams; inherit mode writes them to the harness process streams; collect mode retains a bounded host tail with offset reads. The wrapper publishes the direct command status before waiting for inherited writers. For collect or inherit output, the adapter disconnects an incomplete SDK stream after `graceMs`, withholds its partial spill, and returns that status while retaining the remote group for `waitForExit()` and termination. Natural raw-pipe completion instead awaits lossless transport and preserves backpressure; explicit termination destroys the host pipes and releases blocked output before remote cleanup. Batch and streaming stdin use the SDK handle. -- **Terminal sessions** — `spawnTerminal()` uses E2B's byte PTY API, installs the exact argv and scrubbed environment through private mode-`0600` files, reports the foreground process group, sends real signals, and tears down every live group in the remote terminal session through one retryable awaited `terminate()`; termination rejects new handle operations, aborts and joins in-flight writes, inspections, and signals, and treats zombie-only groups as quiescent. A private random output boundary discards the E2B bootstrap shell's prompt and echoed runner command while preserving every requested-process byte, including its first prompt. PTY allocation is awaited through handle publication before cancellation is observed, so owned rollback can clean the published handle. Setup and teardown own the private state transaction, abort pending setup during service disposal, fence publication, and retain an unproven setup cleanup for disposal retry. Prompt detection, scrollback, readiness, and owner policy remain in `dsh-pty-local`. +- **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 through one retryable awaited `terminate()`; termination rejects new handle operations, aborts and joins in-flight writes, inspections, and signals, and treats zombie-only groups as quiescent. A private random output boundary discards the E2B bootstrap shell's prompt and echoed runner command while preserving every requested-process byte, including its first prompt. PTY allocation is awaited through handle publication before cancellation is observed, so owned rollback can clean the published handle. Setup and teardown own the private state transaction, abort pending setup during service disposal, and fence publication; sandbox disposal or timeout bounds a setup rollback that also fails. Prompt detection, scrollback, readiness, and owner policy remain in `dsh-pty-local`. - **Sandbox disappearance** — `SandboxNotFoundError` during process or terminal liveness, termination, rollback, or disconnect proves the remote execution world cannot retain work, so cleanup treats it as quiescent; unrelated failures remain observable. The default E2B base image supplies the runtime and Bash/GNU utilities this adapter invokes: `node`, `bash`, `setsid`, `ps`, `awk`, `tr`, `env`, `base64`, `chmod`, `tee`, `head`, `rm`, `kill`, `id`, and `getent`. diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index 21ef231af7..c4db7bf8bb 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -11,7 +11,7 @@ - **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv,并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退;TERM 信号发送或探测失败也会强制触发该升级。进程表探测会把仅含僵尸或已死亡条目的进程组视为完全停稳。强制清理只有在有界探测发现进程组为空后才算成功;否则 `waitForExit()` 会公开可重试的失败,而已证明的完全停稳会让后续终止操作不再执行任何动作。发布失败与监控失败都会在拒绝前执行同一清理事务。服务 dispose(资源释放)会拒绝新的启动请求、终止并等待每个保留进程组退出,再等待 SDK 结算和私有清理完成,之后沙箱所有者才会释放。 - **环境边界**:一次受信任的控制 shell 探测会从 passwd 条目解析沙箱用户的登录主目录,以 base64 ASCII 传输沙箱环境,再进行一次严格 UTF-8 解码;随后包装层移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),并把每个有效的 `spec.env` 条目恢复为调用方显式选择。空名称、`=` 和违反 NUL 分帧规则的条目会在启动前被拒绝。在用户 profile 脚本运行前,此后的 E2B 命令 shell 与 PTY 登录 shell 会获得位于根目录下、全新随机生成的 `HOME`,并为每个被清理的环境变量名设置空值覆盖;之后,请求的 argv 会在不改变沙箱用户 umask 的前提下接收序列化环境。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。 - **stdio 投影**:远程包装层先把原始字节分流到可选的有界 spill 文件,再把每个实时分片编码为换行分隔的 base64 ASCII 帧;宿主会跨任意 SDK 回调边界增量恢复字节。pipe 模式把这些字节写入宿主 Node 流;inherit 模式把字节写入 harness 进程流;collect 模式保留有界的宿主尾部,并支持基于偏移量读取。包装层会在等待继承管道的写入方之前发布直接命令状态。对于 collect 或 inherit 输出,超过 `graceMs` 后,适配器会断开未完成的 SDK 流,不公开其中不完整的 spill,并返回该状态,同时保留远程进程组供 `waitForExit()` 和终止操作使用。原始 pipe 自然完成时,会等待无损传输完成并保留背压;显式终止则会销毁宿主 pipe,并在远程清理前释放受阻的输出写入。批量 stdin 和流式 stdin 都使用 SDK 句柄。 -- **终端会话**:`spawnTerminal()` 使用 E2B 的字节 PTY API,以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并通过一项可重试且须等待的 `terminate()` 清理远程终端会话中仍存活的每个进程组;终止会拒绝新的句柄操作,中止并等待在途写入、检查和信号操作结算,并把仅含僵尸进程的进程组视为已经完全停稳。私有随机输出边界会丢弃 E2B 引导 shell 的提示符和回显的 runner 命令,同时保留请求进程的每个字节,包括其第一个提示符。PTY 分配会一直等待到句柄发布后才观察取消,以便由承担清理责任的回滚清理已发布句柄。setup 与 teardown 负责私有状态事务,在服务 dispose 期间中止待处理的 setup、阻止发布,并保留未证明已完成的 setup 清理事务,供 dispose 重试。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。 +- **终端会话**:`spawnTerminal()` 使用 E2B 的字节 PTY API,以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并通过一项可重试且须等待的 `terminate()` 清理远程终端会话中仍存活的每个进程组;终止会拒绝新的句柄操作,中止并等待在途写入、检查和信号操作结算,并把仅含僵尸进程的进程组视为已经完全停稳。私有随机输出边界会丢弃 E2B 引导 shell 的提示符和回显的 runner 命令,同时保留请求进程的每个字节,包括其第一个提示符。PTY 分配会一直等待到句柄发布后才观察取消,以便由承担清理责任的回滚清理已发布句柄。setup 与 teardown 负责私有状态事务,在服务 dispose 期间中止待处理的 setup 并阻止发布;若 setup 回滚也失败,则由沙箱 dispose 或超时约束其存活时间。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。 - **沙箱消失**:在进程或终端的存活探测、终止、回滚或断开连接期间出现 `SandboxNotFoundError`,证明远程执行环境无法保留工作,因此清理会将其视为完全停稳;其他故障仍可观察。 E2B 默认基础镜像提供该适配器调用的运行时和 Bash/GNU 工具:`node`、`bash`、`setsid`、`ps`、`awk`、`tr`、`env`、`base64`、`chmod`、`tee`、`head`、`rm`、`kill`、`id` 和 `getent`。 diff --git a/packages/e2b/subprocess-e2b/src/index.ts b/packages/e2b/subprocess-e2b/src/index.ts index 63cafcbfe0..976880ab01 100644 --- a/packages/e2b/subprocess-e2b/src/index.ts +++ b/packages/e2b/subprocess-e2b/src/index.ts @@ -29,7 +29,6 @@ export class E2BSubprocessService extends SubprocessService { private readonly live = new Set() private readonly terminals = new Set() private readonly terminalSetups = new Map, AbortController>() - private readonly failedTerminalSetupCleanups = new Set<() => Promise>() private disposing = false /** @inheritdoc */ @@ -51,7 +50,6 @@ export class E2BSubprocessService extends SubprocessService { await Promise.all([...this.terminalSetups.keys()]) const handles = [...this.live] const terminals = [...this.terminals] - const failedTerminalSetupCleanups = [...this.failedTerminalSetupCleanups] const pending: Promise[] = [] for (const handle of handles) { handle.terminate() @@ -63,9 +61,6 @@ export class E2BSubprocessService extends SubprocessService { for (const terminal of terminals) { pending.push(terminal.terminate().then(() => { this.terminals.delete(terminal) })) } - for (const cleanup of failedTerminalSetupCleanups) { - pending.push(cleanup().then(() => { this.failedTerminalSetupCleanups.delete(cleanup) })) - } const outcomes = await Promise.allSettled(pending) for (const outcome of outcomes) { if (outcome.status === 'rejected') throw outcome.reason @@ -155,7 +150,6 @@ export class E2BSubprocessService extends SubprocessService { this.ctx.e2b, { ...spec, signal: setupSignal }, stateDir, - (cleanup) => { this.failedTerminalSetupCleanups.add(cleanup) }, ) this.terminals.add(terminal) // oxlint-disable-next-line typescript/no-unnecessary-condition -- Remote allocation yields to disposal. diff --git a/packages/e2b/subprocess-e2b/src/process.ts b/packages/e2b/subprocess-e2b/src/process.ts index fe964a7395..c0aceda334 100644 --- a/packages/e2b/subprocess-e2b/src/process.ts +++ b/packages/e2b/subprocess-e2b/src/process.ts @@ -636,35 +636,18 @@ export class E2BSubprocessHandle implements SubprocessHandle { } private async forceKillGroup(sandbox: Sandbox, handle: CommandHandle, processGroupId: number): Promise { - let groupFailure: unknown try { - if (!await this.signalGroup(sandbox, processGroupId, 'KILL')) { - groupFailure = new Error('process-group KILL did not report delivery') - } - } catch (error: unknown) { - groupFailure = error + await this.signalGroup(sandbox, processGroupId, 'KILL') + } catch (_processGroupKillFailure) { + // SDK kill and the final liveness probe remain independent cleanup paths. } - let handleFailure: unknown try { - if (!await handle.kill()) handleFailure = new Error('E2B SDK kill did not report command termination') - } catch (error: unknown) { - handleFailure = error + await handle.kill() + } catch (_sdkKillFailure) { + // The final liveness probe, not either transport's self-report, proves cleanup. } - let proofFailure: unknown - try { - if (await this.waitForGroupExit(sandbox, processGroupId)) return - proofFailure = new Error(`remote process group ${processGroupId} remained live after force termination`) - } catch (error: unknown) { - proofFailure = error - } - throw new AggregateError( - [ - ...(groupFailure === undefined ? [] : [groupFailure]), - ...(handleFailure === undefined ? [] : [handleFailure]), - proofFailure, - ], - 'subprocess-e2b: force termination failed through both process-group and SDK transports', - ) + if (await this.waitForGroupExit(sandbox, processGroupId)) return + throw new Error(`subprocess-e2b: remote process group ${processGroupId} remained live after force termination`) } private async waitForGroupExit(sandbox: Sandbox, processGroupId: number): Promise { diff --git a/packages/e2b/subprocess-e2b/src/terminal.ts b/packages/e2b/subprocess-e2b/src/terminal.ts index 3876191759..3108a4a60a 100644 --- a/packages/e2b/subprocess-e2b/src/terminal.ts +++ b/packages/e2b/subprocess-e2b/src/terminal.ts @@ -485,14 +485,12 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { * @param runtime - Shared E2B sandbox owner. * @param spec - Fully specified terminal-process request. * @param stateDir - Private remote directory for one startup transaction. - * @param retainFailedCleanup - Optional owner for retrying a cleanup transaction that could not prove quiescence. * @returns The live subprocess terminal handle. */ export async function spawnE2BTerminal( runtime: E2BSandboxService, spec: SubprocessTerminalSpawnSpec, stateDir: string, - retainFailedCleanup?: (cleanup: () => Promise) => void, ): Promise { const sandbox = await runtime.getSandbox() spec.signal?.throwIfAborted() @@ -562,7 +560,7 @@ export async function spawnE2BTerminal( output.destroy() let terminalQuiescent = handle === undefined let stateRemoved = !stateDirectoryCreated - const retryCleanup = async (): Promise => { + const cleanup = async (): Promise => { const failures: Error[] = [] if (!terminalQuiescent && handle !== undefined) { try { @@ -588,9 +586,10 @@ export async function spawnE2BTerminal( } } try { - await retryCleanup() + await cleanup() } catch (cleanupError: unknown) { - retainFailedCleanup?.(retryCleanup) + // TODO(e2b-terminal-setup-rollback): Retain retry state only if a real + // double failure must be recovered before sandbox disposal or timeout. throw new AggregateError([asError(error), asError(cleanupError)], 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 f7adf8183c..471716ef1a 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -808,7 +808,7 @@ describe('E2BSubprocessHandle', () => { await flush() handle.terminate() await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) }) - await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both') + await expect(handle.waitForExit()).rejects.toThrow('remained live after force termination') expect(fake.alive).toBe(true) fake.delaysKill = false @@ -884,7 +884,7 @@ describe('E2BSubprocessHandle', () => { handle.terminate() await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) }) - await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both') + await expect(handle.waitForExit()).rejects.toThrow('remained live after force termination') fake.alive = false handle.terminate() await expect(handle.waitForExit()).resolves.toBe(true) @@ -941,7 +941,7 @@ describe('E2BSubprocessHandle', () => { await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() }) handle.terminate() - await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both') + await expect(handle.waitForExit()).rejects.toThrow('remained live after force termination') fake.handle.killError = undefined handle.terminate() @@ -960,7 +960,7 @@ describe('E2BSubprocessHandle', () => { ) await vi.waitFor(() => { expect(absentGroup.startOptions).toBeDefined() }) absentHandle.terminate() - await expect(absentHandle.waitForExit()).rejects.toThrow('force termination failed through both') + await expect(absentHandle.waitForExit()).rejects.toThrow('remained live after force termination') absentGroup.handle.killError = undefined absentHandle.terminate() await expect(absentHandle.waitForExit()).resolves.toBe(true) @@ -978,7 +978,7 @@ describe('E2BSubprocessHandle', () => { ) await vi.waitFor(() => { expect(optimisticSdk.startOptions).toBeDefined() }) optimisticHandle.terminate() - await expect(optimisticHandle.waitForExit()).rejects.toThrow('force termination failed through both') + await expect(optimisticHandle.waitForExit()).rejects.toThrow('remained live after force termination') optimisticHandle.terminate() await expect(optimisticHandle.waitForExit()).resolves.toBe(true) optimisticSdk.releaseProcessGroupRead() @@ -1322,7 +1322,7 @@ describe('E2BSubprocessHandle', () => { await expect(absent.waitForExit()).resolves.toBe(true) }) - it('preserves publication and rollback failures when cleanup cannot be verified', async () => { + it('preserves publication failure and reports cleanup that cannot be verified', async () => { const fake = new FakeSandbox() fake.processGroupId = 'not-a-pid\n' fake.signalError = new Error('rollback signal failed') @@ -1341,11 +1341,10 @@ describe('E2BSubprocessHandle', () => { const failures = Array.from(failure.errors as Iterable) expect(failures).toHaveLength(2) expect(failures[0]).toBeInstanceOf(Error) - expect(failures[1]).toBeInstanceOf(AggregateError) - if (!(failures[0] instanceof Error) || !(failures[1] instanceof AggregateError)) throw new Error('expected nested errors') + expect(failures[1]).toBeInstanceOf(Error) + if (!(failures[0] instanceof Error) || !(failures[1] instanceof Error)) throw new Error('expected nested errors') expect(failures[0].message).toContain('invalid process-group id') - expect(failures[1].message).toBe('subprocess-e2b: force termination failed through both process-group and SDK transports') - expect(Array.from(failures[1].errors as Iterable)).toContainEqual(new Error('rollback signal failed')) + expect(failures[1].message).toContain('remained live after force termination') expect(fake.handle.kills).toBe(1) const bounded = new AbortController() const waiting = handle.waitForExit(bounded.signal) @@ -1494,7 +1493,7 @@ describe('E2BSubprocessHandle', () => { await flush() handle.terminate() - await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both') + await expect(handle.waitForExit()).rejects.toThrow('remained live after force termination') fake.handle.killError = undefined handle.terminate() await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' }) @@ -1507,7 +1506,7 @@ describe('E2BSubprocessHandle', () => { 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') + await expect(raced.waitForExit()).rejects.toThrow('remained live after force termination') missingGroup.handle.killError = undefined raced.terminate() await expect(raced.waitForExit()).resolves.toBe(true) @@ -1522,7 +1521,7 @@ describe('E2BSubprocessHandle', () => { await flush() handle.terminate() - await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both') + await expect(handle.waitForExit()).rejects.toThrow('remained live after force termination') expect(fake.alive).toBe(true) fake.sdkKillStops = true @@ -1581,7 +1580,7 @@ describe('E2BSubprocessService', () => { await flush() await expect(fiber.dispose()).resolves.toBeUndefined() - await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both') + await expect(handle.waitForExit()).rejects.toThrow('remained live after force termination') fake.handle.killError = undefined handle.terminate() diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index 08d49bb87b..067910a10b 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -871,23 +871,6 @@ describe('E2B subprocess terminal service', () => { expect(fake.createOptions).toBeUndefined() }) - it('retains failed terminal setup cleanup for disposal retry', async () => { - const fake = new FakeTerminalSandbox() - fake.sendError = new Error('bootstrap failed') - fake.clearOnTerm = false - fake.clearOnKill = false - const { ctx, fiber } = await service(fake) - - await expect(ctx.subprocess.spawnTerminal(spec({ graceMs: 1 }))).rejects.toThrow('bootstrap failed') - expect(fake.groups).toEqual([123]) - expect(fake.handle.disconnects).toBe(0) - - fake.clearOnKill = true - await fiber.dispose() - expect(fake.groups).toEqual([]) - expect(fake.handle.disconnects).toBe(1) - }) - it('releases naturally settled terminals and validates terminal requests', async () => { const { ctx, fiber, fake } = await service() for (const request of [