fix(e2b): address review round on cadence config, disposal, and SDK edge cases

- subprocess-e2b: the 20 ms remote poll cadence becomes a validated pollMs
  Config field (each tick is one control-plane request); the README documents
  the latency-versus-request-count trade.
- subprocess-e2b: extract src/remote.ts owning asError, signalOpts,
  commandOpts, delay, waitTick, and one tolerant signalRemoteGroups shared by
  the pgid-keyed process ladder and sid-keyed terminal ladder, so the two
  teardown paths keep identical error tolerance.
- subprocess-e2b: service disposal aggregates sibling cleanup failures into
  one AggregateError instead of discarding all but the first.
- subprocess-e2b: waitForProcessGroupId refuses published group ids <= 1, so
  a same-UID rewrite of the pid file cannot aim termination at kill -- -1;
  README documents the same-UID control-state limitation.
- subprocess-e2b: drain-grace expiry now releases an inherited-output E2B
  callback blocked on host backpressure before disconnecting, so the SDK
  settlement cannot stay pinned behind an unread host stream.
- subprocess-e2b: spawn/spawnTerminal stop validating typed spec fields
  (trust-TypeScript rule; pty-local validates its config before specs exist);
  resolveExecutable rejects separator-containing relative paths per the seam
  contract; terminal setups tracked as a Set of records.
- subprocess-e2b: PTY output push-without-backpressure is a documented
  contract (flowing consumer folds bytes; paused consumer buffers).
- fs-e2b: streamText normalizes the pinned SDK's empty-file '' return into an
  empty stream instead of throwing on getReader().
- e2b overlays: comment the one-world cwd invariant across e2b.cwd,
  workspaceRoot, and bash-local's implicit default workdir.
