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

View File

@@ -7,6 +7,7 @@
import { randomUUID } from 'node:crypto'
import { posix } from 'node:path'
import { Context } from 'cordis'
import z from 'schemastery'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type {
SubprocessHandle,
@@ -16,30 +17,53 @@ import type {
} from '@deepseek-ai/dsh-subprocess'
import { e2bControlEnvs, quoteE2BShellArg } from '@deepseek-ai/dsh-e2b'
import { E2BSubprocessHandle } from './process.ts'
import { asError, signalOpts } from './remote.ts'
import { spawnE2BTerminal } from './terminal.ts'
function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
return signal === undefined ? {} : { signal }
/** Configuration for the E2B subprocess adapter. */
export interface Config {
/** Remote status/liveness poll cadence in milliseconds; each tick is one control-plane request. */
pollMs?: number
}
interface SchemaResolvedConfig extends Config {
pollMs: number
}
interface TerminalSetup {
done: Promise<void>
controller: AbortController
}
/** E2B command manager registered as `ctx.subprocess`. */
export class E2BSubprocessService extends SubprocessService {
static inject = ['e2b']
static Config: z<Config> = z.object({
pollMs: z.number().default(20),
})
private readonly live = new Set<E2BSubprocessHandle>()
private readonly terminals = new Set<SubprocessTerminalHandle>()
private readonly terminalSetups = new Map<Promise<void>, AbortController>()
private readonly terminalSetups = new Set<TerminalSetup>()
private readonly pollMs: number
private disposing = false
/** Create the E2B subprocess service and bind its disposal policy. */
constructor(ctx: Context) {
constructor(ctx: Context, config: Config) {
super(ctx)
// Schemastery fills pollMs before construction; the type does not encode that step.
const { pollMs } = config as SchemaResolvedConfig
if (!Number.isSafeInteger(pollMs) || pollMs <= 0) {
throw new Error('subprocess-e2b: pollMs must be a positive safe integer')
}
this.pollMs = pollMs
ctx.effect(() => async () => {
this.disposing = true
for (const controller of this.terminalSetups.values()) {
controller.abort(new Error('subprocess-e2b: service disposed during terminal setup'))
for (const setup of this.terminalSetups) {
setup.controller.abort(new Error('subprocess-e2b: service disposed during terminal setup'))
}
await Promise.all([...this.terminalSetups.keys()])
await Promise.all([...this.terminalSetups].map(setup => setup.done))
const handles = [...this.live]
const terminals = [...this.terminals]
const pending: Promise<unknown>[] = []
@@ -54,9 +78,11 @@ export class E2BSubprocessService extends SubprocessService {
pending.push(terminal.terminate().then(() => { this.terminals.delete(terminal) }))
}
const outcomes = await Promise.allSettled(pending)
for (const outcome of outcomes) {
if (outcome.status === 'rejected') throw outcome.reason
}
const failures = outcomes.flatMap<unknown>(outcome => outcome.status === 'rejected'
? [outcome.reason as unknown]
: [])
if (failures.length === 1) throw asError(failures[0])
if (failures.length > 1) throw new AggregateError(failures, 'subprocess-e2b: teardown failed')
}, 'e2b subprocess teardown')
}
@@ -77,6 +103,11 @@ export class E2BSubprocessService extends SubprocessService {
signal?.throwIfAborted()
return command
}
if (command.includes('/')) {
throw new Error(
`subprocess-e2b: command ${JSON.stringify(command)} is a relative path; use an absolute path or a bare PATH name`,
)
}
const path = env?.PATH
const prefix = path === undefined ? '' : `PATH=${quoteE2BShellArg(path)} `
const result = await sandbox.commands.run(
@@ -88,6 +119,7 @@ export class E2BSubprocessService extends SubprocessService {
if (executable.includes('\n') || (!posix.isAbsolute(executable) && !executable.includes('/'))) {
throw new Error(`subprocess-e2b: executable ${JSON.stringify(command)} did not resolve to one absolute path`)
}
// A relative result comes from a relative PATH entry; the lookup ran with the shared cwd.
return posix.resolve(this.ctx.e2b.cwd, executable)
}
@@ -98,14 +130,11 @@ export class E2BSubprocessService extends SubprocessService {
if (program === undefined || program.length === 0) {
throw new Error('invalid argv: expected a non-empty program name at argv[0]')
}
if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0) {
throw new Error('subprocess-e2b: graceMs must be a positive finite number')
}
if (spec.signal?.aborted === true) {
throw new Error(`aborted before spawn: ${String(spec.signal.reason)}`)
}
const stateDir = posix.join(this.ctx.e2b.runtimeRoot, 'processes', randomUUID())
const handle = new E2BSubprocessHandle(this.ctx.e2b, spec, stateDir)
const handle = new E2BSubprocessHandle(this.ctx.e2b, spec, stateDir, this.pollMs)
this.live.add(handle)
const release = async (): Promise<void> => {
await handle.waitForExit()
@@ -124,24 +153,20 @@ export class E2BSubprocessService extends SubprocessService {
if (program === undefined || program.length === 0) {
throw new Error('subprocess-e2b: terminal argv must contain a program')
}
for (const [name, value] of [['rows', spec.rows], ['cols', spec.cols], ['graceMs', spec.graceMs]] as const) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`subprocess-e2b: terminal ${name} must be a positive safe integer`)
}
}
spec.signal?.throwIfAborted()
const stateDir = posix.join(this.ctx.e2b.runtimeRoot, 'terminals', randomUUID())
const setup = Promise.withResolvers<void>()
const setupController = new AbortController()
const done = Promise.withResolvers<void>()
const setup: TerminalSetup = { done: done.promise, controller: new AbortController() }
const setupSignal = spec.signal === undefined
? setupController.signal
: AbortSignal.any([spec.signal, setupController.signal])
this.terminalSetups.set(setup.promise, setupController)
? setup.controller.signal
: AbortSignal.any([spec.signal, setup.controller.signal])
this.terminalSetups.add(setup)
try {
const terminal = await spawnE2BTerminal(
this.ctx.e2b,
{ ...spec, signal: setupSignal },
stateDir,
this.pollMs,
)
this.terminals.add(terminal)
// oxlint-disable-next-line typescript/no-unnecessary-condition -- Remote allocation yields to disposal.
@@ -159,8 +184,8 @@ export class E2BSubprocessService extends SubprocessService {
})
return terminal
} finally {
this.terminalSetups.delete(setup.promise)
setup.resolve()
this.terminalSetups.delete(setup)
done.resolve()
}
}
}

View File

@@ -21,8 +21,8 @@ import type {
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import { bootstrapEnvironment, readRemoteEnvironment, serializeRemoteEnvironment } from './environment.ts'
import { E2BBase64Decoder, E2B_OUTPUT_COMPLETE_FRAME, E2BOutputReader } from './output.ts'
import { asError, commandOpts, signalRemoteGroups, waitTick } from './remote.ts'
const GROUP_POLL_MS = 20
const OUTPUT_ENCODER_SOURCE = [
'(async () => {',
' for await (const chunk of process.stdin) {',
@@ -48,10 +48,6 @@ function isValidProcessId(value: number): boolean {
return Number.isSafeInteger(value) && value > 0
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
class DeferredStdin extends Writable {
constructor(private readonly ready: Promise<CommandHandle>) {
super({ decodeStrings: false })
@@ -142,28 +138,6 @@ function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string {
return bootstrap
}
function commandOpts(
envs: Record<string, string>,
signal: AbortSignal | undefined,
): { envs: Record<string, string>; signal?: AbortSignal } {
return { envs: e2bControlEnvs(envs), ...(signal === undefined ? {} : { signal }) }
}
function waitTick(signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted === true) return Promise.resolve(false)
return new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve(true)
}, GROUP_POLL_MS)
const onAbort = (): void => {
clearTimeout(timer)
resolve(false)
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
const WAIT_ABORTED = Symbol('wait aborted')
function waitWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T | typeof WAIT_ABORTED> {
@@ -194,6 +168,8 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private readonly stdoutDecoder = new E2BBase64Decoder()
private readonly stderrDecoder = new E2BBase64Decoder()
private readonly terminationController = new AbortController()
/** Releases output waits that survive the command outcome, so blocked SDK callbacks settle. */
private readonly outputReleased = new AbortController()
private readonly stdoutReader: E2BOutputReader | undefined
private readonly stderrReader: E2BOutputReader | undefined
private readonly paths: RemotePaths
@@ -212,11 +188,13 @@ export class E2BSubprocessHandle implements SubprocessHandle {
* @param runtime - Shared E2B sandbox owner.
* @param spec - Fully resolved subprocess request.
* @param stateDir - Remote directory retaining process identity, status, and valid spills.
* @param pollMs - Remote status/liveness poll cadence.
*/
constructor(
private readonly runtime: E2BSandboxService,
private readonly spec: SubprocessSpawnSpec,
readonly stateDir: string,
private readonly pollMs = 20,
) {
this.paths = {
pid: posix.join(stateDir, 'pid'),
@@ -318,7 +296,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
while (await this.groupAlive(sandbox, processGroupId, signal)) {
this.throwTerminationFailure()
if (!await waitTick(signal)) return false
if (!await waitTick(this.pollMs, signal)) return false
}
this.throwTerminationFailure()
if (signal?.aborted === true) return false
@@ -483,19 +461,21 @@ export class E2BSubprocessHandle implements SubprocessHandle {
await new Promise<void>((resolve, reject) => {
const onDrain = (): void => { cleanup(); resolve() }
const onClose = (): void => { cleanup(); resolve() }
const onTermination = (): void => { cleanup(); resolve() }
const onRelease = (): void => { cleanup(); resolve() }
const onError = (error: Error): void => { cleanup(); reject(error) }
const cleanup = (): void => {
target.removeListener('drain', onDrain)
target.removeListener('close', onClose)
target.removeListener('error', onError)
this.terminationController.signal.removeEventListener('abort', onTermination)
this.terminationController.signal.removeEventListener('abort', onRelease)
this.outputReleased.signal.removeEventListener('abort', onRelease)
}
target.once('drain', onDrain)
target.once('close', onClose)
target.once('error', onError)
this.terminationController.signal.addEventListener('abort', onTermination, { once: true })
if (this.terminationController.signal.aborted) onTermination()
this.terminationController.signal.addEventListener('abort', onRelease, { once: true })
this.outputReleased.signal.addEventListener('abort', onRelease, { once: true })
if (this.terminationController.signal.aborted || this.outputReleased.signal.aborted) onRelease()
})
}
@@ -514,9 +494,14 @@ export class E2BSubprocessHandle implements SubprocessHandle {
if (!/^[1-9][0-9]*$/.test(value) || !Number.isSafeInteger(pid)) {
throw new Error(`subprocess-e2b: remote wrapper published invalid process-group id ${JSON.stringify(value)}`)
}
// A same-UID sandbox process can rewrite this file; refuse ids whose
// negative form addresses every process (`kill -- -1`) or init's group.
if (pid <= 1) {
throw new Error(`subprocess-e2b: unsafe published process-group id ${pid}`)
}
return pid
}
const settled = await Promise.race([commandSettled, waitTick().then(() => false)])
const settled = await Promise.race([commandSettled, waitTick(this.pollMs).then(() => false)])
if (settled) throw new Error('subprocess-e2b: remote command exited before publishing its process-group id')
}
}
@@ -545,13 +530,16 @@ export class E2BSubprocessHandle implements SubprocessHandle {
this.outputDrainExpired = true
this.stdoutReader?.invalidateSpill()
this.stderrReader?.invalidateSpill()
// Release inherited-output waits so a callback blocked on host
// backpressure cannot keep the disconnected SDK settlement pending.
this.outputReleased.abort(new Error('subprocess-e2b: output drain grace expired'))
await handle.disconnect()
return { exitCode, signal: null }
}
if (completed !== undefined) return this.commandOutcome(completed)
// TODO(e2b-status-watch): Replace collect/inherit control-plane polling
// when E2B can observe direct-command exit independently of descendant-held output.
completed = await Promise.race([settlement, waitTick().then(() => undefined)])
completed = await Promise.race([settlement, waitTick(this.pollMs).then(() => undefined)])
}
}
@@ -622,7 +610,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private async terminateGroup(sandbox: Sandbox, handle: CommandHandle, processGroupId: number): Promise<void> {
this.terminationSignal = 'SIGTERM'
try {
await this.signalGroup(sandbox, processGroupId, 'TERM')
await signalRemoteGroups(sandbox, this.controlEnvs, [processGroupId], 'TERM')
if (await this.waitForGroupExit(sandbox, processGroupId)) {
this.markQuiescent()
return
@@ -637,7 +625,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private async forceKillGroup(sandbox: Sandbox, handle: CommandHandle, processGroupId: number): Promise<void> {
try {
await this.signalGroup(sandbox, processGroupId, 'KILL')
await signalRemoteGroups(sandbox, this.controlEnvs, [processGroupId], 'KILL')
} catch (_processGroupKillFailure) {
// SDK kill and the final liveness probe remain independent cleanup paths.
}
@@ -654,7 +642,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
const deadline = Date.now() + this.spec.graceMs
while (await this.groupAlive(sandbox, processGroupId)) {
if (Date.now() >= deadline) return false
await waitTick()
await waitTick(this.pollMs)
}
return true
}
@@ -663,21 +651,6 @@ export class E2BSubprocessHandle implements SubprocessHandle {
if (this.terminationFailure !== undefined) throw this.terminationFailure
}
private async signalGroup(sandbox: Sandbox, pid: number, signal: 'TERM' | 'KILL'): Promise<boolean> {
// TODO(e2b-pgid-identity): Prefer an atomic identity-bound group signal if E2B adds one;
// a userspace identity precheck cannot close the numeric-PGID reuse race.
try {
await sandbox.commands.run(
`kill -${signal} -- -${pid}`,
commandOpts(this.controlEnvs, undefined),
)
return true
} catch (error: unknown) {
if (error instanceof CommandExitError || error instanceof SandboxNotFoundError) return false
throw error
}
}
private async groupAlive(sandbox: Sandbox, pid: number, signal?: AbortSignal): Promise<boolean> {
const result = await sandbox.commands.run(
`set -o pipefail; ps -eo pgid=,stat= | awk '$1 == ${pid} && $2 !~ /^[ZXx]/ { live=1 } END { if (live) print "live" }'`,

View File

@@ -0,0 +1,97 @@
/**
* Shared remote-control helpers for the E2B subprocess adapter: SDK option
* shaping, poll ticks, and the one tolerant process-group signal used by both
* the ordinary-process and terminal teardown ladders.
*/
import { CommandExitError, e2bControlEnvs, SandboxNotFoundError } from '@deepseek-ai/dsh-e2b'
import type { Sandbox } from '@deepseek-ai/dsh-e2b'
/**
* Normalize an unknown rejection into an Error.
* @param error - Any thrown or rejected value.
* @returns The value itself when already an Error, else a stringified wrapper.
*/
export function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
/**
* Shape the optional-signal SDK options object.
* @param signal - Optional cancellation for one SDK request.
* @returns An options fragment that omits an undefined signal.
*/
export function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
return signal === undefined ? {} : { signal }
}
/**
* Shape control-shell command options with the isolated HOME override.
* @param envs - Explicit environment entries for the control command.
* @param signal - Optional cancellation for the SDK request.
* @returns Options for `sandbox.commands.run` control invocations.
*/
export function commandOpts(
envs: Record<string, string>,
signal?: AbortSignal,
): { envs: Record<string, string>; signal?: AbortSignal } {
return { envs: e2bControlEnvs(envs), ...signalOpts(signal) }
}
/**
* Resolve after one duration.
* @param ms - Milliseconds to wait.
* @returns Settles after the timeout.
*/
export function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* Wait one poll interval or until the signal aborts.
* @param pollMs - Poll cadence in milliseconds.
* @param signal - Optional abort that ends the wait early.
* @returns `true` after a full tick, `false` when aborted first.
*/
export function waitTick(pollMs: number, signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted === true) return Promise.resolve(false)
return new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve(true)
}, pollMs)
const onAbort = (): void => {
clearTimeout(timer)
resolve(false)
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
/**
* Signal remote process groups, tolerating the shared teardown outcomes: a
* nonzero `kill` (groups already gone) and a disappeared sandbox. Both the
* pgid-keyed process ladder and the sid-keyed terminal ladder deliver signals
* through this single tolerance so they cannot drift apart.
* @param sandbox - Live SDK handle.
* @param envs - Control-shell environment entries.
* @param groups - Positive process-group ids to signal.
* @param signal - `TERM` or `KILL`.
*/
export async function signalRemoteGroups(
sandbox: Sandbox,
envs: Record<string, string>,
groups: readonly number[],
signal: 'TERM' | 'KILL',
): Promise<void> {
// TODO(e2b-pgid-identity): Prefer an atomic identity-bound group signal if E2B adds one;
// a userspace identity precheck cannot close the numeric-PGID reuse race.
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
}
}

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