fix(e2b): address the v7 review round

- 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
This commit is contained in:
Tianyi Cui
2026-08-08 22:37:38 +08:00
parent 10fc9405c7
commit 450ec99325
11 changed files with 218 additions and 137 deletions

View File

@@ -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 vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md
packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
core/ product API spine: session, system-prompt, tools, agent, agent-loop 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 typert/ type graph generator, loader, and runtime registry
llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin) llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin)
e2b/ E2B POC: sandbox + FS/subprocess adapters 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 subprocess/ subprocess seam + local process-tree impl
pty/ persistent PTY seam/backend/tools pty/ persistent PTY seam/backend/tools
fs/ filesystem seam/backends/policy/tools fs/ filesystem seam/backends/policy/tools
@@ -25,6 +26,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
compact/ compaction seam + basic backend compact/ compaction seam + basic backend
context/ request-context plugins context/ request-context plugins
subagent/ subagent seam + spawn/fork/ACP backends + delegation tool 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 workflow/ workflow seam + worker-thread engine + workflow tool
todo/ todo_write tool todo/ todo_write tool
plan/ plan mode as logged per-agent collaboration state plan/ plan mode as logged per-agent collaboration state
@@ -32,9 +34,11 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime
hooks/ Claude Code/Codex hook bridges + shared wire-protocol library hooks/ Claude Code/Codex hook bridges + shared wire-protocol library
session-persistence/ persistence seam + JSONL/SQLite backends 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 acp/ automation-only Agent Client Protocol server
ui/ TUI/JSON-RPC bridges; boot, approval, interaction plugins ui/ JSON-RPC bridge; boot, approval, interaction plugins
examples/ demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load examples/ demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load
support/ dev/test infrastructure support/ dev/test infrastructure
util/ zero-dependency utilities util/ zero-dependency utilities
python/ Python SDK and bundled runtime (see python/README.md) 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 # vitest unit tests
pnpm run test:coverage # CI coverage gate: per-file 100% on packages/*/*/src 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:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY
pnpm run test:snapshot # keyless ACP/headless/TUI replay vs expected outputs; filter: -t <name> pnpm run test:snapshot # keyless ACP/headless replay vs expected outputs; filter: -t <name>
pnpm run test:snapshot:record # re-record expected outputs (needs key) pnpm run test:snapshot:record # re-record expected outputs (needs key)
pnpm run typecheck pnpm run typecheck
pnpm run lint 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 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 website:build # VitePress build (doubles as dead-link check)
pnpm run demo:headless "task" # one-shot agent (needs DEEPSEEK_API_KEY) 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:cordis # the agent modifies its own runtime (needs key)
pnpm run demo:acp # ACP automation server (needs DEEPSEEK_API_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 ### 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. - 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. - 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 ## Conventions
- Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. - Every npm package is `@deepseek-ai/dsh-<name>`; 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. - **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)). - **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. - **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. - **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)). - **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. - **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. - **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)). - 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. - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it.

View File