This commit is contained in:
Tianyi Cui
2026-08-02 15:38:00 +08:00
parent e6b3afbee9
commit f5866fc202
15 changed files with 339 additions and 139 deletions
+17 -47
View File
@@ -25,8 +25,7 @@ import {
readRemoteEnvironment,
serializeRemoteEnvironment,
} from './environment.ts'
const POLL_MS = 20
import { asError, commandOpts, delay, signalOpts, signalRemoteGroups } from './remote.ts'
const TERMINAL_RUNNER_SOURCE = [
'#!/bin/bash',
@@ -52,21 +51,6 @@ interface TerminalPaths {
outputMarker: string
}
function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
return signal === undefined ? {} : { signal }
}
function commandOpts(
envs: Record<string, string>,
signal?: AbortSignal,
): { envs: Record<string, string>; signal?: AbortSignal } {
return { envs: e2bControlEnvs(envs), ...signalOpts(signal) }
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
class BootstrapOutputFilter {
readonly ready: Promise<void>
@@ -134,10 +118,6 @@ async function waitForBootstrapOutput(
})
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
function parsePositiveId(value: string, message: string): number {
const raw = value.trim()
const id = Number(raw)
@@ -193,27 +173,12 @@ async function sessionProcessGroups(
return [...groups]
}
async function signalGroups(
sandbox: Sandbox,
groups: number[],
signal: 'TERM' | 'KILL',
envs: Record<string, string>,
): Promise<void> {
try {
await sandbox.commands.run(
`kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`,
commandOpts(envs),
)
} catch (error: unknown) {
if (!(error instanceof CommandExitError) && !(error instanceof SandboxNotFoundError)) throw error
}
}
async function awaitSessionEmpty(
sandbox: Sandbox,
sessionId: number,
envs: Record<string, string>,
graceMs: number,
pollMs: number,
kill = false,
): Promise<number[]> {
const deadline = Date.now() + graceMs
@@ -221,12 +186,12 @@ async function awaitSessionEmpty(
const groups = await sessionProcessGroups(sandbox, sessionId, envs)
if (groups.length === 0) return groups
if (kill) {
await signalGroups(sandbox, groups, 'KILL', envs)
await signalRemoteGroups(sandbox, envs, groups, 'KILL')
if (Date.now() >= deadline) return await sessionProcessGroups(sandbox, sessionId, envs)
} else if (Date.now() >= deadline) {
return groups
}
await delay(Math.min(POLL_MS, Math.max(1, deadline - Date.now())))
await delay(Math.min(pollMs, Math.max(1, deadline - Date.now())))
}
}
@@ -236,6 +201,7 @@ async function rollbackUnpublishedTerminal(
completion: Promise<CommandResult>,
envs: Record<string, string>,
graceMs: number,
pollMs: number,
): Promise<void> {
let topLevelExited = false
void completion.then(
@@ -256,11 +222,11 @@ async function rollbackUnpublishedTerminal(
try {
let groups = await sessionProcessGroups(sandbox, sessionId, envs)
if (groups.length > 0) {
await signalGroups(sandbox, groups, 'TERM', envs)
groups = await awaitSessionEmpty(sandbox, sessionId, envs, graceMs)
await signalRemoteGroups(sandbox, envs, groups, 'TERM')
groups = await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, pollMs)
}
if (groups.length > 0) {
await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, true)
await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, pollMs, true)
}
} catch (error: unknown) {
attemptFailures.push(asError(error))
@@ -280,7 +246,7 @@ async function rollbackUnpublishedTerminal(
const proofFailures: Error[] = []
if (sessionId !== undefined) {
try {
const groups = await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, true)
const groups = await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, pollMs, true)
if (groups.length > 0) {
proofFailures.push(new Error(
`subprocess-e2b: terminal setup rollback failed; surviving process groups: ${groups.join(', ')}`,
@@ -328,6 +294,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
private readonly controlEnvs: Record<string, string>,
private readonly stateDir: string,
private readonly graceMs: number,
private readonly pollMs: number,
) {
this.pid = handle.pid
this.done = this.waitForCommand()
@@ -441,8 +408,8 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
let groups = await sessionProcessGroups(this.sandbox, this.sessionId, this.controlEnvs)
if (groups.length > 0) {
this.terminationSignal = 'SIGTERM'
await signalGroups(this.sandbox, groups, 'TERM', this.controlEnvs)
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.controlEnvs, this.graceMs)
await signalRemoteGroups(this.sandbox, this.controlEnvs, groups, 'TERM')
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.controlEnvs, this.graceMs, this.pollMs)
}
if (groups.length === 0 && !this.topLevelExited) {
await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
@@ -457,7 +424,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
throw error
}
}
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.controlEnvs, this.graceMs, true)
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.controlEnvs, this.graceMs, this.pollMs, true)
if (!this.topLevelExited) await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
}
if (groups.length > 0) {
@@ -485,12 +452,14 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
* @param runtime - Shared E2B sandbox owner.
* @param spec - Fully specified terminal-process request.
* @param stateDir - Private remote directory for one startup transaction.
* @param pollMs - Remote session liveness poll cadence.
* @returns The live subprocess terminal handle.
*/
export async function spawnE2BTerminal(
runtime: E2BSandboxService,
spec: SubprocessTerminalSpawnSpec,
stateDir: string,
pollMs = 20,
): Promise<E2BTerminalHandle> {
const sandbox = await runtime.getSandbox()
spec.signal?.throwIfAborted()
@@ -555,6 +524,7 @@ export async function spawnE2BTerminal(
controlEnvs,
stateDir,
spec.graceMs,
pollMs,
)
} catch (error: unknown) {
output.destroy()
@@ -565,7 +535,7 @@ export async function spawnE2BTerminal(
if (!terminalQuiescent && handle !== undefined) {
try {
if (completion === undefined) await handle.kill()
else await rollbackUnpublishedTerminal(sandbox, handle, completion, controlEnvs, spec.graceMs)
else await rollbackUnpublishedTerminal(sandbox, handle, completion, controlEnvs, spec.graceMs, pollMs)
terminalQuiescent = true
} catch (cleanupError: unknown) {
if (cleanupError instanceof SandboxNotFoundError) terminalQuiescent = true