From 450ec99325ebef5d21fdcae1a0ef62b98027dd68 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:37:38 +0800 Subject: [PATCH] fix(e2b): address the v7 review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - restore the parent's AGENTS.md body around this branch's e2b row (the replay had resurrected the pre-profile-CLI layout, deleting the api/, bundle/, settings/, and credentials/ groups); the fs/lsp row condensations stay for the word budget - aggregate the composite rollback failure, not the original error, when private state cleanup also fails — a surviving remote group is no longer hidden by a later cleanup error (triple-failure regression) - own the state directory before the makeDir RPC, matching the terminal transaction, so a cancellation racing a committed creation still enters cleanup - drop the hidden pollMs parameter defaults; the schemastery default is the one home, and tests pass the cadence explicitly - restore spawn/spawnTerminal graceMs validation to the seam's documented bound (the earlier removal cited subprocess-local as not validating; it does), with rejection regressions --- AGENTS.md | 19 +- docs/config-catalog.md | 2 +- docs/module-graph.md | 3 +- packages/e2b/subprocess-e2b/package.json | 2 + packages/e2b/subprocess-e2b/src/index.ts | 15 ++ packages/e2b/subprocess-e2b/src/process.ts | 8 +- packages/e2b/subprocess-e2b/src/terminal.ts | 2 +- .../subprocess-e2b/tests/subprocess.spec.ts | 190 +++++++++++------- .../e2b/subprocess-e2b/tests/terminal.spec.ts | 96 +++++---- packages/e2b/subprocess-e2b/tsconfig.json | 15 +- pnpm-lock.yaml | 3 + 11 files changed, 218 insertions(+), 137 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 87f545ee86..61812e586c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,10 +12,11 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md packages/ @deepseek-ai/dsh- workspaces at packages/// core/ product API spine: session, system-prompt, tools, agent, agent-loop + api/ Remote BFF assembly and TypeRT RPC gateway typert/ type graph generator, loader, and runtime registry llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin) e2b/ E2B POC: sandbox + FS/subprocess adapters - bash/ bash executor seam + local impl + model-facing bash tools + bash/ bash executor seam + local/pwsh impls + model-facing shell tools subprocess/ subprocess seam + local process-tree impl pty/ persistent PTY seam/backend/tools fs/ filesystem seam/backends/policy/tools @@ -25,6 +26,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// compact/ compaction seam + basic backend context/ request-context plugins subagent/ subagent seam + spawn/fork/ACP backends + delegation tool + bundle/ profile plugin bundles: installable patch layers for dsh --profile workflow/ workflow seam + worker-thread engine + workflow tool todo/ todo_write tool plan/ plan mode as logged per-agent collaboration state @@ -32,9 +34,11 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime hooks/ Claude Code/Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends + settings/ user-settings seam + file-backed provider + credentials/ credential-reference seam + env-over-.env provider acp/ automation-only Agent Client Protocol server - ui/ TUI/JSON-RPC bridges; boot, approval, interaction plugins - examples/ demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load + ui/ JSON-RPC bridge; boot, approval, interaction plugins + examples/ demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load support/ dev/test infrastructure util/ zero-dependency utilities python/ Python SDK and bundled runtime (see python/README.md) @@ -56,7 +60,7 @@ pnpm run clean # remove build outputs and safe residue from deleted pa pnpm run test # vitest unit tests pnpm run test:coverage # CI coverage gate: per-file 100% on packages/*/*/src pnpm run test:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY -pnpm run test:snapshot # keyless ACP/headless/TUI replay vs expected outputs; filter: -t +pnpm run test:snapshot # keyless ACP/headless replay vs expected outputs; filter: -t pnpm run test:snapshot:record # re-record expected outputs (needs key) pnpm run typecheck pnpm run lint @@ -67,7 +71,6 @@ pnpm run hygiene # knip + publint + workspace constraints + NodeNext cons pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as dead-link check) pnpm run demo:headless "task" # one-shot agent (needs DEEPSEEK_API_KEY) -pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) pnpm run demo:cordis # the agent modifies its own runtime (needs key) pnpm run demo:acp # ACP automation server (needs DEEPSEEK_API_KEY) ``` @@ -78,7 +81,7 @@ When required `gh`, `pnpm`, build, test, or generator commands fail because the ### Run relevant checks locally -Agents MUST run relevant tests and checks before pushing; select them with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run. +Run checks before pushes via [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md); report only commands run. After `gh stack sync`, validate immediately; do not merge before checks pass. - Match evidence to the surface: focused tests for behavior, snapshots for model or user output, `doc-sync` for docs, build/hygiene and built smokes for published paths, and real-API e2e for provider behavior. - Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change. @@ -91,7 +94,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. -- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only shapes) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. +- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only shapes) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)). - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. @@ -115,7 +118,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. -- **Use incremental merge commits.** Split independent changes. Pushed history may be rewritten before review; afterward prefer new commits. Fix the introducing PR before merging down-stack. If the base advances mid-merge, finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)). +- **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). - **Label PRs:** one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), each matching area; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) is extensible. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 58b319d662..c74358fd39 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1841,7 +1841,7 @@ export interface Config { } ``` -Source: [`packages/e2b/subprocess-e2b/src/index.ts:24`](../packages/e2b/subprocess-e2b/src/index.ts) +Source: [`packages/e2b/subprocess-e2b/src/index.ts:25`](../packages/e2b/subprocess-e2b/src/index.ts) ## `@deepseek-ai/dsh-system-prompt` diff --git a/docs/module-graph.md b/docs/module-graph.md index 91d584466b..2e71473379 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -340,6 +340,7 @@ flowchart TD pkg_subprocess_e2b --> pkg_e2b pkg_subprocess_e2b --> pkg_invariants pkg_subprocess_e2b --> pkg_subprocess + pkg_subprocess_e2b --> pkg_timeout pkg_frontend_static --> pkg_host_webserver pkg_frontend_static --> pkg_invariants pkg_helper --> pkg_brand @@ -1208,7 +1209,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | -| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | +| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index abf3d5973c..007b88f1c9 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-e2b": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -37,6 +38,7 @@ "@deepseek-ai/dsh-e2b": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/e2b/subprocess-e2b/src/index.ts b/packages/e2b/subprocess-e2b/src/index.ts index 38b1d32465..c6d2dbd4eb 100644 --- a/packages/e2b/subprocess-e2b/src/index.ts +++ b/packages/e2b/subprocess-e2b/src/index.ts @@ -9,6 +9,7 @@ import { posix } from 'node:path' import { Context } from 'cordis' import z from 'schemastery' import { SubprocessService } from '@deepseek-ai/dsh-subprocess' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SubprocessHandle, SubprocessSpawnSpec, @@ -35,6 +36,18 @@ interface TerminalSetup { controller: AbortController } +/** + * Enforce the seam's documented grace bound (positive, finite, one Node timer), + * matching subprocess-local's spawn-time check; an unbounded grace would make + * the remote force-escalation deadline unreachable. + * @param graceMs - The spec's cleanup grace in milliseconds. + */ +function requireRepresentableGrace(graceMs: number): void { + if (!Number.isFinite(graceMs) || graceMs <= 0 || graceMs > MAX_TIMER_DELAY_MS) { + throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } +} + /** E2B command manager registered as `ctx.subprocess`. */ export class E2BSubprocessService extends SubprocessService { static inject = ['e2b'] @@ -130,6 +143,7 @@ export class E2BSubprocessService extends SubprocessService { if (program === undefined || program.length === 0) { throw new Error('invalid argv: expected a non-empty program name at argv[0]') } + requireRepresentableGrace(spec.graceMs) if (spec.signal?.aborted === true) { throw new Error(`aborted before spawn: ${String(spec.signal.reason)}`) } @@ -153,6 +167,7 @@ export class E2BSubprocessService extends SubprocessService { if (program === undefined || program.length === 0) { throw new Error('subprocess-e2b: terminal argv must contain a program') } + requireRepresentableGrace(spec.graceMs) spec.signal?.throwIfAborted() const stateDir = posix.join(this.ctx.e2b.runtimeRoot, 'terminals', randomUUID()) const done = Promise.withResolvers() diff --git a/packages/e2b/subprocess-e2b/src/process.ts b/packages/e2b/subprocess-e2b/src/process.ts index feb21a70bc..c80a2f5bd9 100644 --- a/packages/e2b/subprocess-e2b/src/process.ts +++ b/packages/e2b/subprocess-e2b/src/process.ts @@ -194,7 +194,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { private readonly runtime: E2BSandboxService, private readonly spec: SubprocessSpawnSpec, readonly stateDir: string, - private readonly pollMs = 20, + private readonly pollMs: number, ) { this.paths = { pid: posix.join(stateDir, 'pid'), @@ -378,7 +378,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { await this.removeFailedState(sandbox) } catch (cleanupError: unknown) { failure = new AggregateError( - [error, cleanupError], + [failure, cleanupError], 'subprocess-e2b: command failed and private state cleanup failed', ) } @@ -398,8 +398,10 @@ export class E2BSubprocessHandle implements SubprocessHandle { const signal = this.terminationController.signal const ambient = await readRemoteEnvironment(sandbox, signal) this.controlEnvs = bootstrapEnvironment(ambient) - await sandbox.files.makeDir(this.stateDir, { signal }) + // Own the directory before the request: a cancellation racing a committed + // creation must still enter cleanup (removal tolerates an absent path). this.stateDirectoryCreated = true + await sandbox.files.makeDir(this.stateDir, { signal }) await sandbox.commands.run( `chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`, commandOpts(this.controlEnvs, signal), diff --git a/packages/e2b/subprocess-e2b/src/terminal.ts b/packages/e2b/subprocess-e2b/src/terminal.ts index fd10ecdd8e..c8f3bb57b7 100644 --- a/packages/e2b/subprocess-e2b/src/terminal.ts +++ b/packages/e2b/subprocess-e2b/src/terminal.ts @@ -459,7 +459,7 @@ export async function spawnE2BTerminal( runtime: E2BSandboxService, spec: SubprocessTerminalSpawnSpec, stateDir: string, - pollMs = 20, + pollMs: number, ): Promise { const sandbox = await runtime.getSandbox() spec.signal?.throwIfAborted() diff --git a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts index d40c69df89..153b144ccd 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -324,6 +324,16 @@ async function flush(): Promise { await new Promise(resolve => setTimeout(resolve, 0)) } +/** Construct the handle under test with the config default the service would pass. */ +function testHandle( + runtime: ConstructorParameters[0], + spec: ConstructorParameters[1], + stateDir: string, + pollMs = 20, +): E2BSubprocessHandle { + return new E2BSubprocessHandle(runtime, spec, stateDir, pollMs) +} + describe('E2BOutputReader', () => { it('decodes base64 across arbitrary callback boundaries and rejects malformed framing', () => { const decoder = new E2BBase64Decoder() @@ -379,7 +389,7 @@ describe('E2BSubprocessHandle', () => { const fake = new FakeSandbox() fake.processGroupId = '4343\n' fake.deferStart() - const handle = new E2BSubprocessHandle(runtime(fake), spec({ + const handle = testHandle(runtime(fake), spec({ argv: ['tool', 'argument with spaces'], stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 8, spill: { maxBytes: 32 } } }, env: { @@ -450,10 +460,23 @@ describe('E2BSubprocessHandle', () => { await expect(handle.waitForExit()).resolves.toBe(true) }) + it('rejects an unrepresentable graceMs before any remote work', () => { + const ctx = new Context() + const service = Object.create(E2BSubprocessService.prototype) as E2BSubprocessService + Reflect.set(service, 'disposing', false) + Reflect.set(service, 'ctx', ctx) + for (const graceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(() => service.spawn(spec({ graceMs }))).toThrow('graceMs must be a positive finite number') + void expect(service.spawnTerminal({ + argv: ['bash'], cwd: '/w', rows: 24, cols: 80, graceMs, + })).rejects.toThrow('graceMs must be a positive finite number') + } + }) + it('rejects malformed environment entries before command start', async () => { for (const env of [{ 'BAD=NAME': 'x' }, { BAD: 'x\0INJECTED=1' }]) { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec({ env }), '/runtime/invalid-environment') + const handle = testHandle(runtime(fake), spec({ env }), '/runtime/invalid-environment') await expect(handle.done).rejects.toThrow('environment entries') expect(fake.startOptions).toBeUndefined() expect(fake.removed).toContain('/runtime/invalid-environment') @@ -462,7 +485,7 @@ describe('E2BSubprocessHandle', () => { it('preserves UTF-8 bytes when the ASCII transport is split across callbacks', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec({ + const handle = testHandle(runtime(fake), spec({ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } }, }), '/runtime/split-utf8') await flush() @@ -478,14 +501,14 @@ describe('E2BSubprocessHandle', () => { it('rejects malformed output transport without confusing it with a consumer sink failure', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/malformed-output') + const handle = testHandle(runtime(fake), spec(), '/runtime/malformed-output') await flush() await fake.stdoutWire('%\n') fake.finish() await expect(handle.done).rejects.toThrow('invalid base64 output transport') const stderrFake = new FakeSandbox() - const stderrHandle = new E2BSubprocessHandle(runtime(stderrFake), spec(), '/runtime/malformed-stderr') + const stderrHandle = testHandle(runtime(stderrFake), spec(), '/runtime/malformed-stderr') await flush() await stderrFake.stderrWire('%\n') stderrFake.finish() @@ -494,7 +517,7 @@ describe('E2BSubprocessHandle', () => { it('rejects a naturally completed command whose encoder omits its completion frame', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/incomplete-output') + const handle = testHandle(runtime(fake), spec(), '/runtime/incomplete-output') await flush() fake.alive = false fake.handle.succeed(0) @@ -503,7 +526,7 @@ describe('E2BSubprocessHandle', () => { 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') + const handle = testHandle(runtime(fake), spec({ graceMs: 5 }), '/runtime/drain-bound') await flush() await fake.stdout('leader-output') fake.exitStatus = '0\n' @@ -529,7 +552,7 @@ describe('E2BSubprocessHandle', () => { return false }) as typeof process.stdout.write) try { - const handle = new E2BSubprocessHandle(runtime(fake), spec({ + const handle = testHandle(runtime(fake), spec({ graceMs: 5, stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 4 } }, }), '/runtime/inherit-backpressure') @@ -555,7 +578,7 @@ describe('E2BSubprocessHandle', () => { it('waits for lossless raw-pipe output after the direct status is published', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec({ + const handle = testHandle(runtime(fake), spec({ graceMs: 1, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } }, }), '/runtime/pipe-drain') @@ -580,7 +603,7 @@ describe('E2BSubprocessHandle', () => { 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') + const handle = testHandle(runtime(fake), spec({ graceMs: 100 }), '/runtime/drain-complete') await flush() fake.exitStatus = '0\n' fake.afterStatusRead = () => { @@ -598,7 +621,7 @@ describe('E2BSubprocessHandle', () => { fake.delaysKill = true fake.delaysKillCompletion = true fake.sdkKillStops = false - const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 5 }), '/runtime/drain-signal') + const handle = testHandle(runtime(fake), spec({ graceMs: 5 }), '/runtime/drain-signal') await flush() handle.terminate() @@ -614,7 +637,7 @@ describe('E2BSubprocessHandle', () => { it('preserves a published nonzero exit code when termination settles the SDK inside the drain grace', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 100 }), '/runtime/drain-signal-settled') + const handle = testHandle(runtime(fake), spec({ graceMs: 100 }), '/runtime/drain-signal-settled') await flush() fake.exitStatus = '7\n' fake.afterStatusRead = () => { @@ -628,7 +651,7 @@ describe('E2BSubprocessHandle', () => { it('rejects an invalid direct-command exit status', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/invalid-status') + const handle = testHandle(runtime(fake), spec(), '/runtime/invalid-status') await flush() fake.exitStatus = '999\n' await expect(handle.done).rejects.toThrow('invalid exit code') @@ -639,7 +662,7 @@ describe('E2BSubprocessHandle', () => { it('rolls back a published process group before rejecting a monitoring failure', async () => { const fake = new FakeSandbox() fake.statusError = new Error('status transport failed') - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/status-failure') + const handle = testHandle(runtime(fake), spec(), '/runtime/status-failure') await expect(handle.done).rejects.toThrow('status transport failed') expect(fake.commandsSeen).toContain('kill -TERM -- -4242') @@ -650,7 +673,7 @@ describe('E2BSubprocessHandle', () => { failed.statusError = new Error('status transport failed') failed.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed')) failed.handle.killError = new Error('SDK kill failed') - const retained = new E2BSubprocessHandle(runtime(failed), spec({ graceMs: 1 }), '/runtime/status-cleanup-failure') + const retained = testHandle(runtime(failed), spec({ graceMs: 1 }), '/runtime/status-cleanup-failure') await expect(retained.done).rejects.toThrow( 'command monitoring failed and process-group rollback did not reach quiescence', @@ -659,13 +682,30 @@ describe('E2BSubprocessHandle', () => { failed.handle.killError = undefined retained.terminate() await expect(retained.waitForExit()).resolves.toBe(true) + + // A state-cleanup failure on top preserves the rollback failure instead of + // re-aggregating only the original monitoring error. + const triple = new FakeSandbox() + triple.statusError = new Error('status transport failed') + triple.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed')) + triple.handle.killError = new Error('SDK kill failed') + triple.nextRemoveError = new Error('state cleanup failed') + const tripleHandle = testHandle(runtime(triple), spec({ graceMs: 1 }), '/runtime/triple-failure') + const failure = await tripleHandle.done.catch((error: unknown) => error as AggregateError) + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).message).toContain('private state cleanup failed') + const nested = (failure as AggregateError).errors[0] as AggregateError + expect(nested.message).toContain('rollback did not reach quiescence') + triple.handle.killError = undefined + tripleHandle.terminate() + await expect(tripleHandle.waitForExit()).resolves.toBe(true) }) it('surfaces deferred piped-stdin write and close failures as stream errors', async () => { const writeFake = new FakeSandbox() writeFake.deferStart() vi.spyOn(writeFake.handle, 'sendStdin').mockRejectedValueOnce('stdin rejected') - const writeHandle = new E2BSubprocessHandle(runtime(writeFake), spec({ + const writeHandle = testHandle(runtime(writeFake), spec({ stdio: { stdin: 'pipe', stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } }, }), '/runtime/stdin-write-error') const writeError = once(writeHandle.stdin!, 'error') @@ -677,7 +717,7 @@ describe('E2BSubprocessHandle', () => { const closeFake = new FakeSandbox() vi.spyOn(closeFake.handle, 'closeStdin').mockRejectedValueOnce(new Error('close rejected')) - const closeHandle = new E2BSubprocessHandle(runtime(closeFake), spec({ + const closeHandle = testHandle(runtime(closeFake), spec({ stdio: { stdin: 'pipe', stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } }, }), '/runtime/stdin-close-error') await flush() @@ -690,7 +730,7 @@ describe('E2BSubprocessHandle', () => { it('collects bounded tails, retains valid spills, and maps natural nonzero exits', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec({ + const handle = testHandle(runtime(fake), spec({ stdio: { stdin: { data: 'batch' }, stdout: { maxBytes: 4, spill: { maxBytes: 16 } }, @@ -716,7 +756,7 @@ describe('E2BSubprocessHandle', () => { it('removes a spill once the complete stream exceeds its cap', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec({ + const handle = testHandle(runtime(fake), spec({ stdio: { stdin: 'ignore', stdout: { maxBytes: 2, spill: { maxBytes: 3 } }, stderr: 'inherit' }, }), '/runtime/oversize') await flush() @@ -736,7 +776,7 @@ describe('E2BSubprocessHandle', () => { it('contains remote spill-removal failures and routes empty inherited output', async () => { const fake = new FakeSandbox() fake.nextRemoveError = new Error('already removed') - const handle = new E2BSubprocessHandle(runtime(fake), spec({ + const handle = testHandle(runtime(fake), spec({ stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 4, spill: { maxBytes: 8 } } }, }), '/runtime/remove-error') await flush() @@ -749,7 +789,7 @@ describe('E2BSubprocessHandle', () => { it('terminates a process group with TERM and reports the signal outcome', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/term') + const handle = testHandle(runtime(fake), spec(), '/runtime/term') await flush() handle.terminate() handle.terminate() @@ -767,7 +807,7 @@ describe('E2BSubprocessHandle', () => { it('makes termination a permanent no-op after natural quiescence is observed', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/natural-quiescence') + const handle = testHandle(runtime(fake), spec(), '/runtime/natural-quiescence') await flush() fake.finish() await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) @@ -784,7 +824,7 @@ describe('E2BSubprocessHandle', () => { it('treats a zombie-only process group as quiescent', async () => { const fake = new FakeSandbox() fake.zombieOnly = true - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/zombie-quiescence') + const handle = testHandle(runtime(fake), spec(), '/runtime/zombie-quiescence') await flush() await expect(handle.waitForExit()).resolves.toBe(true) @@ -800,7 +840,7 @@ describe('E2BSubprocessHandle', () => { fake.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed')) fake.handle.killError = new Error('SDK kill failed') fake.deferSignals() - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/quiescent-race') + const handle = testHandle(runtime(fake), spec(), '/runtime/quiescent-race') await flush() handle.terminate() @@ -823,7 +863,7 @@ describe('E2BSubprocessHandle', () => { const fake = new FakeSandbox() fake.trapsTerm = true fake.handle.killError = new Error('already gone') - const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/kill') + const handle = testHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/kill') await flush() handle.terminate() await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) @@ -838,7 +878,7 @@ describe('E2BSubprocessHandle', () => { fake.delaysKill = true fake.delaysKillCompletion = true fake.sdkKillStops = false - const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/termination-fence') + const handle = testHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/termination-fence') await flush() handle.terminate() await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) }) @@ -856,7 +896,7 @@ describe('E2BSubprocessHandle', () => { it('honors termination requested before asynchronous startup finishes', async () => { const fake = new FakeSandbox() fake.deferStart() - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/deferred-kill') + const handle = testHandle(runtime(fake), spec(), '/runtime/deferred-kill') handle.terminate() fake.releaseStart() await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' }) @@ -879,7 +919,7 @@ describe('E2BSubprocessHandle', () => { signal?.addEventListener('abort', rejectAbort, { once: true }) }) } - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/stalled-preparation') + const handle = testHandle(runtime(fake), spec(), '/runtime/stalled-preparation') await vi.waitFor(() => { expect(preparationSignal).toBeDefined() }) handle.terminate() @@ -893,7 +933,7 @@ describe('E2BSubprocessHandle', () => { const fake = new FakeSandbox() fake.deferProcessGroupRead() fake.signalErrors.push(commandError(1), commandError(1)) - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/pre-publication-kill') + const handle = testHandle(runtime(fake), spec(), '/runtime/pre-publication-kill') await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() }) handle.terminate() @@ -913,7 +953,7 @@ describe('E2BSubprocessHandle', () => { fake.delaysKillCompletion = true fake.sdkKillStops = false fake.handle.killResult = false - const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/provisional-sdk-false') + const handle = testHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/provisional-sdk-false') await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() }) handle.terminate() @@ -936,7 +976,7 @@ describe('E2BSubprocessHandle', () => { calls += 1 return calls === 1 ? fake.sandbox : await reconnect.promise }) - const handle = new E2BSubprocessHandle(delayedRuntime, spec(), '/runtime/pre-publication-observer') + const handle = testHandle(delayedRuntime, spec(), '/runtime/pre-publication-observer') await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() }) handle.terminate() @@ -957,7 +997,7 @@ describe('E2BSubprocessHandle', () => { 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') + const handle = testHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/pre-publication-group-kill') await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() }) handle.terminate() @@ -971,7 +1011,7 @@ describe('E2BSubprocessHandle', () => { 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') + const handle = testHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/pre-publication-failure') await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() }) handle.terminate() @@ -987,7 +1027,7 @@ describe('E2BSubprocessHandle', () => { 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( + const absentHandle = testHandle( runtime(absentGroup), spec({ graceMs: 1 }), '/runtime/pre-publication-absent-group', @@ -1005,7 +1045,7 @@ describe('E2BSubprocessHandle', () => { optimisticSdk.deferProcessGroupRead() optimisticSdk.signalErrors.push(commandError(1), commandError(1)) optimisticSdk.sdkKillStops = false - const optimisticHandle = new E2BSubprocessHandle( + const optimisticHandle = testHandle( runtime(optimisticSdk), spec({ graceMs: 1 }), '/runtime/pre-publication-optimistic-sdk', @@ -1021,14 +1061,14 @@ describe('E2BSubprocessHandle', () => { 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') + const handle = testHandle(runtime(fake), spec({ signal: AbortSignal.abort('stop') }), '/runtime/pre-aborted') await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' }) }) it('reacts to a signal that aborts after the remote command has started', async () => { const fake = new FakeSandbox() const controller = new AbortController() - const handle = new E2BSubprocessHandle(runtime(fake), spec({ signal: controller.signal }), '/runtime/live-abort') + const handle = testHandle(runtime(fake), spec({ signal: controller.signal }), '/runtime/live-abort') await flush() controller.abort('stop') await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' }) @@ -1036,7 +1076,7 @@ describe('E2BSubprocessHandle', () => { it('can terminate a surviving process group after the command leader settles', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/surviving-group') + const handle = testHandle(runtime(fake), spec(), '/runtime/surviving-group') await flush() await fake.completeOutput() fake.handle.succeed(0) @@ -1054,7 +1094,7 @@ describe('E2BSubprocessHandle', () => { it('bounds waitForExit while startup or a live group is pending', async () => { const fake = new FakeSandbox() fake.deferStart() - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/wait') + const handle = testHandle(runtime(fake), spec(), '/runtime/wait') const beforeStart = new AbortController() const pending = handle.waitForExit(beforeStart.signal) beforeStart.abort() @@ -1071,7 +1111,7 @@ describe('E2BSubprocessHandle', () => { const terminatingFake = new FakeSandbox() terminatingFake.deferStart() - const terminating = new E2BSubprocessHandle(runtime(terminatingFake), spec(), '/runtime/wait-termination-start') + const terminating = testHandle(runtime(terminatingFake), spec(), '/runtime/wait-termination-start') terminating.terminate() const beforeHandle = new AbortController() const handlePending = terminating.waitForExit(beforeHandle.signal) @@ -1083,7 +1123,7 @@ describe('E2BSubprocessHandle', () => { it('bounds both sides of the liveness-poll abort race', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/poll-abort') + const handle = testHandle(runtime(fake), spec(), '/runtime/poll-abort') await flush() const beforeTick = new AbortController() @@ -1114,7 +1154,7 @@ describe('E2BSubprocessHandle', () => { it('observes a live group across one successful bounded poll', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/poll-success') + const handle = testHandle(runtime(fake), spec(), '/runtime/poll-success') await flush() setTimeout(() => { fake.finish() }, 1) await expect(handle.waitForExit(new AbortController().signal)).resolves.toBe(true) @@ -1124,7 +1164,7 @@ describe('E2BSubprocessHandle', () => { it('treats startup failure as no live tree and contains readiness rejection', async () => { const fake = new FakeSandbox() fake.backgroundError = new Error('start failed') - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/fail') + const handle = testHandle(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') @@ -1132,7 +1172,7 @@ describe('E2BSubprocessHandle', () => { await expect(handle.waitForExit()).resolves.toBe(true) handle.terminate() - const unavailableHandle = new E2BSubprocessHandle( + const unavailableHandle = testHandle( runtime(new FakeSandbox(), async () => { throw new Error('sandbox unavailable') }), spec(), '/runtime/unavailable-start', @@ -1142,14 +1182,14 @@ describe('E2BSubprocessHandle', () => { const envFailure = new FakeSandbox() envFailure.envError = new Error('ambient lookup failed') - const envHandle = new E2BSubprocessHandle(runtime(envFailure), spec(), '/runtime/env-failure') + const envHandle = testHandle(runtime(envFailure), spec(), '/runtime/env-failure') await expect(envHandle.done).rejects.toThrow('ambient lookup failed') expect(envFailure.removed).toEqual([]) const expectEnvironmentFailure = async (name: string, wire: string, message: string): Promise => { const fake = new FakeSandbox() fake.environmentWire = wire - const failed = new E2BSubprocessHandle(runtime(fake), spec(), `/runtime/${name}`) + const failed = testHandle(runtime(fake), spec(), `/runtime/${name}`) await expect(failed.done).rejects.toThrow(message) } const encodedEnvironment = Buffer.from('PATH=/bin\0').toString('base64') @@ -1180,13 +1220,13 @@ describe('E2BSubprocessHandle', () => { 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') + const cleanupHandle = testHandle(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') + const absentHandle = testHandle(runtime(absentState), spec(), '/runtime/absent-state') await expect(absentHandle.done).rejects.toThrow('start failed after external cleanup') }) @@ -1194,7 +1234,7 @@ describe('E2BSubprocessHandle', () => { const fake = new FakeSandbox() fake.deferStart() fake.backgroundError = new Error('start failed') - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/fail-with-signal') + const handle = testHandle(runtime(fake), spec(), '/runtime/fail-with-signal') const waiting = handle.waitForExit(new AbortController().signal) fake.releaseStart() await expect(handle.done).rejects.toThrow('start failed') @@ -1209,7 +1249,7 @@ describe('E2BSubprocessHandle', () => { if (calls === 1) return fake.sandbox throw new Error('connection unavailable') }) - const handle = new E2BSubprocessHandle(unavailable, spec(), '/runtime/unavailable') + const handle = testHandle(unavailable, spec(), '/runtime/unavailable') await flush() await expect(handle.waitForExit()).rejects.toThrow('connection unavailable') fake.finish() @@ -1224,7 +1264,7 @@ describe('E2BSubprocessHandle', () => { calls += 1 return calls === 1 ? fake.sandbox : await reconnect.promise }) - const handle = new E2BSubprocessHandle(unavailable, spec(), '/runtime/reconnect-abort') + const handle = testHandle(unavailable, spec(), '/runtime/reconnect-abort') await flush() const controller = new AbortController() const waiting = handle.waitForExit(controller.signal) @@ -1238,7 +1278,7 @@ describe('E2BSubprocessHandle', () => { it('returns false when a liveness request itself is aborted and surfaces other probe failures', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/probe') + const handle = testHandle(runtime(fake), spec(), '/runtime/probe') await flush() const controller = new AbortController() controller.abort() @@ -1251,7 +1291,7 @@ describe('E2BSubprocessHandle', () => { it('treats a timeout-killed sandbox as quiescent during liveness probing', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/expired-sandbox') + const handle = testHandle(runtime(fake), spec(), '/runtime/expired-sandbox') await flush() fake.finish() await handle.done @@ -1263,7 +1303,7 @@ describe('E2BSubprocessHandle', () => { it('treats a missing sandbox handle as quiescent during liveness acquisition', async () => { const fake = new FakeSandbox() let calls = 0 - const handle = new E2BSubprocessHandle(runtime(fake, async () => { + const handle = testHandle(runtime(fake, async () => { calls += 1 if (calls === 1) return fake.sandbox throw new SandboxNotFoundError('sandbox expired') @@ -1280,7 +1320,7 @@ describe('E2BSubprocessHandle', () => { it('treats sandbox loss during termination as quiescent', async () => { const fake = new FakeSandbox() let calls = 0 - const handle = new E2BSubprocessHandle(runtime(fake, async () => { + const handle = testHandle(runtime(fake, async () => { calls += 1 if (calls === 1) return fake.sandbox throw new SandboxNotFoundError('sandbox expired') @@ -1298,7 +1338,7 @@ describe('E2BSubprocessHandle', () => { it('makes batch stdin close failures best-effort', async () => { const fake = new FakeSandbox() vi.spyOn(fake.handle, 'sendStdin').mockRejectedValueOnce(new Error('closed')) - const handle = new E2BSubprocessHandle(runtime(fake), spec({ + const handle = testHandle(runtime(fake), spec({ stdio: { stdin: { data: 'ignored' }, stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } }, }), '/runtime/stdin-closed') await flush() @@ -1309,7 +1349,7 @@ describe('E2BSubprocessHandle', () => { it('rejects malformed SDK process ids and non-command settlement failures', async () => { const invalidPid = new FakeSandbox() invalidPid.handle.pid = 0 - const invalid = new E2BSubprocessHandle(runtime(invalidPid), spec(), '/runtime/invalid-pid') + const invalid = testHandle(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') @@ -1318,7 +1358,7 @@ describe('E2BSubprocessHandle', () => { 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') + const retained = testHandle(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 @@ -1326,7 +1366,7 @@ describe('E2BSubprocessHandle', () => { await expect(retained.waitForExit()).resolves.toBe(true) const crashedFake = new FakeSandbox() - const crashed = new E2BSubprocessHandle(runtime(crashedFake), spec(), '/runtime/crashed') + const crashed = testHandle(runtime(crashedFake), spec(), '/runtime/crashed') await flush() crashedFake.alive = false crashedFake.handle.crash(new Error('command transport failed')) @@ -1339,7 +1379,7 @@ describe('E2BSubprocessHandle', () => { invalidGroup.delaysKill = true invalidGroup.sdkKillStops = false invalidGroup.afterProbe = () => { invalidGroup.alive = false } - const invalid = new E2BSubprocessHandle(runtime(invalidGroup), spec(), '/runtime/invalid-group') + const invalid = testHandle(runtime(invalidGroup), spec(), '/runtime/invalid-group') await expect(invalid.done).rejects.toThrow(/invalid process-group id/) expect(invalidGroup.handle.kills).toBe(1) expect(invalidGroup.commandsSeen).toContain('kill -KILL -- -4242') @@ -1351,14 +1391,14 @@ describe('E2BSubprocessHandle', () => { unsafeGroup.delaysKill = true unsafeGroup.sdkKillStops = false unsafeGroup.afterProbe = () => { unsafeGroup.alive = false } - const unsafe = new E2BSubprocessHandle(runtime(unsafeGroup), spec(), '/runtime/unsafe-group') + const unsafe = testHandle(runtime(unsafeGroup), spec(), '/runtime/unsafe-group') await expect(unsafe.done).rejects.toThrow(/unsafe published process-group id 1/) expect(unsafeGroup.commandsSeen).not.toContain('kill -KILL -- -1') await expect(unsafe.waitForExit()).resolves.toBe(true) const absentGroup = new FakeSandbox() absentGroup.processGroupId = '' - const absent = new E2BSubprocessHandle(runtime(absentGroup), spec(), '/runtime/absent-group') + const absent = testHandle(runtime(absentGroup), spec(), '/runtime/absent-group') await flush() absentGroup.finish() await expect(absent.done).rejects.toThrow(/exited before publishing/) @@ -1372,7 +1412,7 @@ describe('E2BSubprocessHandle', () => { fake.processGroupId = 'not-a-pid\n' fake.signalError = new Error('rollback signal failed') fake.handle.killError = new Error('SDK kill failed') - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/failed-rollback') + const handle = testHandle(runtime(fake), spec(), '/runtime/failed-rollback') let failure: unknown try { @@ -1403,7 +1443,7 @@ describe('E2BSubprocessHandle', () => { naturallyGone.processGroupId = 'not-a-pid\n' naturallyGone.signalError = new Error('rollback signal failed') naturallyGone.handle.killError = new Error('SDK kill failed') - const observed = new E2BSubprocessHandle(runtime(naturallyGone), spec(), '/runtime/failed-rollback-observed') + const observed = testHandle(runtime(naturallyGone), spec(), '/runtime/failed-rollback-observed') await expect(observed.done).rejects.toThrow('process-group publication failed') naturallyGone.alive = false await expect(observed.waitForExit()).resolves.toBe(true) @@ -1412,7 +1452,7 @@ describe('E2BSubprocessHandle', () => { it('waits for delayed process-group publication', async () => { const fake = new FakeSandbox() fake.processGroupReads.push('', '4242\n') - const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/delayed-group') + const handle = testHandle(runtime(fake), spec(), '/runtime/delayed-group') await vi.waitFor(() => { expect(handle.pid).toBe(4242) }) fake.finish() await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) @@ -1420,7 +1460,7 @@ describe('E2BSubprocessHandle', () => { it('handles output backpressure and contains a stderr sink failure', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec({ + const handle = testHandle(runtime(fake), spec({ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }, }), '/runtime/backpressure') await flush() @@ -1445,7 +1485,7 @@ describe('E2BSubprocessHandle', () => { it('settles output backpressure when the consumer closes the pipe', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec({ + const handle = testHandle(runtime(fake), spec({ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } }, }), '/runtime/backpressure-close') await flush() @@ -1462,7 +1502,7 @@ describe('E2BSubprocessHandle', () => { it('breaks output backpressure when termination owns the command', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec({ + const handle = testHandle(runtime(fake), spec({ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } }, }), '/runtime/backpressure-termination') await flush() @@ -1484,7 +1524,7 @@ describe('E2BSubprocessHandle', () => { it('settles backpressure when a synchronous pipe write starts termination', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec({ + const handle = testHandle(runtime(fake), spec({ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } }, }), '/runtime/backpressure-synchronous-termination') await flush() @@ -1500,7 +1540,7 @@ describe('E2BSubprocessHandle', () => { it('contains a pipe callback failure instead of rejecting command settlement', async () => { const fake = new FakeSandbox() - const handle = new E2BSubprocessHandle(runtime(fake), spec({ + const handle = testHandle(runtime(fake), spec({ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } }, }), '/runtime/pipe-error') await flush() @@ -1516,14 +1556,14 @@ describe('E2BSubprocessHandle', () => { const gone = new FakeSandbox() gone.trapsTerm = true gone.signalError = commandError(1) - const goneHandle = new E2BSubprocessHandle(runtime(gone), spec({ graceMs: 1 }), '/runtime/gone-signal') + const goneHandle = testHandle(runtime(gone), spec({ graceMs: 1 }), '/runtime/gone-signal') await flush() goneHandle.terminate() await expect(goneHandle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) const failed = new FakeSandbox() failed.signalError = new Error('signal transport failed') - const failedHandle = new E2BSubprocessHandle(runtime(failed), spec(), '/runtime/failed-signal') + const failedHandle = testHandle(runtime(failed), spec(), '/runtime/failed-signal') await flush() failedHandle.terminate() await expect(failedHandle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) @@ -1534,7 +1574,7 @@ describe('E2BSubprocessHandle', () => { 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') + const handle = testHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/retry-signal') await flush() handle.terminate() @@ -1548,7 +1588,7 @@ describe('E2BSubprocessHandle', () => { 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') + const raced = testHandle(runtime(missingGroup), spec({ graceMs: 1 }), '/runtime/group-exit-race') await flush() raced.terminate() await expect(raced.waitForExit()).rejects.toThrow('remained live after force termination') @@ -1562,7 +1602,7 @@ describe('E2BSubprocessHandle', () => { fake.trapsTerm = true fake.sdkKillStops = false fake.signalErrors.push(undefined, new Error('KILL transport failed')) - const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/optimistic-sdk-kill') + const handle = testHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/optimistic-sdk-kill') await flush() handle.terminate() diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index 826732f057..5472b2cfa7 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -259,10 +259,20 @@ function holdRequestUntilAbort(started: PromiseWithResolvers) { } } +/** Spawn the terminal under test with the config default the service would pass. */ +function testSpawn( + runtime: Parameters[0], + spec: Parameters[1], + stateDir: string, + pollMs = 20, +): ReturnType { + return spawnE2BTerminal(runtime, spec, stateDir, pollMs) +} + describe('E2B terminal allocation', () => { it('hides bootstrap-shell bytes and preserves requested-shell bytes across the output boundary', async () => { const fake = new FakeTerminalSandbox() - const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/terminal-one') + const terminal = await testSpawn(runtime(fake), spec(), '/runtime/terminal-one') let output = '' terminal.output.on('data', (chunk) => { output += String(chunk) }) await new Promise(resolve => setTimeout(resolve, 0)) @@ -314,7 +324,7 @@ describe('E2B terminal allocation', () => { it('inherits only safe ambient values and limits the allocation signal to setup', async () => { const fake = new FakeTerminalSandbox() const controller = new AbortController() - const terminal = await spawnE2BTerminal( + const terminal = await testSpawn( runtime(fake), spec({ env: undefined, signal: controller.signal }), '/runtime/abort-live', @@ -335,7 +345,7 @@ describe('E2B terminal allocation', () => { const fake = new FakeTerminalSandbox() fake.deferCreate() const controller = new AbortController() - const spawning = spawnE2BTerminal( + const spawning = testSpawn( runtime(fake), spec({ signal: controller.signal }), '/runtime/allocation-cancel', @@ -352,23 +362,23 @@ describe('E2B terminal allocation', () => { it('rejects malformed environment and argv values before PTY allocation', async () => { const invalidName = new FakeTerminalSandbox() - await expect(spawnE2BTerminal(runtime(invalidName), spec({ env: { 'BAD=NAME': 'x' } }), '/runtime/name')) + await expect(testSpawn(runtime(invalidName), spec({ env: { 'BAD=NAME': 'x' } }), '/runtime/name')) .rejects.toThrow('environment entries') expect(invalidName.createOptions).toBeUndefined() const invalidValue = new FakeTerminalSandbox() - await expect(spawnE2BTerminal(runtime(invalidValue), spec({ env: { BAD: 'x\0y' } }), '/runtime/value')) + await expect(testSpawn(runtime(invalidValue), spec({ env: { BAD: 'x\0y' } }), '/runtime/value')) .rejects.toThrow('environment entries') const invalidArg = new FakeTerminalSandbox() - await expect(spawnE2BTerminal(runtime(invalidArg), spec({ argv: ['/bin/bash', 'x\0y'] }), '/runtime/argv')) + await expect(testSpawn(runtime(invalidArg), spec({ argv: ['/bin/bash', 'x\0y'] }), '/runtime/argv')) .rejects.toThrow('argv must not contain NUL') }) 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')) + await expect(testSpawn(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') @@ -377,19 +387,19 @@ describe('E2B terminal allocation', () => { 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')) + await expect(testSpawn(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')) + await expect(testSpawn(runtime(invalidPid), spec(), '/runtime/invalid-pid')) .rejects.toThrow('invalid terminal pid 0') expect(invalidPid.handle.sdkKills).toBe(1) expect(invalidPid.removed).toContain('/runtime/invalid-pid') const failedInput = new FakeTerminalSandbox() failedInput.sendError = new Error('bootstrap failed') - await expect(spawnE2BTerminal(runtime(failedInput), spec(), '/runtime/input')) + await expect(testSpawn(runtime(failedInput), spec(), '/runtime/input')) .rejects.toThrow('bootstrap failed') expect(failedInput.commands).toContain('kill -TERM -- -123') expect(failedInput.groups).toEqual([]) @@ -397,7 +407,7 @@ describe('E2B terminal allocation', () => { const invalidSession = new FakeTerminalSandbox() invalidSession.sessionId = 'not-a-session\n' invalidSession.clearOnTerm = false - await expect(spawnE2BTerminal(runtime(invalidSession), spec(), '/runtime/session')) + await expect(testSpawn(runtime(invalidSession), spec(), '/runtime/session')) .rejects.toThrow('cannot resolve process session') expect(invalidSession.commands).toContain('kill -TERM -- -123') expect(invalidSession.commands).toContain('kill -KILL -- -123') @@ -410,7 +420,7 @@ describe('E2B terminal allocation', () => { const termFailed = new FakeTerminalSandbox() termFailed.sendError = new Error('bootstrap failed') termFailed.termFailure = new Error('TERM transport failed') - await expect(spawnE2BTerminal(runtime(termFailed), spec(), '/runtime/term-failed')) + await expect(testSpawn(runtime(termFailed), spec(), '/runtime/term-failed')) .rejects.toThrow('bootstrap failed') expect(termFailed.commands).toContain('kill -KILL -- -123') expect(termFailed.handle.sdkKills).toBe(1) @@ -421,7 +431,7 @@ describe('E2B terminal allocation', () => { uninspectable.handle.sdkKillError = new Error('PTY kill failed') let uninspectableFailure: unknown try { - await spawnE2BTerminal(runtime(uninspectable), spec(), '/runtime/uninspectable') + await testSpawn(runtime(uninspectable), spec(), '/runtime/uninspectable') } catch (error: unknown) { uninspectableFailure = error } @@ -432,21 +442,21 @@ describe('E2B terminal allocation', () => { survivingGroups.sendError = new Error('bootstrap failed') survivingGroups.clearOnTerm = false survivingGroups.clearOnKill = false - await expect(spawnE2BTerminal(runtime(survivingGroups), spec({ graceMs: 1 }), '/runtime/surviving-groups')) + await expect(testSpawn(runtime(survivingGroups), spec({ graceMs: 1 }), '/runtime/surviving-groups')) .rejects.toThrow('bootstrap failed') const survivingPid = new FakeTerminalSandbox() survivingPid.sendError = new Error('bootstrap failed') survivingPid.groups = [] survivingPid.handle.settleOnSdkKill = false - await expect(spawnE2BTerminal(runtime(survivingPid), spec({ graceMs: 1 }), '/runtime/surviving-pid')) + await expect(testSpawn(runtime(survivingPid), spec({ graceMs: 1 }), '/runtime/surviving-pid')) .rejects.toThrow('bootstrap failed') const waitFailed = new FakeTerminalSandbox() waitFailed.handle.waitError = new Error('wait failed') waitFailed.handle.settleOnSdkKill = false waitFailed.handle.sdkKillError = new Error('kill failed') - await expect(spawnE2BTerminal(runtime(waitFailed), spec(), '/runtime/wait-failed')) + await expect(testSpawn(runtime(waitFailed), spec(), '/runtime/wait-failed')) .rejects.toThrow('wait failed') expect(waitFailed.handle.sdkKills).toBe(1) @@ -454,7 +464,7 @@ describe('E2B terminal allocation', () => { cleanupFailed.handle.pid = 0 cleanupFailed.handle.sdkKillError = new Error('kill transport failed') cleanupFailed.removeError = new Error('remove transport failed') - await expect(spawnE2BTerminal(runtime(cleanupFailed), spec(), '/runtime/cleanup-failed')) + await expect(testSpawn(runtime(cleanupFailed), spec(), '/runtime/cleanup-failed')) .rejects.toThrow('invalid terminal pid 0') const expiredDuringRollback = new FakeTerminalSandbox() @@ -463,7 +473,7 @@ describe('E2B terminal allocation', () => { expiredDuringRollback.handle.settleOnSdkKill = false expiredDuringRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired') expiredDuringRollback.removeError = new SandboxNotFoundError('sandbox expired') - await expect(spawnE2BTerminal(runtime(expiredDuringRollback), spec(), '/runtime/expired-rollback')) + await expect(testSpawn(runtime(expiredDuringRollback), spec(), '/runtime/expired-rollback')) .rejects.toThrow('bootstrap failed before timeout') expect(expiredDuringRollback.handle.sdkKills).toBe(1) @@ -471,30 +481,30 @@ describe('E2B terminal allocation', () => { expiredBeforeSdkRollback.handle.waitError = new Error('wait failed after timeout') expiredBeforeSdkRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired') expiredBeforeSdkRollback.handle.settleOnSdkKill = false - await expect(spawnE2BTerminal(runtime(expiredBeforeSdkRollback), spec(), '/runtime/expired-sdk-rollback')) + await expect(testSpawn(runtime(expiredBeforeSdkRollback), spec(), '/runtime/expired-sdk-rollback')) .rejects.toThrow('wait failed after timeout') const missingDuringDisconnect = new FakeTerminalSandbox() missingDuringDisconnect.sendError = new Error('bootstrap failed before disconnect') missingDuringDisconnect.handle.disconnectError = new SandboxNotFoundError('sandbox expired') - await expect(spawnE2BTerminal(runtime(missingDuringDisconnect), spec(), '/runtime/missing-disconnect')) + await expect(testSpawn(runtime(missingDuringDisconnect), spec(), '/runtime/missing-disconnect')) .rejects.toThrow('bootstrap failed before disconnect') const failedDisconnect = new FakeTerminalSandbox() failedDisconnect.sendError = new Error('bootstrap failed with disconnect failure') failedDisconnect.handle.disconnectError = new Error('disconnect transport failed') - await expect(spawnE2BTerminal(runtime(failedDisconnect), spec(), '/runtime/failed-disconnect')) + await expect(testSpawn(runtime(failedDisconnect), spec(), '/runtime/failed-disconnect')) .rejects.toThrow('bootstrap failed with disconnect failure') }) it('propagates setup cancellation and provider failures', async () => { const aborted = new FakeTerminalSandbox() - await expect(spawnE2BTerminal(runtime(aborted), spec({ signal: AbortSignal.abort(new Error('stop')) }), '/runtime/abort')) + await expect(testSpawn(runtime(aborted), spec({ signal: AbortSignal.abort(new Error('stop')) }), '/runtime/abort')) .rejects.toThrow('stop') const createFailed = new FakeTerminalSandbox() createFailed.createError = new Error('create failed') - await expect(spawnE2BTerminal(runtime(createFailed), spec(), '/runtime/create')) + await expect(testSpawn(runtime(createFailed), spec(), '/runtime/create')) .rejects.toThrow('create failed') }) @@ -502,7 +512,7 @@ describe('E2B terminal allocation', () => { it('bounds a missing bootstrap-output boundary by process exit or cancellation', async () => { const exited = new FakeTerminalSandbox() exited.emitOutputMarker = false - const exiting = spawnE2BTerminal(runtime(exited), spec(), '/runtime/missing-output-boundary') + const exiting = testSpawn(runtime(exited), spec(), '/runtime/missing-output-boundary') await vi.waitFor(() => { expect(exited.inputs).toHaveLength(1) }) exited.handle.succeed(0) await expect(exiting).rejects.toThrow('terminal exited before publishing its output boundary') @@ -510,7 +520,7 @@ describe('E2B terminal allocation', () => { const cancelled = new FakeTerminalSandbox() cancelled.emitOutputMarker = false const controller = new AbortController() - const cancelling = spawnE2BTerminal( + const cancelling = testSpawn( runtime(cancelled), spec({ signal: controller.signal }), '/runtime/cancel-output-boundary', @@ -525,7 +535,7 @@ describe('E2B terminal allocation', () => { describe('E2B terminal lifecycle', () => { it('aborts and joins in-flight terminal operations before cleanup', async () => { const fake = new FakeTerminalSandbox() - const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/in-flight-operations') + const terminal = await testSpawn(runtime(fake), spec(), '/runtime/in-flight-operations') const writeStarted = Promise.withResolvers() const inspectStarted = Promise.withResolvers() const signalStarted = Promise.withResolvers() @@ -563,7 +573,7 @@ describe('E2B terminal lifecycle', () => { it('maps ordinary exits, closes output, and reports an absent foreground after exit', async () => { const fake = new FakeTerminalSandbox() fake.groups = [] - const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/natural') + const terminal = await testSpawn(runtime(fake), spec(), '/runtime/natural') terminal.output.resume() const ended = once(terminal.output, 'end') fake.handle.succeed(7) @@ -583,7 +593,7 @@ describe('E2B terminal lifecycle', () => { ] as const)('classifies an unrequested command exit %i', async (exitCode, expected) => { const fake = new FakeTerminalSandbox() fake.groups = [] - const terminal = await spawnE2BTerminal(runtime(fake), spec(), `/runtime/exit-${exitCode}`) + const terminal = await testSpawn(runtime(fake), spec(), `/runtime/exit-${exitCode}`) fake.handle.fail(exitCode) await expect(terminal.done).resolves.toEqual(expected) await terminal.terminate() @@ -593,7 +603,7 @@ describe('E2B terminal lifecycle', () => { const fake = new FakeTerminalSandbox() fake.groups = [] fake.zombieGroups = [123] - const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/zombie-session') + const terminal = await testSpawn(runtime(fake), spec(), '/runtime/zombie-session') fake.handle.succeed(0) await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null }) @@ -605,7 +615,7 @@ describe('E2B terminal lifecycle', () => { it('treats a timeout-killed sandbox as quiescent during terminal cleanup', async () => { const fake = new FakeTerminalSandbox() - const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/expired-sandbox') + const terminal = await testSpawn(runtime(fake), spec(), '/runtime/expired-sandbox') fake.sessionGroupsFailure = new SandboxNotFoundError('sandbox expired') fake.handle.succeed(0) @@ -618,7 +628,7 @@ describe('E2B terminal lifecycle', () => { fake.groups = [] fake.handle.settleOnSdkKill = false fake.handle.sdkKillError = new SandboxNotFoundError('sandbox expired') - const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/expired-pty-kill') + const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/expired-pty-kill') await terminal.terminate() expect(fake.handle.sdkKills).toBe(1) @@ -629,7 +639,7 @@ describe('E2B terminal lifecycle', () => { fake.groups = [] fake.handle.settleOnSdkKill = false fake.handle.sdkKillError = new Error('PTY kill transport failed') - const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/failed-pty-kill') + const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/failed-pty-kill') await expect(terminal.terminate()).rejects.toThrow('PTY kill transport failed') fake.handle.sdkKillError = undefined @@ -643,7 +653,7 @@ describe('E2B terminal lifecycle', () => { ['propagates another failure', new Error('disconnect failed'), false], ] as const)('%s while disconnecting a settled terminal', async (_label, failure, accepted) => { const fake = new FakeTerminalSandbox() - const terminal = await spawnE2BTerminal(runtime(fake), spec(), `/runtime/disconnect-${accepted}`) + const terminal = await testSpawn(runtime(fake), spec(), `/runtime/disconnect-${accepted}`) fake.handle.disconnectError = failure fake.groups = [] fake.handle.succeed(0) @@ -655,7 +665,7 @@ describe('E2B terminal lifecycle', () => { it('rejects killing the terminal shell and propagates live foreground failures', async () => { const fake = new FakeTerminalSandbox() fake.foreground = '123\n' - const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/signal') + const terminal = await testSpawn(runtime(fake), spec(), '/runtime/signal') await expect(terminal.signalForeground('SIGKILL')).rejects.toThrow('refusing to SIGKILL') fake.foreground = 'invalid\n' await expect(terminal.inspectForeground()).rejects.toThrow('cannot resolve foreground') @@ -671,7 +681,7 @@ describe('E2B terminal lifecycle', () => { const fake = new FakeTerminalSandbox() fake.groups = [123, 456] fake.clearOnTerm = false - const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 0 }), '/runtime/escalate') + const terminal = await testSpawn(runtime(fake), spec({ graceMs: 0 }), '/runtime/escalate') const terminating = terminal.terminate() await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) await terminating @@ -682,7 +692,7 @@ describe('E2B terminal lifecycle', () => { it('surfaces cleanup failures and allows a later retry', async () => { const fake = new FakeTerminalSandbox() fake.groups = [1] - const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/retry') + const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/retry') await expect(terminal.terminate()).rejects.toThrow('unsafe process group 1') fake.groups = [] @@ -694,7 +704,7 @@ describe('E2B terminal lifecycle', () => { it('propagates a process-group signalling transport failure before retry', async () => { const fake = new FakeTerminalSandbox() fake.termFailure = new Error('signal transport failed') - const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/signal-failure') + const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/signal-failure') await expect(terminal.terminate()).rejects.toThrow('signal transport failed') fake.groups = [] @@ -704,7 +714,7 @@ describe('E2B terminal lifecycle', () => { const alreadyExited = new FakeTerminalSandbox() alreadyExited.termFailure = commandError(1) - const tolerant = await spawnE2BTerminal(runtime(alreadyExited), spec({ graceMs: 1 }), '/runtime/group-exited') + const tolerant = await testSpawn(runtime(alreadyExited), spec({ graceMs: 1 }), '/runtime/group-exited') const tolerantTermination = tolerant.terminate() await expect(tolerant.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) await tolerantTermination @@ -714,7 +724,7 @@ describe('E2B terminal lifecycle', () => { const fake = new FakeTerminalSandbox() fake.groups = [] fake.removeError = new Error('private state already gone') - const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/reject-during-cleanup') + const terminal = await testSpawn(runtime(fake), spec(), '/runtime/reject-during-cleanup') terminal.output.on('error', () => {}) const cleanup = terminal.terminate() await Promise.resolve() @@ -727,7 +737,7 @@ describe('E2B terminal lifecycle', () => { const fake = new FakeTerminalSandbox() fake.groups = [] fake.handle.settleOnSdkKill = false - const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/reject-after-kill') + const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/reject-after-kill') terminal.output.on('error', () => {}) const cleanup = terminal.terminate() while (fake.handle.sdkKills === 0) await new Promise(resolve => setTimeout(resolve, 0)) @@ -741,20 +751,20 @@ describe('E2B terminal lifecycle', () => { const survivor = new FakeTerminalSandbox() survivor.clearOnTerm = false survivor.clearOnKill = false - const terminal = await spawnE2BTerminal(runtime(survivor), spec({ graceMs: 1 }), '/runtime/survivor') + const terminal = await testSpawn(runtime(survivor), spec({ graceMs: 1 }), '/runtime/survivor') await expect(terminal.terminate()).rejects.toThrow('surviving process groups: 123') const livePid = new FakeTerminalSandbox() livePid.groups = [] livePid.handle.settleOnSdkKill = false - const live = await spawnE2BTerminal(runtime(livePid), spec({ graceMs: 1 }), '/runtime/live-pid') + const live = await testSpawn(runtime(livePid), spec({ graceMs: 1 }), '/runtime/live-pid') await expect(live.terminate()).rejects.toThrow('surviving pid: 123') livePid.handle.succeed(0) await live.done const crashed = new FakeTerminalSandbox() crashed.groups = [] - const failed = await spawnE2BTerminal(runtime(crashed), spec(), '/runtime/crashed') + const failed = await testSpawn(runtime(crashed), spec(), '/runtime/crashed') const outputError = once(failed.output, 'error') crashed.handle.crash('transport gone') await expect(failed.done).rejects.toEqual('transport gone') diff --git a/packages/e2b/subprocess-e2b/tsconfig.json b/packages/e2b/subprocess-e2b/tsconfig.json index fda7cfc667..1bae0ef012 100644 --- a/packages/e2b/subprocess-e2b/tsconfig.json +++ b/packages/e2b/subprocess-e2b/tsconfig.json @@ -4,22 +4,27 @@ "rootDir": "src", "outDir": "lib/types" }, - "include": ["src"], + "include": [ + "src" + ], "references": [ - { - "path": "../../../vendor/cosmokit" - }, { "path": "../../../vendor/cordis" }, { - "path": "../e2b" + "path": "../../../vendor/cosmokit" }, { "path": "../../subprocess/subprocess" }, { "path": "../../support/invariants" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../e2b" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2e7d0ec1c..fab00ba9cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3190,6 +3190,9 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis