fix(e2b): harden remote process lifecycle
This commit is contained in:
18 files changed
+586
-153
No files matched your search
@@ -3,6 +3,64 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import type { SubprocessOutputRead, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
const BASE64_TEXT = /^[A-Za-z0-9+/]+={0,2}$/u
|
||||
|
||||
/** Reserved non-base64 frame proving that one remote encoder reached clean EOF. */
|
||||
export const E2B_OUTPUT_COMPLETE_FRAME = '!dsh-e2b-output-complete!'
|
||||
|
||||
/** Incrementally decode newline-delimited base64 frames emitted by one remote encoder. */
|
||||
export class E2BBase64Decoder {
|
||||
private pending = ''
|
||||
private complete = false
|
||||
|
||||
/**
|
||||
* Decode every complete newline-delimited frame in one arbitrarily split SDK callback.
|
||||
* @param text - ASCII base64 frames from E2B's decoded callback.
|
||||
* @returns the complete raw bytes made available by this callback.
|
||||
*/
|
||||
push(text: string): Buffer {
|
||||
if (text.length === 0) return Buffer.alloc(0)
|
||||
this.pending += text
|
||||
const decoded: Buffer[] = []
|
||||
for (;;) {
|
||||
const boundary = this.pending.indexOf('\n')
|
||||
if (boundary < 0) break
|
||||
const frame = this.pending.slice(0, boundary)
|
||||
this.pending = this.pending.slice(boundary + 1)
|
||||
if (frame === E2B_OUTPUT_COMPLETE_FRAME) {
|
||||
if (this.complete) throw new Error('subprocess-e2b: duplicate output transport completion')
|
||||
this.complete = true
|
||||
continue
|
||||
}
|
||||
if (this.complete) throw new Error('subprocess-e2b: output transport continued after completion')
|
||||
if (!BASE64_TEXT.test(frame)) {
|
||||
throw new Error('subprocess-e2b: invalid base64 output transport')
|
||||
}
|
||||
const bytes = Buffer.from(frame, 'base64')
|
||||
if (bytes.toString('base64') !== frame) {
|
||||
throw new Error('subprocess-e2b: invalid base64 output transport')
|
||||
}
|
||||
decoded.push(bytes)
|
||||
}
|
||||
return Buffer.concat(decoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate clean encoder completion, or discard an interrupted trailing frame after requested termination.
|
||||
* @param requireComplete - Whether natural completion requires the reserved EOF frame.
|
||||
*/
|
||||
finish(requireComplete = true): void {
|
||||
if (!requireComplete) {
|
||||
this.pending = ''
|
||||
return
|
||||
}
|
||||
if (this.pending.length > 0) {
|
||||
throw new Error('subprocess-e2b: truncated base64 output transport')
|
||||
}
|
||||
if (!this.complete) throw new Error('subprocess-e2b: incomplete output transport')
|
||||
}
|
||||
}
|
||||
|
||||
/** Offset reader used for one collect-mode E2B stream. */
|
||||
export class E2BOutputReader implements SubprocessOutputReader {
|
||||
private chunks: Buffer[] = []
|
||||
@@ -27,12 +85,12 @@ export class E2BOutputReader implements SubprocessOutputReader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one decoded SDK output event.
|
||||
* @param text - Event text delivered by E2B.
|
||||
* Append one byte-faithful decoded transport event.
|
||||
* @param bytes - Raw command bytes recovered from the ASCII SDK transport.
|
||||
*/
|
||||
push(text: string): void {
|
||||
if (text.length === 0) return
|
||||
const chunk = Buffer.from(text)
|
||||
push(bytes: Uint8Array): void {
|
||||
if (bytes.length === 0) return
|
||||
const chunk = Buffer.from(bytes)
|
||||
this.totalBytes += chunk.length
|
||||
this.chunks.push(chunk)
|
||||
this.retainedBytes += chunk.length
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
quoteE2BShellArg,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import type { CommandHandle, CommandResult, Sandbox } from '@deepseek-ai/dsh-e2b'
|
||||
import { SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess'
|
||||
import type {
|
||||
SubprocessCollect,
|
||||
SubprocessHandle,
|
||||
@@ -16,9 +17,21 @@ import type {
|
||||
SubprocessSpawnSpec,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
|
||||
import { E2BOutputReader } from './output.ts'
|
||||
import { E2BBase64Decoder, E2B_OUTPUT_COMPLETE_FRAME, E2BOutputReader } from './output.ts'
|
||||
|
||||
const GROUP_POLL_MS = 20
|
||||
const OUTPUT_ENCODER_SOURCE = [
|
||||
'(async () => {',
|
||||
' for await (const chunk of process.stdin) {',
|
||||
" if (!process.stdout.write(chunk.toString('base64') + '\\n')) {",
|
||||
" await new Promise(resolve => process.stdout.once('drain', resolve))",
|
||||
' }',
|
||||
' }',
|
||||
` if (!process.stdout.write(${JSON.stringify(E2B_OUTPUT_COMPLETE_FRAME)} + '\\n')) {`,
|
||||
" await new Promise(resolve => process.stdout.once('drain', resolve))",
|
||||
' }',
|
||||
'})().catch(() => { process.exitCode = 1 })',
|
||||
].join('\n')
|
||||
|
||||
function isCollect(mode: SubprocessOutputMode): mode is SubprocessCollect {
|
||||
return mode !== 'pipe' && mode !== 'inherit'
|
||||
@@ -60,40 +73,65 @@ interface RemotePaths {
|
||||
stderr: string
|
||||
}
|
||||
|
||||
function explicitEnvironment(env: Readonly<Record<string, string>> | undefined): string {
|
||||
return Object.entries(env ?? {})
|
||||
.map(([name, value]) => `${name}=${value}\0`)
|
||||
.join('')
|
||||
function remoteEnvironment(raw: string, explicit: Readonly<Record<string, string>> | undefined): string {
|
||||
const environment = new Map<string, string>()
|
||||
for (const entry of raw.split('\0')) {
|
||||
if (entry.length === 0) continue
|
||||
const separator = entry.indexOf('=')
|
||||
if (separator <= 0) continue
|
||||
const name = entry.slice(0, separator)
|
||||
if (name.startsWith('DSH_') || SENSITIVE_ENV_PATTERN.test(name)) continue
|
||||
environment.set(name, entry.slice(separator + 1))
|
||||
}
|
||||
for (const [name, value] of Object.entries(explicit ?? {})) environment.set(name, value)
|
||||
return [...environment].map(([name, value]) => `${name}=${value}\0`).join('')
|
||||
}
|
||||
|
||||
function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string {
|
||||
const encoder = `"$dsh_e2b_env_bin" -i "$dsh_e2b_node" -e ${quoteE2BShellArg(OUTPUT_ENCODER_SOURCE)}`
|
||||
const stdoutRedirect = hasSpill(spec.stdio.stdout)
|
||||
? `> >(tee --output-error=warn-nopipe >(head -c ${spec.stdio.stdout.spill.maxBytes} > ${quoteE2BShellArg(paths.stdout)}))`
|
||||
: ''
|
||||
? `> >("$dsh_e2b_tee" --output-error=warn-nopipe >("$dsh_e2b_head" -c ${spec.stdio.stdout.spill.maxBytes} > ${quoteE2BShellArg(paths.stdout)}) | ${encoder} 2>/dev/null)`
|
||||
: `> >(${encoder} 2>/dev/null)`
|
||||
const stderrRedirect = hasSpill(spec.stdio.stderr)
|
||||
? `2> >(tee --output-error=warn-nopipe >(head -c ${spec.stdio.stderr.spill.maxBytes} > ${quoteE2BShellArg(paths.stderr)}) >&2)`
|
||||
: ''
|
||||
? `2> >("$dsh_e2b_tee" --output-error=warn-nopipe >("$dsh_e2b_head" -c ${spec.stdio.stderr.spill.maxBytes} > ${quoteE2BShellArg(paths.stderr)}) | ${encoder} >&2 2>/dev/null)`
|
||||
: `2> >(${encoder} >&2 2>/dev/null)`
|
||||
const inner = [
|
||||
'set +e',
|
||||
'umask 077',
|
||||
'dsh_e2b_pgid="$(ps -o pgid= -p "$$" | tr -d " ")"',
|
||||
'dsh_e2b_env_bin=$1',
|
||||
'dsh_e2b_node=$2',
|
||||
'dsh_e2b_ps=$3',
|
||||
'dsh_e2b_tr=$4',
|
||||
'dsh_e2b_tee=$5',
|
||||
'dsh_e2b_head=$6',
|
||||
'shift 6',
|
||||
'dsh_e2b_pgid="$("$dsh_e2b_ps" -o pgid= -p "$$" | "$dsh_e2b_tr" -d " ")"',
|
||||
`printf '%s\\n' "$dsh_e2b_pgid" > ${quoteE2BShellArg(paths.pid)}`,
|
||||
`mapfile -d '' -t dsh_e2b_explicit < ${quoteE2BShellArg(paths.environment)}`,
|
||||
`mapfile -d '' -t dsh_e2b_env < ${quoteE2BShellArg(paths.environment)}`,
|
||||
`: > ${quoteE2BShellArg(paths.environment)}`,
|
||||
'dsh_e2b_env=()',
|
||||
"while IFS= read -r -d '' dsh_e2b_entry; do",
|
||||
' dsh_e2b_name="${dsh_e2b_entry%%=*}"',
|
||||
' case "${dsh_e2b_name^^}" in DSH_*|*KEY*|*SECRET*|*TOKEN*) continue ;; esac',
|
||||
' dsh_e2b_env+=("$dsh_e2b_entry")',
|
||||
'done < <(env -0)',
|
||||
`env -i "\${dsh_e2b_env[@]}" "\${dsh_e2b_explicit[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(),
|
||||
`"$dsh_e2b_env_bin" -i "\${dsh_e2b_env[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(),
|
||||
'dsh_e2b_status=$?',
|
||||
'wait',
|
||||
`printf '%s\\n' "$dsh_e2b_status" > ${quoteE2BShellArg(paths.status)}`,
|
||||
'exit "$dsh_e2b_status"',
|
||||
].join('\n')
|
||||
const argv = spec.argv.map(quoteE2BShellArg).join(' ')
|
||||
return `exec setsid --wait -- bash -c ${quoteE2BShellArg(inner)} dsh-e2b ${argv}`
|
||||
const bootstrap = [
|
||||
`mapfile -d '' -t dsh_e2b_env < ${quoteE2BShellArg(paths.environment)}`,
|
||||
'dsh_e2b_env_bin="$(command -v env)"',
|
||||
'dsh_e2b_setsid="$(command -v setsid)"',
|
||||
'dsh_e2b_bash="$(command -v bash)"',
|
||||
'dsh_e2b_node="$(command -v node)"',
|
||||
'dsh_e2b_ps="$(command -v ps)"',
|
||||
'dsh_e2b_tr="$(command -v tr)"',
|
||||
'dsh_e2b_tee="$(command -v tee)"',
|
||||
'dsh_e2b_head="$(command -v head)"',
|
||||
'for dsh_e2b_tool in "$dsh_e2b_env_bin" "$dsh_e2b_setsid" "$dsh_e2b_bash" "$dsh_e2b_node" "$dsh_e2b_ps" "$dsh_e2b_tr" "$dsh_e2b_tee" "$dsh_e2b_head"; do',
|
||||
' [[ "$dsh_e2b_tool" == /* && -x "$dsh_e2b_tool" ]] || exit 125',
|
||||
'done',
|
||||
`exec "$dsh_e2b_env_bin" -i "\${dsh_e2b_env[@]}" "$dsh_e2b_setsid" --wait -- "$dsh_e2b_bash" -c ${quoteE2BShellArg(inner)} dsh-e2b "$dsh_e2b_env_bin" "$dsh_e2b_node" "$dsh_e2b_ps" "$dsh_e2b_tr" "$dsh_e2b_tee" "$dsh_e2b_head" ${argv}`,
|
||||
].join('\n')
|
||||
return bootstrap
|
||||
}
|
||||
|
||||
function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
|
||||
@@ -128,10 +166,14 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
readonly done: Promise<SubprocessOutcome>
|
||||
|
||||
private readonly readyState = Promise.withResolvers<CommandHandle>()
|
||||
private readonly stdoutDecoder = new E2BBase64Decoder()
|
||||
private readonly stderrDecoder = new E2BBase64Decoder()
|
||||
private readonly stdoutReader: E2BOutputReader | undefined
|
||||
private readonly stderrReader: E2BOutputReader | undefined
|
||||
private readonly paths: RemotePaths
|
||||
private remotePid = -1
|
||||
private commandHandle: CommandHandle | undefined
|
||||
private outputTransportError: Error | undefined
|
||||
private terminationRequested = false
|
||||
private terminationSignal: NodeJS.Signals | null = null
|
||||
private termination: Promise<void> | undefined
|
||||
@@ -195,7 +237,8 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
try {
|
||||
handle = await this.readyForWait(signal)
|
||||
} catch {
|
||||
return true
|
||||
handle = this.commandHandle
|
||||
if (handle === undefined) return true
|
||||
}
|
||||
if (handle === undefined) return false
|
||||
let sandbox: Sandbox
|
||||
@@ -205,7 +248,8 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
if (isAborted(signal)) return false
|
||||
throw error
|
||||
}
|
||||
while (await this.groupAlive(sandbox, this.remotePid, signal)) {
|
||||
const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
|
||||
while (await this.groupAlive(sandbox, processGroupId, signal)) {
|
||||
if (!await waitTick(signal)) return false
|
||||
}
|
||||
return !isAborted(signal)
|
||||
@@ -248,6 +292,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
if (!Number.isSafeInteger(handle.pid) || handle.pid <= 0) {
|
||||
throw new Error(`subprocess-e2b: E2B returned invalid command pid ${handle.pid}`)
|
||||
}
|
||||
this.commandHandle = handle
|
||||
const completion = handle.wait()
|
||||
void completion.catch(() => {})
|
||||
try {
|
||||
@@ -266,6 +311,10 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
this.readyState.resolve(handle)
|
||||
await this.writeBatchStdin(handle)
|
||||
const outcome = await this.waitForCommand(completion)
|
||||
if (this.outputTransportError !== undefined) throw this.outputTransportError
|
||||
const requireCompleteOutput = this.terminationSignal === null
|
||||
this.stdoutDecoder.finish(requireCompleteOutput)
|
||||
this.stderrDecoder.finish(requireCompleteOutput)
|
||||
await this.finalizeSpills(sandbox)
|
||||
return outcome
|
||||
} catch (error: unknown) {
|
||||
@@ -279,12 +328,13 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
}
|
||||
|
||||
private async prepareState(sandbox: Sandbox): Promise<void> {
|
||||
const ambient = await sandbox.commands.run('env -0')
|
||||
await sandbox.files.makeDir(this.stateDir)
|
||||
await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`)
|
||||
const files = [
|
||||
{ path: this.paths.pid, data: '' },
|
||||
{ path: this.paths.status, data: '' },
|
||||
{ path: this.paths.environment, data: explicitEnvironment(this.spec.env) },
|
||||
{ path: this.paths.environment, data: remoteEnvironment(ambient.stdout, this.spec.env) },
|
||||
...(hasSpill(this.spec.stdio.stdout) ? [{ path: this.paths.stdout, data: '' }] : []),
|
||||
...(hasSpill(this.spec.stdio.stderr) ? [{ path: this.paths.stderr, data: '' }] : []),
|
||||
]
|
||||
@@ -303,25 +353,34 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
}
|
||||
|
||||
private async dispatchOutput(stream: 'stdout' | 'stderr', data: string): Promise<void> {
|
||||
let bytes: Buffer
|
||||
try {
|
||||
bytes = stream === 'stdout' ? this.stdoutDecoder.push(data) : this.stderrDecoder.push(data)
|
||||
} catch (error: unknown) {
|
||||
this.outputTransportError ??= asError(error)
|
||||
const target = stream === 'stdout' ? this.stdout : this.stderr
|
||||
target?.destroy(this.outputTransportError)
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (stream === 'stdout') {
|
||||
this.stdoutReader?.push(data)
|
||||
await this.writeOutput(this.stdout, this.spec.stdio.stdout === 'inherit' ? process.stdout : undefined, data)
|
||||
this.stdoutReader?.push(bytes)
|
||||
await this.writeOutput(this.stdout, this.spec.stdio.stdout === 'inherit' ? process.stdout : undefined, bytes)
|
||||
return
|
||||
}
|
||||
this.stderrReader?.push(data)
|
||||
await this.writeOutput(this.stderr, this.spec.stdio.stderr === 'inherit' ? process.stderr : undefined, data)
|
||||
this.stderrReader?.push(bytes)
|
||||
await this.writeOutput(this.stderr, this.spec.stdio.stderr === 'inherit' ? process.stderr : undefined, bytes)
|
||||
} catch (error: unknown) {
|
||||
const target = stream === 'stdout' ? this.stdout : this.stderr
|
||||
target?.destroy(asError(error))
|
||||
}
|
||||
}
|
||||
|
||||
private async writeOutput(pipe: PassThrough | undefined, inherited: NodeJS.WriteStream | undefined, data: string): Promise<void> {
|
||||
private async writeOutput(pipe: PassThrough | undefined, inherited: NodeJS.WriteStream | undefined, data: Uint8Array): Promise<void> {
|
||||
const target = pipe ?? inherited
|
||||
if (target === undefined || data.length === 0) return
|
||||
if (target.destroyed) throw new Error('subprocess output stream is closed')
|
||||
if (target.write(Buffer.from(data))) return
|
||||
if (target.write(data)) return
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onDrain = (): void => { cleanup(); resolve() }
|
||||
const onError = (error: Error): void => { cleanup(); reject(error) }
|
||||
@@ -369,10 +428,10 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
}
|
||||
|
||||
private async rollbackUnpublishedGroup(sandbox: Sandbox, handle: CommandHandle): Promise<void> {
|
||||
// The background command begins with `exec setsid`, so E2B's command PID is
|
||||
// the provisional group id even before the private publication file can be
|
||||
// trusted. Kill that group before the SDK-PID fallback, then prove no group
|
||||
// member survived before rejecting startup.
|
||||
// The bootstrap ends in an exec chain through the scrubbed environment and
|
||||
// `setsid`, so E2B's command PID is the provisional group id even before the
|
||||
// private publication file can be trusted. Kill that group before the SDK-PID
|
||||
// fallback, then prove no group member survived before rejecting startup.
|
||||
try {
|
||||
await this.signalGroup(sandbox, handle.pid, 'KILL')
|
||||
} finally {
|
||||
@@ -382,23 +441,25 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
}
|
||||
|
||||
private async terminateRemote(): Promise<void> {
|
||||
let handle: CommandHandle
|
||||
let handle: CommandHandle | undefined
|
||||
try {
|
||||
handle = await this.readyState.promise
|
||||
} catch {
|
||||
return
|
||||
handle = this.commandHandle
|
||||
}
|
||||
if (handle === undefined) return
|
||||
const sandbox = await this.runtime.getSandbox()
|
||||
const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
|
||||
this.terminationSignal = 'SIGTERM'
|
||||
await this.signalGroup(sandbox, this.remotePid, 'TERM')
|
||||
await this.signalGroup(sandbox, processGroupId, 'TERM')
|
||||
const deadline = Date.now() + this.spec.graceMs
|
||||
while (Date.now() < deadline && await this.groupAlive(sandbox, this.remotePid)) {
|
||||
while (Date.now() < deadline && await this.groupAlive(sandbox, processGroupId)) {
|
||||
await waitTick()
|
||||
}
|
||||
if (!await this.groupAlive(sandbox, this.remotePid)) return
|
||||
if (!await this.groupAlive(sandbox, processGroupId)) return
|
||||
this.terminationSignal = 'SIGKILL'
|
||||
try {
|
||||
await this.signalGroup(sandbox, this.remotePid, 'KILL')
|
||||
await this.signalGroup(sandbox, processGroupId, 'KILL')
|
||||
} finally {
|
||||
await handle.kill().catch(() => false)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** E2B PTY allocation and process-session ownership for the subprocess seam. */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { constants } from 'node:os'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { posix } from 'node:path'
|
||||
import {
|
||||
@@ -53,13 +52,8 @@ function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function commandSignal(exitCode: number): NodeJS.Signals | null {
|
||||
const number = exitCode - 128
|
||||
if (number <= 0) return null
|
||||
for (const [name, value] of Object.entries(constants.signals)) {
|
||||
if (value === number) return name as NodeJS.Signals
|
||||
}
|
||||
return null
|
||||
function asError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
function parsePositiveId(value: string, message: string): number {
|
||||
@@ -121,6 +115,132 @@ async function waitUntilReady(
|
||||
}
|
||||
}
|
||||
|
||||
async function sessionProcessGroups(sandbox: Sandbox, sessionId: number): Promise<number[]> {
|
||||
const result = await sandbox.commands.run(
|
||||
`ps -eo sid=,pgid= | awk '$1 == ${sessionId} { print $2 }'`,
|
||||
)
|
||||
const groups = new Set<number>()
|
||||
for (const raw of result.stdout.trim().split(/\s+/)) {
|
||||
if (raw.length === 0) continue
|
||||
const group = parsePositiveId(
|
||||
raw,
|
||||
`subprocess-e2b: invalid process group ${JSON.stringify(raw)} in terminal session ${sessionId}`,
|
||||
)
|
||||
if (group <= 1) {
|
||||
throw new Error(`subprocess-e2b: unsafe process group ${group} in terminal session ${sessionId}`)
|
||||
}
|
||||
groups.add(group)
|
||||
}
|
||||
return [...groups]
|
||||
}
|
||||
|
||||
async function signalGroups(sandbox: Sandbox, groups: number[], signal: 'TERM' | 'KILL'): Promise<void> {
|
||||
try {
|
||||
await sandbox.commands.run(`kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`)
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof CommandExitError)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function awaitSessionEmpty(
|
||||
sandbox: Sandbox,
|
||||
sessionId: number,
|
||||
graceMs: number,
|
||||
kill = false,
|
||||
): Promise<number[]> {
|
||||
const deadline = Date.now() + graceMs
|
||||
for (;;) {
|
||||
const groups = await sessionProcessGroups(sandbox, sessionId)
|
||||
if (groups.length === 0 || Date.now() >= deadline) return groups
|
||||
if (kill) await signalGroups(sandbox, groups, 'KILL')
|
||||
await delay(Math.min(POLL_MS, Math.max(1, deadline - Date.now())))
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackUnpublishedTerminal(
|
||||
sandbox: Sandbox,
|
||||
handle: CommandHandle,
|
||||
completion: Promise<CommandResult>,
|
||||
graceMs: number,
|
||||
): Promise<void> {
|
||||
let topLevelExited = false
|
||||
void completion.then(
|
||||
() => { topLevelExited = true },
|
||||
() => { topLevelExited = true },
|
||||
)
|
||||
const validPid = Number.isSafeInteger(handle.pid) && handle.pid > 1
|
||||
const attemptFailures: Error[] = []
|
||||
let sessionId: number | undefined
|
||||
if (validPid) {
|
||||
sessionId = handle.pid
|
||||
try {
|
||||
sessionId = await terminalSessionId(sandbox, handle.pid)
|
||||
} catch (_sessionLookupFailure) {
|
||||
// E2B's PTY leader is also the provisional POSIX session leader, so its
|
||||
// PID remains usable after the setup lookup itself fails or is canceled.
|
||||
}
|
||||
try {
|
||||
let groups = await sessionProcessGroups(sandbox, sessionId)
|
||||
if (groups.length > 0) {
|
||||
await signalGroups(sandbox, groups, 'TERM')
|
||||
groups = await awaitSessionEmpty(sandbox, sessionId, graceMs)
|
||||
}
|
||||
if (groups.length > 0) {
|
||||
await signalGroups(sandbox, groups, 'KILL')
|
||||
await awaitSessionEmpty(sandbox, sessionId, graceMs, true)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
attemptFailures.push(asError(error))
|
||||
}
|
||||
}
|
||||
// Completion can settle while any awaited provider cleanup above is running.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!topLevelExited) {
|
||||
if (validPid) {
|
||||
try {
|
||||
await sandbox.pty.kill(handle.pid)
|
||||
} catch (error: unknown) {
|
||||
attemptFailures.push(asError(error))
|
||||
}
|
||||
}
|
||||
// The awaited PTY fallback can settle completion before the SDK fallback.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!topLevelExited) {
|
||||
try {
|
||||
await handle.kill()
|
||||
} catch (error: unknown) {
|
||||
attemptFailures.push(asError(error))
|
||||
}
|
||||
}
|
||||
await Promise.race([completion.catch(() => undefined), delay(graceMs)])
|
||||
}
|
||||
const proofFailures: Error[] = []
|
||||
if (sessionId !== undefined) {
|
||||
try {
|
||||
const groups = await awaitSessionEmpty(sandbox, sessionId, graceMs, true)
|
||||
if (groups.length > 0) {
|
||||
proofFailures.push(new Error(
|
||||
`subprocess-e2b: terminal setup rollback failed; surviving process groups: ${groups.join(', ')}`,
|
||||
))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
proofFailures.push(asError(error))
|
||||
}
|
||||
}
|
||||
// The bounded completion race above updates this callback-owned state.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!topLevelExited) {
|
||||
proofFailures.push(new Error(`subprocess-e2b: terminal setup rollback failed; surviving pid: ${handle.pid}`))
|
||||
}
|
||||
if (proofFailures.length > 0) {
|
||||
throw new AggregateError(
|
||||
[...attemptFailures, ...proofFailures],
|
||||
'subprocess-e2b: terminal setup rollback did not reach quiescence',
|
||||
)
|
||||
}
|
||||
await handle.disconnect()
|
||||
}
|
||||
|
||||
/** One E2B PTY and all process groups in its remote process session. */
|
||||
export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
||||
readonly pid: number
|
||||
@@ -203,8 +323,9 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
||||
return { exitCode: result.exitCode, signal: null }
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof CommandExitError) {
|
||||
const signal = this.terminationSignal ?? commandSignal(error.exitCode)
|
||||
return signal === null ? { exitCode: error.exitCode, signal: null } : { exitCode: null, signal }
|
||||
return this.terminationSignal === null
|
||||
? { exitCode: error.exitCode, signal: null }
|
||||
: { exitCode: null, signal: this.terminationSignal }
|
||||
}
|
||||
this.output.destroy(error instanceof Error ? error : new Error(String(error)))
|
||||
throw error
|
||||
@@ -214,49 +335,12 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
||||
}
|
||||
}
|
||||
|
||||
private async sessionProcessGroups(): Promise<number[]> {
|
||||
const result = await this.sandbox.commands.run(
|
||||
`ps -eo sid=,pgid= | awk '$1 == ${this.sessionId} { print $2 }'`,
|
||||
)
|
||||
const groups = new Set<number>()
|
||||
for (const raw of result.stdout.trim().split(/\s+/)) {
|
||||
if (raw.length === 0) continue
|
||||
const group = parsePositiveId(
|
||||
raw,
|
||||
`subprocess-e2b: invalid process group ${JSON.stringify(raw)} in terminal session ${this.sessionId}`,
|
||||
)
|
||||
if (group <= 1) {
|
||||
throw new Error(`subprocess-e2b: unsafe process group ${group} in terminal session ${this.sessionId}`)
|
||||
}
|
||||
groups.add(group)
|
||||
}
|
||||
return [...groups]
|
||||
}
|
||||
|
||||
private async signalGroups(groups: number[], signal: 'TERM' | 'KILL'): Promise<void> {
|
||||
try {
|
||||
await this.sandbox.commands.run(`kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`)
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof CommandExitError)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async awaitSessionEmpty(kill = false): Promise<number[]> {
|
||||
const deadline = Date.now() + this.graceMs
|
||||
for (;;) {
|
||||
const groups = await this.sessionProcessGroups()
|
||||
if (groups.length === 0 || Date.now() >= deadline) return groups
|
||||
if (kill) await this.signalGroups(groups, 'KILL')
|
||||
await delay(Math.min(POLL_MS, Math.max(1, deadline - Date.now())))
|
||||
}
|
||||
}
|
||||
|
||||
private async closeOnce(): Promise<void> {
|
||||
let groups = await this.sessionProcessGroups()
|
||||
let groups = await sessionProcessGroups(this.sandbox, this.sessionId)
|
||||
if (groups.length > 0) {
|
||||
this.terminationSignal = 'SIGTERM'
|
||||
await this.signalGroups(groups, 'TERM')
|
||||
groups = await this.awaitSessionEmpty()
|
||||
await signalGroups(this.sandbox, groups, 'TERM')
|
||||
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.graceMs)
|
||||
}
|
||||
if (groups.length === 0 && !this.topLevelExited) {
|
||||
await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
|
||||
@@ -264,7 +348,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
||||
if (groups.length > 0 || !this.topLevelExited) {
|
||||
this.terminationSignal = 'SIGKILL'
|
||||
if (!this.topLevelExited) await this.sandbox.pty.kill(this.pid)
|
||||
groups = await this.awaitSessionEmpty(true)
|
||||
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.graceMs, true)
|
||||
if (!this.topLevelExited) await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
|
||||
}
|
||||
if (groups.length > 0) {
|
||||
@@ -348,9 +432,20 @@ export async function spawnE2BTerminal(
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
output.destroy()
|
||||
if (handle !== undefined) await handle.kill().catch(() => false)
|
||||
if (completion !== undefined) await completion.catch(() => {})
|
||||
let cleanupError: Error | undefined
|
||||
if (handle !== undefined && completion !== undefined) {
|
||||
try {
|
||||
await rollbackUnpublishedTerminal(sandbox, handle, completion, spec.graceMs)
|
||||
} catch (rollbackError: unknown) {
|
||||
cleanupError = asError(rollbackError)
|
||||
}
|
||||
} else if (handle !== undefined) {
|
||||
await handle.kill().catch(() => false)
|
||||
}
|
||||
await sandbox.files.remove(stateDir).catch(() => {})
|
||||
if (cleanupError !== undefined) {
|
||||
throw new AggregateError([asError(error), cleanupError], asError(error).message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user