@@ -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` ## `@deepseek-ai/dsh-system-prompt`

View File

@@ -340,6 +340,7 @@ flowchart TD
pkg_subprocess_e2b --> pkg_e2b pkg_subprocess_e2b --> pkg_e2b
pkg_subprocess_e2b --> pkg_invariants pkg_subprocess_e2b --> pkg_invariants
pkg_subprocess_e2b --> pkg_subprocess pkg_subprocess_e2b --> pkg_subprocess
pkg_subprocess_e2b --> pkg_timeout
pkg_frontend_static --> pkg_host_webserver pkg_frontend_static --> pkg_host_webserver
pkg_frontend_static --> pkg_invariants pkg_frontend_static --> pkg_invariants
pkg_helper --> pkg_brand 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-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) | | [`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) | | [`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) | | [`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) | | [`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) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |

View File

@@ -28,6 +28,7 @@
"@deepseek-ai/dsh-e2b": "^0.0.1", "@deepseek-ai/dsh-e2b": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7" "cordis": "^4.0.0-rc.7"
}, },
"dependencies": { "dependencies": {
@@ -37,6 +38,7 @@
"@deepseek-ai/dsh-e2b": "workspace:^", "@deepseek-ai/dsh-e2b": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7" "cordis": "^4.0.0-rc.7"
} }
} }

View File

@@ -9,6 +9,7 @@ import { posix } from 'node:path'
import { Context } from 'cordis' import { Context } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess' import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { import type {
SubprocessHandle, SubprocessHandle,
SubprocessSpawnSpec, SubprocessSpawnSpec,
@@ -35,6 +36,18 @@ interface TerminalSetup {
controller: AbortController 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`. */ /** E2B command manager registered as `ctx.subprocess`. */
export class E2BSubprocessService extends SubprocessService { export class E2BSubprocessService extends SubprocessService {
static inject = ['e2b'] static inject = ['e2b']
@@ -130,6 +143,7 @@ export class E2BSubprocessService extends SubprocessService {
if (program === undefined || program.length === 0) { if (program === undefined || program.length === 0) {
throw new Error('invalid argv: expected a non-empty program name at argv[0]') throw new Error('invalid argv: expected a non-empty program name at argv[0]')
} }
requireRepresentableGrace(spec.graceMs)
if (spec.signal?.aborted === true) { if (spec.signal?.aborted === true) {
throw new Error(`aborted before spawn: ${String(spec.signal.reason)}`) 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) { if (program === undefined || program.length === 0) {
throw new Error('subprocess-e2b: terminal argv must contain a program') throw new Error('subprocess-e2b: terminal argv must contain a program')
} }
requireRepresentableGrace(spec.graceMs)
spec.signal?.throwIfAborted() spec.signal?.throwIfAborted()
const stateDir = posix.join(this.ctx.e2b.runtimeRoot, 'terminals', randomUUID()) const stateDir = posix.join(this.ctx.e2b.runtimeRoot, 'terminals', randomUUID())
const done = Promise.withResolvers<void>() const done = Promise.withResolvers<void>()

View File

@@ -194,7 +194,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private readonly runtime: E2BSandboxService, private readonly runtime: E2BSandboxService,
private readonly spec: SubprocessSpawnSpec, private readonly spec: SubprocessSpawnSpec,
readonly stateDir: string, readonly stateDir: string,
private readonly pollMs = 20, private readonly pollMs: number,
) { ) {
this.paths = { this.paths = {
pid: posix.join(stateDir, 'pid'), pid: posix.join(stateDir, 'pid'),
@@ -378,7 +378,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
await this.removeFailedState(sandbox) await this.removeFailedState(sandbox)
} catch (cleanupError: unknown) { } catch (cleanupError: unknown) {
failure = new AggregateError( failure = new AggregateError(
[error, cleanupError], [failure, cleanupError],
'subprocess-e2b: command failed and private state cleanup failed', 'subprocess-e2b: command failed and private state cleanup failed',
) )
} }
@@ -398,8 +398,10 @@ export class E2BSubprocessHandle implements SubprocessHandle {
const signal = this.terminationController.signal const signal = this.terminationController.signal
const ambient = await readRemoteEnvironment(sandbox, signal) const ambient = await readRemoteEnvironment(sandbox, signal)
this.controlEnvs = bootstrapEnvironment(ambient) 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 this.stateDirectoryCreated = true
await sandbox.files.makeDir(this.stateDir, { signal })
await sandbox.commands.run( await sandbox.commands.run(
`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`, `chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`,
commandOpts(this.controlEnvs, signal), commandOpts(this.controlEnvs, signal),

View File

@@ -459,7 +459,7 @@ export async function spawnE2BTerminal(
runtime: E2BSandboxService, runtime: E2BSandboxService,
spec: SubprocessTerminalSpawnSpec, spec: SubprocessTerminalSpawnSpec,
stateDir: string, stateDir: string,
pollMs = 20, pollMs: number,
): Promise<E2BTerminalHandle> { ): Promise<E2BTerminalHandle> {
const sandbox = await runtime.getSandbox() const sandbox = await runtime.getSandbox()
spec.signal?.throwIfAborted() spec.signal?.throwIfAborted()

View File

@@ -324,6 +324,16 @@ async function flush(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
} }
/** Construct the handle under test with the config default the service would pass. */
function testHandle(
runtime: ConstructorParameters<typeof E2BSubprocessHandle>[0],
spec: ConstructorParameters<typeof E2BSubprocessHandle>[1],
stateDir: string,
pollMs = 20,
): E2BSubprocessHandle {
return new E2BSubprocessHandle(runtime, spec, stateDir, pollMs)
}
describe('E2BOutputReader', () => { describe('E2BOutputReader', () => {
it('decodes base64 across arbitrary callback boundaries and rejects malformed framing', () => { it('decodes base64 across arbitrary callback boundaries and rejects malformed framing', () => {
const decoder = new E2BBase64Decoder() const decoder = new E2BBase64Decoder()
@@ -379,7 +389,7 @@ describe('E2BSubprocessHandle', () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
fake.processGroupId = '4343\n' fake.processGroupId = '4343\n'
fake.deferStart() fake.deferStart()
const handle = new E2BSubprocessHandle(runtime(fake), spec({ const handle = testHandle(runtime(fake), spec({
argv: ['tool', 'argument with spaces'], argv: ['tool', 'argument with spaces'],
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 8, spill: { maxBytes: 32 } } }, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 8, spill: { maxBytes: 32 } } },
env: { env: {
@@ -450,10 +460,23 @@ describe('E2BSubprocessHandle', () => {
await expect(handle.waitForExit()).resolves.toBe(true) 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 () => { it('rejects malformed environment entries before command start', async () => {
for (const env of [{ 'BAD=NAME': 'x' }, { BAD: 'x\0INJECTED=1' }]) { for (const env of [{ 'BAD=NAME': 'x' }, { BAD: 'x\0INJECTED=1' }]) {
const fake = new FakeSandbox() 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') await expect(handle.done).rejects.toThrow('environment entries')
expect(fake.startOptions).toBeUndefined() expect(fake.startOptions).toBeUndefined()
expect(fake.removed).toContain('/runtime/invalid-environment') 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 () => { it('preserves UTF-8 bytes when the ASCII transport is split across callbacks', async () => {
const fake = new FakeSandbox() 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 } }, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
}), '/runtime/split-utf8') }), '/runtime/split-utf8')
await flush() await flush()
@@ -478,14 +501,14 @@ describe('E2BSubprocessHandle', () => {
it('rejects malformed output transport without confusing it with a consumer sink failure', async () => { it('rejects malformed output transport without confusing it with a consumer sink failure', async () => {
const fake = new FakeSandbox() 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 flush()
await fake.stdoutWire('%\n') await fake.stdoutWire('%\n')
fake.finish() fake.finish()
await expect(handle.done).rejects.toThrow('invalid base64 output transport') await expect(handle.done).rejects.toThrow('invalid base64 output transport')
const stderrFake = new FakeSandbox() 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 flush()
await stderrFake.stderrWire('%\n') await stderrFake.stderrWire('%\n')
stderrFake.finish() stderrFake.finish()
@@ -494,7 +517,7 @@ describe('E2BSubprocessHandle', () => {
it('rejects a naturally completed command whose encoder omits its completion frame', async () => { it('rejects a naturally completed command whose encoder omits its completion frame', async () => {
const fake = new FakeSandbox() 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() await flush()
fake.alive = false fake.alive = false
fake.handle.succeed(0) fake.handle.succeed(0)
@@ -503,7 +526,7 @@ describe('E2BSubprocessHandle', () => {
it('bounds descendant-held output draining and withholds the incomplete spill', async () => { it('bounds descendant-held output draining and withholds the incomplete spill', async () => {
const fake = new FakeSandbox() 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 flush()
await fake.stdout('leader-output') await fake.stdout('leader-output')
fake.exitStatus = '0\n' fake.exitStatus = '0\n'
@@ -529,7 +552,7 @@ describe('E2BSubprocessHandle', () => {
return false return false
}) as typeof process.stdout.write) }) as typeof process.stdout.write)
try { try {
const handle = new E2BSubprocessHandle(runtime(fake), spec({ const handle = testHandle(runtime(fake), spec({
graceMs: 5, graceMs: 5,
stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 4 } }, stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 4 } },
}), '/runtime/inherit-backpressure') }), '/runtime/inherit-backpressure')
@@ -555,7 +578,7 @@ describe('E2BSubprocessHandle', () => {
it('waits for lossless raw-pipe output after the direct status is published', async () => { it('waits for lossless raw-pipe output after the direct status is published', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({ const handle = testHandle(runtime(fake), spec({
graceMs: 1, graceMs: 1,
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } }, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
}), '/runtime/pipe-drain') }), '/runtime/pipe-drain')
@@ -580,7 +603,7 @@ describe('E2BSubprocessHandle', () => {
it('accepts clean encoder completion inside the output-drain grace', async () => { it('accepts clean encoder completion inside the output-drain grace', async () => {
const fake = new FakeSandbox() 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() await flush()
fake.exitStatus = '0\n' fake.exitStatus = '0\n'
fake.afterStatusRead = () => { fake.afterStatusRead = () => {
@@ -598,7 +621,7 @@ describe('E2BSubprocessHandle', () => {
fake.delaysKill = true fake.delaysKill = true
fake.delaysKillCompletion = true fake.delaysKillCompletion = true
fake.sdkKillStops = false 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() await flush()
handle.terminate() 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 () => { it('preserves a published nonzero exit code when termination settles the SDK inside the drain grace', async () => {
const fake = new FakeSandbox() 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() await flush()
fake.exitStatus = '7\n' fake.exitStatus = '7\n'
fake.afterStatusRead = () => { fake.afterStatusRead = () => {
@@ -628,7 +651,7 @@ describe('E2BSubprocessHandle', () => {
it('rejects an invalid direct-command exit status', async () => { it('rejects an invalid direct-command exit status', async () => {
const fake = new FakeSandbox() 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() await flush()
fake.exitStatus = '999\n' fake.exitStatus = '999\n'
await expect(handle.done).rejects.toThrow('invalid exit code') 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 () => { it('rolls back a published process group before rejecting a monitoring failure', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
fake.statusError = new Error('status transport failed') 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') await expect(handle.done).rejects.toThrow('status transport failed')
expect(fake.commandsSeen).toContain('kill -TERM -- -4242') expect(fake.commandsSeen).toContain('kill -TERM -- -4242')
@@ -650,7 +673,7 @@ describe('E2BSubprocessHandle', () => {
failed.statusError = new Error('status transport failed') failed.statusError = new Error('status transport failed')
failed.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed')) failed.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed'))
failed.handle.killError = new Error('SDK kill 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( await expect(retained.done).rejects.toThrow(
'command monitoring failed and process-group rollback did not reach quiescence', 'command monitoring failed and process-group rollback did not reach quiescence',
@@ -659,13 +682,30 @@ describe('E2BSubprocessHandle', () => {
failed.handle.killError = undefined failed.handle.killError = undefined
retained.terminate() retained.terminate()
await expect(retained.waitForExit()).resolves.toBe(true) 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 () => { it('surfaces deferred piped-stdin write and close failures as stream errors', async () => {
const writeFake = new FakeSandbox() const writeFake = new FakeSandbox()
writeFake.deferStart() writeFake.deferStart()
vi.spyOn(writeFake.handle, 'sendStdin').mockRejectedValueOnce('stdin rejected') 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 } }, stdio: { stdin: 'pipe', stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } },
}), '/runtime/stdin-write-error') }), '/runtime/stdin-write-error')
const writeError = once(writeHandle.stdin!, 'error') const writeError = once(writeHandle.stdin!, 'error')
@@ -677,7 +717,7 @@ describe('E2BSubprocessHandle', () => {
const closeFake = new FakeSandbox() const closeFake = new FakeSandbox()
vi.spyOn(closeFake.handle, 'closeStdin').mockRejectedValueOnce(new Error('close rejected')) 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 } }, stdio: { stdin: 'pipe', stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } },
}), '/runtime/stdin-close-error') }), '/runtime/stdin-close-error')
await flush() await flush()
@@ -690,7 +730,7 @@ describe('E2BSubprocessHandle', () => {
it('collects bounded tails, retains valid spills, and maps natural nonzero exits', async () => { it('collects bounded tails, retains valid spills, and maps natural nonzero exits', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({ const handle = testHandle(runtime(fake), spec({
stdio: { stdio: {
stdin: { data: 'batch' }, stdin: { data: 'batch' },
stdout: { maxBytes: 4, spill: { maxBytes: 16 } }, stdout: { maxBytes: 4, spill: { maxBytes: 16 } },
@@ -716,7 +756,7 @@ describe('E2BSubprocessHandle', () => {
it('removes a spill once the complete stream exceeds its cap', async () => { it('removes a spill once the complete stream exceeds its cap', async () => {
const fake = new FakeSandbox() 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' }, stdio: { stdin: 'ignore', stdout: { maxBytes: 2, spill: { maxBytes: 3 } }, stderr: 'inherit' },
}), '/runtime/oversize') }), '/runtime/oversize')
await flush() await flush()
@@ -736,7 +776,7 @@ describe('E2BSubprocessHandle', () => {
it('contains remote spill-removal failures and routes empty inherited output', async () => { it('contains remote spill-removal failures and routes empty inherited output', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
fake.nextRemoveError = new Error('already removed') 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 } } }, stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 4, spill: { maxBytes: 8 } } },
}), '/runtime/remove-error') }), '/runtime/remove-error')
await flush() await flush()
@@ -749,7 +789,7 @@ describe('E2BSubprocessHandle', () => {
it('terminates a process group with TERM and reports the signal outcome', async () => { it('terminates a process group with TERM and reports the signal outcome', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/term') const handle = testHandle(runtime(fake), spec(), '/runtime/term')
await flush() await flush()
handle.terminate() handle.terminate()
handle.terminate() handle.terminate()
@@ -767,7 +807,7 @@ describe('E2BSubprocessHandle', () => {
it('makes termination a permanent no-op after natural quiescence is observed', async () => { it('makes termination a permanent no-op after natural quiescence is observed', async () => {
const fake = new FakeSandbox() 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() await flush()
fake.finish() fake.finish()
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) 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 () => { it('treats a zombie-only process group as quiescent', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
fake.zombieOnly = true 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 flush()
await expect(handle.waitForExit()).resolves.toBe(true) 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.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed'))
fake.handle.killError = new Error('SDK kill failed') fake.handle.killError = new Error('SDK kill failed')
fake.deferSignals() fake.deferSignals()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/quiescent-race') const handle = testHandle(runtime(fake), spec(), '/runtime/quiescent-race')
await flush() await flush()
handle.terminate() handle.terminate()
@@ -823,7 +863,7 @@ describe('E2BSubprocessHandle', () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
fake.trapsTerm = true fake.trapsTerm = true
fake.handle.killError = new Error('already gone') 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() await flush()
handle.terminate() handle.terminate()
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
@@ -838,7 +878,7 @@ describe('E2BSubprocessHandle', () => {
fake.delaysKill = true fake.delaysKill = true
fake.delaysKillCompletion = true fake.delaysKillCompletion = true
fake.sdkKillStops = false 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() await flush()
handle.terminate() handle.terminate()
await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) }) await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) })
@@ -856,7 +896,7 @@ describe('E2BSubprocessHandle', () => {
it('honors termination requested before asynchronous startup finishes', async () => { it('honors termination requested before asynchronous startup finishes', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
fake.deferStart() fake.deferStart()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/deferred-kill') const handle = testHandle(runtime(fake), spec(), '/runtime/deferred-kill')
handle.terminate() handle.terminate()
fake.releaseStart() fake.releaseStart()
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' }) await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
@@ -879,7 +919,7 @@ describe('E2BSubprocessHandle', () => {
signal?.addEventListener('abort', rejectAbort, { once: true }) 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() }) await vi.waitFor(() => { expect(preparationSignal).toBeDefined() })
handle.terminate() handle.terminate()
@@ -893,7 +933,7 @@ describe('E2BSubprocessHandle', () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
fake.deferProcessGroupRead() fake.deferProcessGroupRead()
fake.signalErrors.push(commandError(1), commandError(1)) 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() }) await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() })
handle.terminate() handle.terminate()
@@ -913,7 +953,7 @@ describe('E2BSubprocessHandle', () => {
fake.delaysKillCompletion = true fake.delaysKillCompletion = true
fake.sdkKillStops = false fake.sdkKillStops = false
fake.handle.killResult = 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() }) await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() })
handle.terminate() handle.terminate()
@@ -936,7 +976,7 @@ describe('E2BSubprocessHandle', () => {
calls += 1 calls += 1
return calls === 1 ? fake.sandbox : await reconnect.promise 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() }) await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() })
handle.terminate() handle.terminate()
@@ -957,7 +997,7 @@ describe('E2BSubprocessHandle', () => {
fake.deferProcessGroupRead() fake.deferProcessGroupRead()
fake.trapsTerm = true fake.trapsTerm = true
fake.handle.killError = new Error('SDK kill unavailable') 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() }) await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() })
handle.terminate() handle.terminate()
@@ -971,7 +1011,7 @@ describe('E2BSubprocessHandle', () => {
fake.deferProcessGroupRead() fake.deferProcessGroupRead()
fake.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed')) fake.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed'))
fake.handle.killError = new Error('SDK kill 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() }) await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() })
handle.terminate() handle.terminate()
@@ -987,7 +1027,7 @@ describe('E2BSubprocessHandle', () => {
absentGroup.deferProcessGroupRead() absentGroup.deferProcessGroupRead()
absentGroup.signalErrors.push(commandError(1), commandError(1)) absentGroup.signalErrors.push(commandError(1), commandError(1))
absentGroup.handle.killError = new Error('SDK kill failed without a provisional group') absentGroup.handle.killError = new Error('SDK kill failed without a provisional group')
const absentHandle = new E2BSubprocessHandle( const absentHandle = testHandle(
runtime(absentGroup), runtime(absentGroup),
spec({ graceMs: 1 }), spec({ graceMs: 1 }),
'/runtime/pre-publication-absent-group', '/runtime/pre-publication-absent-group',
@@ -1005,7 +1045,7 @@ describe('E2BSubprocessHandle', () => {
optimisticSdk.deferProcessGroupRead() optimisticSdk.deferProcessGroupRead()
optimisticSdk.signalErrors.push(commandError(1), commandError(1)) optimisticSdk.signalErrors.push(commandError(1), commandError(1))
optimisticSdk.sdkKillStops = false optimisticSdk.sdkKillStops = false
const optimisticHandle = new E2BSubprocessHandle( const optimisticHandle = testHandle(
runtime(optimisticSdk), runtime(optimisticSdk),
spec({ graceMs: 1 }), spec({ graceMs: 1 }),
'/runtime/pre-publication-optimistic-sdk', '/runtime/pre-publication-optimistic-sdk',
@@ -1021,14 +1061,14 @@ describe('E2BSubprocessHandle', () => {
it('honors an already-aborted signal when constructing the asynchronous handle directly', async () => { it('honors an already-aborted signal when constructing the asynchronous handle directly', async () => {
const fake = new FakeSandbox() 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' }) await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
}) })
it('reacts to a signal that aborts after the remote command has started', async () => { it('reacts to a signal that aborts after the remote command has started', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
const controller = new AbortController() 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() await flush()
controller.abort('stop') controller.abort('stop')
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' }) 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 () => { it('can terminate a surviving process group after the command leader settles', async () => {
const fake = new FakeSandbox() 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 flush()
await fake.completeOutput() await fake.completeOutput()
fake.handle.succeed(0) fake.handle.succeed(0)
@@ -1054,7 +1094,7 @@ describe('E2BSubprocessHandle', () => {
it('bounds waitForExit while startup or a live group is pending', async () => { it('bounds waitForExit while startup or a live group is pending', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
fake.deferStart() fake.deferStart()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/wait') const handle = testHandle(runtime(fake), spec(), '/runtime/wait')
const beforeStart = new AbortController() const beforeStart = new AbortController()
const pending = handle.waitForExit(beforeStart.signal) const pending = handle.waitForExit(beforeStart.signal)
beforeStart.abort() beforeStart.abort()
@@ -1071,7 +1111,7 @@ describe('E2BSubprocessHandle', () => {
const terminatingFake = new FakeSandbox() const terminatingFake = new FakeSandbox()
terminatingFake.deferStart() 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() terminating.terminate()
const beforeHandle = new AbortController() const beforeHandle = new AbortController()
const handlePending = terminating.waitForExit(beforeHandle.signal) const handlePending = terminating.waitForExit(beforeHandle.signal)
@@ -1083,7 +1123,7 @@ describe('E2BSubprocessHandle', () => {
it('bounds both sides of the liveness-poll abort race', async () => { it('bounds both sides of the liveness-poll abort race', async () => {
const fake = new FakeSandbox() 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() await flush()
const beforeTick = new AbortController() const beforeTick = new AbortController()
@@ -1114,7 +1154,7 @@ describe('E2BSubprocessHandle', () => {
it('observes a live group across one successful bounded poll', async () => { it('observes a live group across one successful bounded poll', async () => {
const fake = new FakeSandbox() 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() await flush()
setTimeout(() => { fake.finish() }, 1) setTimeout(() => { fake.finish() }, 1)
await expect(handle.waitForExit(new AbortController().signal)).resolves.toBe(true) 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 () => { it('treats startup failure as no live tree and contains readiness rejection', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
fake.backgroundError = new Error('start failed') 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') await expect(handle.done).rejects.toThrow('start failed')
expect(handle.pid).toBe(-1) expect(handle.pid).toBe(-1)
expect(fake.removed).toContain('/runtime/fail/environment') expect(fake.removed).toContain('/runtime/fail/environment')
@@ -1132,7 +1172,7 @@ describe('E2BSubprocessHandle', () => {
await expect(handle.waitForExit()).resolves.toBe(true) await expect(handle.waitForExit()).resolves.toBe(true)
handle.terminate() handle.terminate()
const unavailableHandle = new E2BSubprocessHandle( const unavailableHandle = testHandle(
runtime(new FakeSandbox(), async () => { throw new Error('sandbox unavailable') }), runtime(new FakeSandbox(), async () => { throw new Error('sandbox unavailable') }),
spec(), spec(),
'/runtime/unavailable-start', '/runtime/unavailable-start',
@@ -1142,14 +1182,14 @@ describe('E2BSubprocessHandle', () => {
const envFailure = new FakeSandbox() const envFailure = new FakeSandbox()
envFailure.envError = new Error('ambient lookup failed') 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') await expect(envHandle.done).rejects.toThrow('ambient lookup failed')
expect(envFailure.removed).toEqual([]) expect(envFailure.removed).toEqual([])
const expectEnvironmentFailure = async (name: string, wire: string, message: string): Promise<void> => { const expectEnvironmentFailure = async (name: string, wire: string, message: string): Promise<void> => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
fake.environmentWire = wire 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) await expect(failed.done).rejects.toThrow(message)
} }
const encodedEnvironment = Buffer.from('PATH=/bin\0').toString('base64') const encodedEnvironment = Buffer.from('PATH=/bin\0').toString('base64')
@@ -1180,13 +1220,13 @@ describe('E2BSubprocessHandle', () => {
const cleanupFailure = new FakeSandbox() const cleanupFailure = new FakeSandbox()
cleanupFailure.backgroundError = new Error('start failed before credential consumption') cleanupFailure.backgroundError = new Error('start failed before credential consumption')
cleanupFailure.nextRemoveError = new Error('credential cleanup failed') 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') await expect(cleanupHandle.done).rejects.toThrow('command failed and private state cleanup failed')
const absentState = new FakeSandbox() const absentState = new FakeSandbox()
absentState.backgroundError = new Error('start failed after external cleanup') absentState.backgroundError = new Error('start failed after external cleanup')
absentState.nextRemoveError = new FileNotFoundError('already removed') 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') await expect(absentHandle.done).rejects.toThrow('start failed after external cleanup')
}) })
@@ -1194,7 +1234,7 @@ describe('E2BSubprocessHandle', () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
fake.deferStart() fake.deferStart()
fake.backgroundError = new Error('start failed') 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) const waiting = handle.waitForExit(new AbortController().signal)
fake.releaseStart() fake.releaseStart()
await expect(handle.done).rejects.toThrow('start failed') await expect(handle.done).rejects.toThrow('start failed')
@@ -1209,7 +1249,7 @@ describe('E2BSubprocessHandle', () => {
if (calls === 1) return fake.sandbox if (calls === 1) return fake.sandbox
throw new Error('connection unavailable') throw new Error('connection unavailable')
}) })
const handle = new E2BSubprocessHandle(unavailable, spec(), '/runtime/unavailable') const handle = testHandle(unavailable, spec(), '/runtime/unavailable')
await flush() await flush()
await expect(handle.waitForExit()).rejects.toThrow('connection unavailable') await expect(handle.waitForExit()).rejects.toThrow('connection unavailable')
fake.finish() fake.finish()
@@ -1224,7 +1264,7 @@ describe('E2BSubprocessHandle', () => {
calls += 1 calls += 1
return calls === 1 ? fake.sandbox : await reconnect.promise 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() await flush()
const controller = new AbortController() const controller = new AbortController()
const waiting = handle.waitForExit(controller.signal) 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 () => { it('returns false when a liveness request itself is aborted and surfaces other probe failures', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/probe') const handle = testHandle(runtime(fake), spec(), '/runtime/probe')
await flush() await flush()
const controller = new AbortController() const controller = new AbortController()
controller.abort() controller.abort()
@@ -1251,7 +1291,7 @@ describe('E2BSubprocessHandle', () => {
it('treats a timeout-killed sandbox as quiescent during liveness probing', async () => { it('treats a timeout-killed sandbox as quiescent during liveness probing', async () => {
const fake = new FakeSandbox() 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() await flush()
fake.finish() fake.finish()
await handle.done await handle.done
@@ -1263,7 +1303,7 @@ describe('E2BSubprocessHandle', () => {
it('treats a missing sandbox handle as quiescent during liveness acquisition', async () => { it('treats a missing sandbox handle as quiescent during liveness acquisition', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
let calls = 0 let calls = 0
const handle = new E2BSubprocessHandle(runtime(fake, async () => { const handle = testHandle(runtime(fake, async () => {
calls += 1 calls += 1
if (calls === 1) return fake.sandbox if (calls === 1) return fake.sandbox
throw new SandboxNotFoundError('sandbox expired') throw new SandboxNotFoundError('sandbox expired')
@@ -1280,7 +1320,7 @@ describe('E2BSubprocessHandle', () => {
it('treats sandbox loss during termination as quiescent', async () => { it('treats sandbox loss during termination as quiescent', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
let calls = 0 let calls = 0
const handle = new E2BSubprocessHandle(runtime(fake, async () => { const handle = testHandle(runtime(fake, async () => {
calls += 1 calls += 1
if (calls === 1) return fake.sandbox if (calls === 1) return fake.sandbox
throw new SandboxNotFoundError('sandbox expired') throw new SandboxNotFoundError('sandbox expired')
@@ -1298,7 +1338,7 @@ describe('E2BSubprocessHandle', () => {
it('makes batch stdin close failures best-effort', async () => { it('makes batch stdin close failures best-effort', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
vi.spyOn(fake.handle, 'sendStdin').mockRejectedValueOnce(new Error('closed')) 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 } }, stdio: { stdin: { data: 'ignored' }, stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } },
}), '/runtime/stdin-closed') }), '/runtime/stdin-closed')
await flush() await flush()
@@ -1309,7 +1349,7 @@ describe('E2BSubprocessHandle', () => {
it('rejects malformed SDK process ids and non-command settlement failures', async () => { it('rejects malformed SDK process ids and non-command settlement failures', async () => {
const invalidPid = new FakeSandbox() const invalidPid = new FakeSandbox()
invalidPid.handle.pid = 0 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/) await expect(invalid.done).rejects.toThrow(/invalid command pid 0/)
expect(invalidPid.handle.kills).toBe(1) expect(invalidPid.handle.kills).toBe(1)
expect(invalidPid.removed).toContain('/runtime/invalid-pid/environment') expect(invalidPid.removed).toContain('/runtime/invalid-pid/environment')
@@ -1318,7 +1358,7 @@ describe('E2BSubprocessHandle', () => {
const failedRollback = new FakeSandbox() const failedRollback = new FakeSandbox()
failedRollback.handle.pid = 0 failedRollback.handle.pid = 0
failedRollback.handle.killError = new Error('invalid handle kill failed') 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.done).rejects.toThrow('invalid command pid rollback did not reach quiescence')
await expect(retained.waitForExit()).rejects.toThrow('invalid handle kill failed') await expect(retained.waitForExit()).rejects.toThrow('invalid handle kill failed')
failedRollback.handle.killError = undefined failedRollback.handle.killError = undefined
@@ -1326,7 +1366,7 @@ describe('E2BSubprocessHandle', () => {
await expect(retained.waitForExit()).resolves.toBe(true) await expect(retained.waitForExit()).resolves.toBe(true)
const crashedFake = new FakeSandbox() const crashedFake = new FakeSandbox()
const crashed = new E2BSubprocessHandle(runtime(crashedFake), spec(), '/runtime/crashed') const crashed = testHandle(runtime(crashedFake), spec(), '/runtime/crashed')
await flush() await flush()
crashedFake.alive = false crashedFake.alive = false
crashedFake.handle.crash(new Error('command transport failed')) crashedFake.handle.crash(new Error('command transport failed'))
@@ -1339,7 +1379,7 @@ describe('E2BSubprocessHandle', () => {
invalidGroup.delaysKill = true invalidGroup.delaysKill = true
invalidGroup.sdkKillStops = false invalidGroup.sdkKillStops = false
invalidGroup.afterProbe = () => { invalidGroup.alive = 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/) await expect(invalid.done).rejects.toThrow(/invalid process-group id/)
expect(invalidGroup.handle.kills).toBe(1) expect(invalidGroup.handle.kills).toBe(1)
expect(invalidGroup.commandsSeen).toContain('kill -KILL -- -4242') expect(invalidGroup.commandsSeen).toContain('kill -KILL -- -4242')
@@ -1351,14 +1391,14 @@ describe('E2BSubprocessHandle', () => {
unsafeGroup.delaysKill = true unsafeGroup.delaysKill = true
unsafeGroup.sdkKillStops = false unsafeGroup.sdkKillStops = false
unsafeGroup.afterProbe = () => { unsafeGroup.alive = 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/) await expect(unsafe.done).rejects.toThrow(/unsafe published process-group id 1/)
expect(unsafeGroup.commandsSeen).not.toContain('kill -KILL -- -1') expect(unsafeGroup.commandsSeen).not.toContain('kill -KILL -- -1')
await expect(unsafe.waitForExit()).resolves.toBe(true) await expect(unsafe.waitForExit()).resolves.toBe(true)
const absentGroup = new FakeSandbox() const absentGroup = new FakeSandbox()
absentGroup.processGroupId = '' absentGroup.processGroupId = ''
const absent = new E2BSubprocessHandle(runtime(absentGroup), spec(), '/runtime/absent-group') const absent = testHandle(runtime(absentGroup), spec(), '/runtime/absent-group')
await flush() await flush()
absentGroup.finish() absentGroup.finish()
await expect(absent.done).rejects.toThrow(/exited before publishing/) await expect(absent.done).rejects.toThrow(/exited before publishing/)
@@ -1372,7 +1412,7 @@ describe('E2BSubprocessHandle', () => {
fake.processGroupId = 'not-a-pid\n' fake.processGroupId = 'not-a-pid\n'
fake.signalError = new Error('rollback signal failed') fake.signalError = new Error('rollback signal failed')
fake.handle.killError = new Error('SDK kill 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 let failure: unknown
try { try {
@@ -1403,7 +1443,7 @@ describe('E2BSubprocessHandle', () => {
naturallyGone.processGroupId = 'not-a-pid\n' naturallyGone.processGroupId = 'not-a-pid\n'
naturallyGone.signalError = new Error('rollback signal failed') naturallyGone.signalError = new Error('rollback signal failed')
naturallyGone.handle.killError = new Error('SDK kill 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') await expect(observed.done).rejects.toThrow('process-group publication failed')
naturallyGone.alive = false naturallyGone.alive = false
await expect(observed.waitForExit()).resolves.toBe(true) await expect(observed.waitForExit()).resolves.toBe(true)
@@ -1412,7 +1452,7 @@ describe('E2BSubprocessHandle', () => {
it('waits for delayed process-group publication', async () => { it('waits for delayed process-group publication', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
fake.processGroupReads.push('', '4242\n') 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) }) await vi.waitFor(() => { expect(handle.pid).toBe(4242) })
fake.finish() fake.finish()
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) 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 () => { it('handles output backpressure and contains a stderr sink failure', async () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({ const handle = testHandle(runtime(fake), spec({
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
}), '/runtime/backpressure') }), '/runtime/backpressure')
await flush() await flush()
@@ -1445,7 +1485,7 @@ describe('E2BSubprocessHandle', () => {
it('settles output backpressure when the consumer closes the pipe', async () => { it('settles output backpressure when the consumer closes the pipe', async () => {
const fake = new FakeSandbox() 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 } }, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
}), '/runtime/backpressure-close') }), '/runtime/backpressure-close')
await flush() await flush()
@@ -1462,7 +1502,7 @@ describe('E2BSubprocessHandle', () => {
it('breaks output backpressure when termination owns the command', async () => { it('breaks output backpressure when termination owns the command', async () => {
const fake = new FakeSandbox() 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 } }, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
}), '/runtime/backpressure-termination') }), '/runtime/backpressure-termination')
await flush() await flush()
@@ -1484,7 +1524,7 @@ describe('E2BSubprocessHandle', () => {
it('settles backpressure when a synchronous pipe write starts termination', async () => { it('settles backpressure when a synchronous pipe write starts termination', async () => {
const fake = new FakeSandbox() 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 } }, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
}), '/runtime/backpressure-synchronous-termination') }), '/runtime/backpressure-synchronous-termination')
await flush() await flush()
@@ -1500,7 +1540,7 @@ describe('E2BSubprocessHandle', () => {
it('contains a pipe callback failure instead of rejecting command settlement', async () => { it('contains a pipe callback failure instead of rejecting command settlement', async () => {
const fake = new FakeSandbox() 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 } }, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
}), '/runtime/pipe-error') }), '/runtime/pipe-error')
await flush() await flush()
@@ -1516,14 +1556,14 @@ describe('E2BSubprocessHandle', () => {
const gone = new FakeSandbox() const gone = new FakeSandbox()
gone.trapsTerm = true gone.trapsTerm = true
gone.signalError = commandError(1) 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() await flush()
goneHandle.terminate() goneHandle.terminate()
await expect(goneHandle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) await expect(goneHandle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
const failed = new FakeSandbox() const failed = new FakeSandbox()
failed.signalError = new Error('signal transport failed') 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() await flush()
failedHandle.terminate() failedHandle.terminate()
await expect(failedHandle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) await expect(failedHandle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
@@ -1534,7 +1574,7 @@ describe('E2BSubprocessHandle', () => {
const fake = new FakeSandbox() const fake = new FakeSandbox()
fake.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed')) fake.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed'))
fake.handle.killError = new Error('SDK kill 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() await flush()
handle.terminate() handle.terminate()
@@ -1548,7 +1588,7 @@ describe('E2BSubprocessHandle', () => {
missingGroup.trapsTerm = true missingGroup.trapsTerm = true
missingGroup.signalErrors.push(undefined, commandError(1)) missingGroup.signalErrors.push(undefined, commandError(1))
missingGroup.handle.killError = new Error('SDK kill failed after group exit race') 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() await flush()
raced.terminate() raced.terminate()
await expect(raced.waitForExit()).rejects.toThrow('remained live after force termination') await expect(raced.waitForExit()).rejects.toThrow('remained live after force termination')
@@ -1562,7 +1602,7 @@ describe('E2BSubprocessHandle', () => {
fake.trapsTerm = true fake.trapsTerm = true
fake.sdkKillStops = false fake.sdkKillStops = false
fake.signalErrors.push(undefined, new Error('KILL transport failed')) 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() await flush()
handle.terminate() handle.terminate()

View File

@@ -259,10 +259,20 @@ function holdRequestUntilAbort(started: PromiseWithResolvers<AbortSignal>) {
} }
} }
/** Spawn the terminal under test with the config default the service would pass. */
function testSpawn(
runtime: Parameters<typeof spawnE2BTerminal>[0],
spec: Parameters<typeof spawnE2BTerminal>[1],
stateDir: string,
pollMs = 20,
): ReturnType<typeof spawnE2BTerminal> {
return spawnE2BTerminal(runtime, spec, stateDir, pollMs)
}
describe('E2B terminal allocation', () => { describe('E2B terminal allocation', () => {
it('hides bootstrap-shell bytes and preserves requested-shell bytes across the output boundary', async () => { it('hides bootstrap-shell bytes and preserves requested-shell bytes across the output boundary', async () => {
const fake = new FakeTerminalSandbox() 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 = '' let output = ''
terminal.output.on('data', (chunk) => { output += String(chunk) }) terminal.output.on('data', (chunk) => { output += String(chunk) })
await new Promise(resolve => setTimeout(resolve, 0)) 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 () => { it('inherits only safe ambient values and limits the allocation signal to setup', async () => {
const fake = new FakeTerminalSandbox() const fake = new FakeTerminalSandbox()
const controller = new AbortController() const controller = new AbortController()
const terminal = await spawnE2BTerminal( const terminal = await testSpawn(
runtime(fake), runtime(fake),
spec({ env: undefined, signal: controller.signal }), spec({ env: undefined, signal: controller.signal }),
'/runtime/abort-live', '/runtime/abort-live',
@@ -335,7 +345,7 @@ describe('E2B terminal allocation', () => {
const fake = new FakeTerminalSandbox() const fake = new FakeTerminalSandbox()
fake.deferCreate() fake.deferCreate()
const controller = new AbortController() const controller = new AbortController()
const spawning = spawnE2BTerminal( const spawning = testSpawn(
runtime(fake), runtime(fake),
spec({ signal: controller.signal }), spec({ signal: controller.signal }),
'/runtime/allocation-cancel', '/runtime/allocation-cancel',
@@ -352,23 +362,23 @@ describe('E2B terminal allocation', () => {
it('rejects malformed environment and argv values before PTY allocation', async () => { it('rejects malformed environment and argv values before PTY allocation', async () => {
const invalidName = new FakeTerminalSandbox() 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') .rejects.toThrow('environment entries')
expect(invalidName.createOptions).toBeUndefined() expect(invalidName.createOptions).toBeUndefined()
const invalidValue = new FakeTerminalSandbox() 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') .rejects.toThrow('environment entries')
const invalidArg = new FakeTerminalSandbox() 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') .rejects.toThrow('argv must not contain NUL')
}) })
it('cleans malformed handles, bootstrap failures, and readiness failures', async () => { it('cleans malformed handles, bootstrap failures, and readiness failures', async () => {
const failedState = new FakeTerminalSandbox() const failedState = new FakeTerminalSandbox()
failedState.writeError = new Error('state write failed') 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') .rejects.toThrow('state write failed')
expect(failedState.writes.get('/runtime/state-write/environment')).toContain('KEEP=visible\0') expect(failedState.writes.get('/runtime/state-write/environment')).toContain('KEEP=visible\0')
expect(failedState.removed).toContain('/runtime/state-write') expect(failedState.removed).toContain('/runtime/state-write')
@@ -377,19 +387,19 @@ describe('E2B terminal allocation', () => {
const stateAlreadyGone = new FakeTerminalSandbox() const stateAlreadyGone = new FakeTerminalSandbox()
stateAlreadyGone.writeError = new Error('state write failed after external cleanup') stateAlreadyGone.writeError = new Error('state write failed after external cleanup')
stateAlreadyGone.removeError = new FileNotFoundError('state already gone') 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') .rejects.toThrow('state write failed after external cleanup')
const invalidPid = new FakeTerminalSandbox() const invalidPid = new FakeTerminalSandbox()
invalidPid.handle.pid = 0 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') .rejects.toThrow('invalid terminal pid 0')
expect(invalidPid.handle.sdkKills).toBe(1) expect(invalidPid.handle.sdkKills).toBe(1)
expect(invalidPid.removed).toContain('/runtime/invalid-pid') expect(invalidPid.removed).toContain('/runtime/invalid-pid')
const failedInput = new FakeTerminalSandbox() const failedInput = new FakeTerminalSandbox()
failedInput.sendError = new Error('bootstrap failed') 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') .rejects.toThrow('bootstrap failed')
expect(failedInput.commands).toContain('kill -TERM -- -123') expect(failedInput.commands).toContain('kill -TERM -- -123')
expect(failedInput.groups).toEqual([]) expect(failedInput.groups).toEqual([])
@@ -397,7 +407,7 @@ describe('E2B terminal allocation', () => {
const invalidSession = new FakeTerminalSandbox() const invalidSession = new FakeTerminalSandbox()
invalidSession.sessionId = 'not-a-session\n' invalidSession.sessionId = 'not-a-session\n'
invalidSession.clearOnTerm = false 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') .rejects.toThrow('cannot resolve process session')
expect(invalidSession.commands).toContain('kill -TERM -- -123') expect(invalidSession.commands).toContain('kill -TERM -- -123')
expect(invalidSession.commands).toContain('kill -KILL -- -123') expect(invalidSession.commands).toContain('kill -KILL -- -123')
@@ -410,7 +420,7 @@ describe('E2B terminal allocation', () => {
const termFailed = new FakeTerminalSandbox() const termFailed = new FakeTerminalSandbox()
termFailed.sendError = new Error('bootstrap failed') termFailed.sendError = new Error('bootstrap failed')
termFailed.termFailure = new Error('TERM transport 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') .rejects.toThrow('bootstrap failed')
expect(termFailed.commands).toContain('kill -KILL -- -123') expect(termFailed.commands).toContain('kill -KILL -- -123')
expect(termFailed.handle.sdkKills).toBe(1) expect(termFailed.handle.sdkKills).toBe(1)
@@ -421,7 +431,7 @@ describe('E2B terminal allocation', () => {
uninspectable.handle.sdkKillError = new Error('PTY kill failed') uninspectable.handle.sdkKillError = new Error('PTY kill failed')
let uninspectableFailure: unknown let uninspectableFailure: unknown
try { try {
await spawnE2BTerminal(runtime(uninspectable), spec(), '/runtime/uninspectable') await testSpawn(runtime(uninspectable), spec(), '/runtime/uninspectable')
} catch (error: unknown) { } catch (error: unknown) {
uninspectableFailure = error uninspectableFailure = error
} }
@@ -432,21 +442,21 @@ describe('E2B terminal allocation', () => {
survivingGroups.sendError = new Error('bootstrap failed') survivingGroups.sendError = new Error('bootstrap failed')
survivingGroups.clearOnTerm = false survivingGroups.clearOnTerm = false
survivingGroups.clearOnKill = 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') .rejects.toThrow('bootstrap failed')
const survivingPid = new FakeTerminalSandbox() const survivingPid = new FakeTerminalSandbox()
survivingPid.sendError = new Error('bootstrap failed') survivingPid.sendError = new Error('bootstrap failed')
survivingPid.groups = [] survivingPid.groups = []
survivingPid.handle.settleOnSdkKill = false 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') .rejects.toThrow('bootstrap failed')
const waitFailed = new FakeTerminalSandbox() const waitFailed = new FakeTerminalSandbox()
waitFailed.handle.waitError = new Error('wait failed') waitFailed.handle.waitError = new Error('wait failed')
waitFailed.handle.settleOnSdkKill = false waitFailed.handle.settleOnSdkKill = false
waitFailed.handle.sdkKillError = new Error('kill failed') 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') .rejects.toThrow('wait failed')
expect(waitFailed.handle.sdkKills).toBe(1) expect(waitFailed.handle.sdkKills).toBe(1)
@@ -454,7 +464,7 @@ describe('E2B terminal allocation', () => {
cleanupFailed.handle.pid = 0 cleanupFailed.handle.pid = 0
cleanupFailed.handle.sdkKillError = new Error('kill transport failed') cleanupFailed.handle.sdkKillError = new Error('kill transport failed')
cleanupFailed.removeError = new Error('remove 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') .rejects.toThrow('invalid terminal pid 0')
const expiredDuringRollback = new FakeTerminalSandbox() const expiredDuringRollback = new FakeTerminalSandbox()
@@ -463,7 +473,7 @@ describe('E2B terminal allocation', () => {
expiredDuringRollback.handle.settleOnSdkKill = false expiredDuringRollback.handle.settleOnSdkKill = false
expiredDuringRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired') expiredDuringRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
expiredDuringRollback.removeError = 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') .rejects.toThrow('bootstrap failed before timeout')
expect(expiredDuringRollback.handle.sdkKills).toBe(1) 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.waitError = new Error('wait failed after timeout')
expiredBeforeSdkRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired') expiredBeforeSdkRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
expiredBeforeSdkRollback.handle.settleOnSdkKill = false 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') .rejects.toThrow('wait failed after timeout')
const missingDuringDisconnect = new FakeTerminalSandbox() const missingDuringDisconnect = new FakeTerminalSandbox()
missingDuringDisconnect.sendError = new Error('bootstrap failed before disconnect') missingDuringDisconnect.sendError = new Error('bootstrap failed before disconnect')
missingDuringDisconnect.handle.disconnectError = new SandboxNotFoundError('sandbox expired') 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') .rejects.toThrow('bootstrap failed before disconnect')
const failedDisconnect = new FakeTerminalSandbox() const failedDisconnect = new FakeTerminalSandbox()
failedDisconnect.sendError = new Error('bootstrap failed with disconnect failure') failedDisconnect.sendError = new Error('bootstrap failed with disconnect failure')
failedDisconnect.handle.disconnectError = new Error('disconnect transport failed') 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') .rejects.toThrow('bootstrap failed with disconnect failure')
}) })
it('propagates setup cancellation and provider failures', async () => { it('propagates setup cancellation and provider failures', async () => {
const aborted = new FakeTerminalSandbox() 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') .rejects.toThrow('stop')
const createFailed = new FakeTerminalSandbox() const createFailed = new FakeTerminalSandbox()
createFailed.createError = new Error('create failed') 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') .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 () => { it('bounds a missing bootstrap-output boundary by process exit or cancellation', async () => {
const exited = new FakeTerminalSandbox() const exited = new FakeTerminalSandbox()
exited.emitOutputMarker = false 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) }) await vi.waitFor(() => { expect(exited.inputs).toHaveLength(1) })
exited.handle.succeed(0) exited.handle.succeed(0)
await expect(exiting).rejects.toThrow('terminal exited before publishing its output boundary') await expect(exiting).rejects.toThrow('terminal exited before publishing its output boundary')
@@ -510,7 +520,7 @@ describe('E2B terminal allocation', () => {
const cancelled = new FakeTerminalSandbox() const cancelled = new FakeTerminalSandbox()
cancelled.emitOutputMarker = false cancelled.emitOutputMarker = false
const controller = new AbortController() const controller = new AbortController()
const cancelling = spawnE2BTerminal( const cancelling = testSpawn(
runtime(cancelled), runtime(cancelled),
spec({ signal: controller.signal }), spec({ signal: controller.signal }),
'/runtime/cancel-output-boundary', '/runtime/cancel-output-boundary',
@@ -525,7 +535,7 @@ describe('E2B terminal allocation', () => {
describe('E2B terminal lifecycle', () => { describe('E2B terminal lifecycle', () => {
it('aborts and joins in-flight terminal operations before cleanup', async () => { it('aborts and joins in-flight terminal operations before cleanup', async () => {
const fake = new FakeTerminalSandbox() 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<AbortSignal>() const writeStarted = Promise.withResolvers<AbortSignal>()
const inspectStarted = Promise.withResolvers<AbortSignal>() const inspectStarted = Promise.withResolvers<AbortSignal>()
const signalStarted = Promise.withResolvers<AbortSignal>() const signalStarted = Promise.withResolvers<AbortSignal>()
@@ -563,7 +573,7 @@ describe('E2B terminal lifecycle', () => {
it('maps ordinary exits, closes output, and reports an absent foreground after exit', async () => { it('maps ordinary exits, closes output, and reports an absent foreground after exit', async () => {
const fake = new FakeTerminalSandbox() const fake = new FakeTerminalSandbox()
fake.groups = [] fake.groups = []
const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/natural') const terminal = await testSpawn(runtime(fake), spec(), '/runtime/natural')
terminal.output.resume() terminal.output.resume()
const ended = once(terminal.output, 'end') const ended = once(terminal.output, 'end')
fake.handle.succeed(7) fake.handle.succeed(7)
@@ -583,7 +593,7 @@ describe('E2B terminal lifecycle', () => {
] as const)('classifies an unrequested command exit %i', async (exitCode, expected) => { ] as const)('classifies an unrequested command exit %i', async (exitCode, expected) => {
const fake = new FakeTerminalSandbox() const fake = new FakeTerminalSandbox()
fake.groups = [] 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) fake.handle.fail(exitCode)
await expect(terminal.done).resolves.toEqual(expected) await expect(terminal.done).resolves.toEqual(expected)
await terminal.terminate() await terminal.terminate()
@@ -593,7 +603,7 @@ describe('E2B terminal lifecycle', () => {
const fake = new FakeTerminalSandbox() const fake = new FakeTerminalSandbox()
fake.groups = [] fake.groups = []
fake.zombieGroups = [123] 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) fake.handle.succeed(0)
await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null }) 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 () => { it('treats a timeout-killed sandbox as quiescent during terminal cleanup', async () => {
const fake = new FakeTerminalSandbox() 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.sessionGroupsFailure = new SandboxNotFoundError('sandbox expired')
fake.handle.succeed(0) fake.handle.succeed(0)
@@ -618,7 +628,7 @@ describe('E2B terminal lifecycle', () => {
fake.groups = [] fake.groups = []
fake.handle.settleOnSdkKill = false fake.handle.settleOnSdkKill = false
fake.handle.sdkKillError = new SandboxNotFoundError('sandbox expired') 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() await terminal.terminate()
expect(fake.handle.sdkKills).toBe(1) expect(fake.handle.sdkKills).toBe(1)
@@ -629,7 +639,7 @@ describe('E2B terminal lifecycle', () => {
fake.groups = [] fake.groups = []
fake.handle.settleOnSdkKill = false fake.handle.settleOnSdkKill = false
fake.handle.sdkKillError = new Error('PTY kill transport failed') 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') await expect(terminal.terminate()).rejects.toThrow('PTY kill transport failed')
fake.handle.sdkKillError = undefined fake.handle.sdkKillError = undefined
@@ -643,7 +653,7 @@ describe('E2B terminal lifecycle', () => {
['propagates another failure', new Error('disconnect failed'), false], ['propagates another failure', new Error('disconnect failed'), false],
] as const)('%s while disconnecting a settled terminal', async (_label, failure, accepted) => { ] as const)('%s while disconnecting a settled terminal', async (_label, failure, accepted) => {
const fake = new FakeTerminalSandbox() 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.handle.disconnectError = failure
fake.groups = [] fake.groups = []
fake.handle.succeed(0) fake.handle.succeed(0)
@@ -655,7 +665,7 @@ describe('E2B terminal lifecycle', () => {
it('rejects killing the terminal shell and propagates live foreground failures', async () => { it('rejects killing the terminal shell and propagates live foreground failures', async () => {
const fake = new FakeTerminalSandbox() const fake = new FakeTerminalSandbox()
fake.foreground = '123\n' 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') await expect(terminal.signalForeground('SIGKILL')).rejects.toThrow('refusing to SIGKILL')
fake.foreground = 'invalid\n' fake.foreground = 'invalid\n'
await expect(terminal.inspectForeground()).rejects.toThrow('cannot resolve foreground') await expect(terminal.inspectForeground()).rejects.toThrow('cannot resolve foreground')
@@ -671,7 +681,7 @@ describe('E2B terminal lifecycle', () => {
const fake = new FakeTerminalSandbox() const fake = new FakeTerminalSandbox()
fake.groups = [123, 456] fake.groups = [123, 456]
fake.clearOnTerm = false 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() const terminating = terminal.terminate()
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
await terminating await terminating
@@ -682,7 +692,7 @@ describe('E2B terminal lifecycle', () => {
it('surfaces cleanup failures and allows a later retry', async () => { it('surfaces cleanup failures and allows a later retry', async () => {
const fake = new FakeTerminalSandbox() const fake = new FakeTerminalSandbox()
fake.groups = [1] 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') await expect(terminal.terminate()).rejects.toThrow('unsafe process group 1')
fake.groups = [] fake.groups = []
@@ -694,7 +704,7 @@ describe('E2B terminal lifecycle', () => {
it('propagates a process-group signalling transport failure before retry', async () => { it('propagates a process-group signalling transport failure before retry', async () => {
const fake = new FakeTerminalSandbox() const fake = new FakeTerminalSandbox()
fake.termFailure = new Error('signal transport failed') 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') await expect(terminal.terminate()).rejects.toThrow('signal transport failed')
fake.groups = [] fake.groups = []
@@ -704,7 +714,7 @@ describe('E2B terminal lifecycle', () => {
const alreadyExited = new FakeTerminalSandbox() const alreadyExited = new FakeTerminalSandbox()
alreadyExited.termFailure = commandError(1) 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() const tolerantTermination = tolerant.terminate()
await expect(tolerant.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) await expect(tolerant.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
await tolerantTermination await tolerantTermination
@@ -714,7 +724,7 @@ describe('E2B terminal lifecycle', () => {
const fake = new FakeTerminalSandbox() const fake = new FakeTerminalSandbox()
fake.groups = [] fake.groups = []
fake.removeError = new Error('private state already gone') 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', () => {}) terminal.output.on('error', () => {})
const cleanup = terminal.terminate() const cleanup = terminal.terminate()
await Promise.resolve() await Promise.resolve()
@@ -727,7 +737,7 @@ describe('E2B terminal lifecycle', () => {
const fake = new FakeTerminalSandbox() const fake = new FakeTerminalSandbox()
fake.groups = [] fake.groups = []
fake.handle.settleOnSdkKill = false 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', () => {}) terminal.output.on('error', () => {})
const cleanup = terminal.terminate() const cleanup = terminal.terminate()
while (fake.handle.sdkKills === 0) await new Promise(resolve => setTimeout(resolve, 0)) while (fake.handle.sdkKills === 0) await new Promise(resolve => setTimeout(resolve, 0))
@@ -741,20 +751,20 @@ describe('E2B terminal lifecycle', () => {
const survivor = new FakeTerminalSandbox() const survivor = new FakeTerminalSandbox()
survivor.clearOnTerm = false survivor.clearOnTerm = false
survivor.clearOnKill = 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') await expect(terminal.terminate()).rejects.toThrow('surviving process groups: 123')
const livePid = new FakeTerminalSandbox() const livePid = new FakeTerminalSandbox()
livePid.groups = [] livePid.groups = []
livePid.handle.settleOnSdkKill = false 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') await expect(live.terminate()).rejects.toThrow('surviving pid: 123')
livePid.handle.succeed(0) livePid.handle.succeed(0)
await live.done await live.done
const crashed = new FakeTerminalSandbox() const crashed = new FakeTerminalSandbox()
crashed.groups = [] 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') const outputError = once(failed.output, 'error')
crashed.handle.crash('transport gone') crashed.handle.crash('transport gone')
await expect(failed.done).rejects.toEqual('transport gone') await expect(failed.done).rejects.toEqual('transport gone')

View File

@@ -4,22 +4,27 @@
"rootDir": "src", "rootDir": "src",
"outDir": "lib/types" "outDir": "lib/types"
}, },
"include": ["src"], "include": [
"src"
],
"references": [ "references": [
{
"path": "../../../vendor/cosmokit"
},
{ {
"path": "../../../vendor/cordis" "path": "../../../vendor/cordis"
}, },
{ {
"path": "../e2b" "path": "../../../vendor/cosmokit"
}, },
{ {
"path": "../../subprocess/subprocess" "path": "../../subprocess/subprocess"
}, },
{ {
"path": "../../support/invariants" "path": "../../support/invariants"
},
{
"path": "../../util/timeout"
},
{
"path": "../e2b"
} }
] ]
} }

3
pnpm-lock.yaml generated
View File

@@ -3190,6 +3190,9 @@ importers:
'@deepseek-ai/dsh-subprocess': '@deepseek-ai/dsh-subprocess':
specifier: workspace:^ specifier: workspace:^
version: link:../../subprocess/subprocess version: link:../../subprocess/subprocess
'@deepseek-ai/dsh-timeout':
specifier: workspace:^
version: link:../../util/timeout
cordis: cordis:
specifier: ^4.0.0-rc.7 specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis version: link:../../../vendor/cordis