fix(e2b): close proven provider boundary gaps
This commit is contained in:
@@ -29,7 +29,7 @@ export async function readRemoteEnvironment(sandbox: Sandbox, signal?: AbortSign
|
||||
// TODO(e2b-replace-environment): Remove this ambient probe when E2B can start
|
||||
// a command with a replacement environment instead of merged overrides.
|
||||
const result = await sandbox.commands.run(
|
||||
'set -o pipefail; printf \'%s\' "$PWD" | base64 -w 0; printf \'\\n\'; env -0 | base64 -w 0',
|
||||
'set -o pipefail; dsh_e2b_passwd="$(getent passwd "$(id -u)")"; IFS=: read -r _ _ _ _ _ dsh_e2b_home _ <<<"$dsh_e2b_passwd"; test -n "$dsh_e2b_home" -a -d "$dsh_e2b_home"; printf \'%s\' "$dsh_e2b_home" | base64 -w 0; printf \'\\n\'; env -0 | base64 -w 0',
|
||||
{ envs: e2bControlEnvs(), ...(signal === undefined ? {} : { signal }) },
|
||||
)
|
||||
const lines = result.stdout.trim().split('\n')
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
/** Package-owned invariant companion for `@deepseek-ai/dsh-subprocess-e2b`. */
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-subprocess-e2b`.
|
||||
* @module @deepseek-ai/dsh-subprocess-e2b/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
@@ -104,7 +104,6 @@ function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string {
|
||||
: `2> >(${encoder} >&2 2>/dev/null)`
|
||||
const inner = [
|
||||
'set +e',
|
||||
'umask 077',
|
||||
'dsh_e2b_env_bin=$1',
|
||||
'dsh_e2b_node=$2',
|
||||
'dsh_e2b_ps=$3',
|
||||
@@ -520,6 +519,8 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
() => true,
|
||||
)
|
||||
while (true) {
|
||||
// TODO(e2b-publication-cancel): Join cancellation to the existing
|
||||
// termination transaction before aborting an in-flight SDK file read.
|
||||
const raw = await sandbox.files.read(this.paths.pid)
|
||||
const value = raw.trim()
|
||||
if (value.length > 0) {
|
||||
@@ -543,6 +544,8 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
result => ({ kind: 'result', result }),
|
||||
(error: unknown) => ({ kind: 'error', error }),
|
||||
)
|
||||
const hasPipeOutput = this.spec.stdio.stdout === 'pipe' || this.spec.stdio.stderr === 'pipe'
|
||||
let completed = hasPipeOutput ? await settlement : undefined
|
||||
while (true) {
|
||||
const rawStatus = (await sandbox.files.read(this.paths.status)).trim()
|
||||
if (rawStatus.length > 0) {
|
||||
@@ -550,19 +553,19 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
if (!/^(?:0|[1-9][0-9]*)$/.test(rawStatus) || !Number.isSafeInteger(exitCode) || exitCode > 255) {
|
||||
throw new Error(`subprocess-e2b: remote wrapper published invalid exit code ${JSON.stringify(rawStatus)}`)
|
||||
}
|
||||
if (this.spec.stdio.stdout === 'pipe' || this.spec.stdio.stderr === 'pipe') {
|
||||
return this.commandOutcome(await settlement, exitCode)
|
||||
}
|
||||
const completed = await withinMs(settlement, this.spec.graceMs)
|
||||
if (completed !== undefined) return this.commandOutcome(completed, exitCode)
|
||||
const drained = await withinMs(settlement, this.spec.graceMs)
|
||||
if (drained !== undefined) return this.commandOutcome(drained, exitCode)
|
||||
this.outputDrainExpired = true
|
||||
this.stdoutReader?.invalidateSpill()
|
||||
this.stderrReader?.invalidateSpill()
|
||||
await handle.disconnect()
|
||||
return { exitCode, signal: null }
|
||||
}
|
||||
const completed = await Promise.race([settlement, waitTick().then(() => undefined)])
|
||||
if (completed !== undefined) return this.commandOutcome(completed)
|
||||
// TODO(e2b-status-watch): Replace collect/inherit polling when E2B can
|
||||
// observe direct-command exit independently of descendant-held output.
|
||||
completed = await Promise.race([settlement, waitTick().then(() => undefined)])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -345,6 +345,9 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
||||
|
||||
private topLevelExited = false
|
||||
private cleanup: Promise<void> | undefined
|
||||
private readonly operationController = new AbortController()
|
||||
private readonly operations = new Set<Promise<unknown>>()
|
||||
private terminating = false
|
||||
private terminationSignal: NodeJS.Signals | null = null
|
||||
|
||||
constructor(
|
||||
@@ -364,17 +367,56 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
||||
// TODO(e2b-pgid-identity): Replace retained numeric PTY/session ids when E2B
|
||||
// exposes identity-bound input, foreground-signal, and cleanup operations.
|
||||
/** @inheritdoc */
|
||||
async write(data: string): Promise<void> {
|
||||
if (this.topLevelExited) throw new Error('terminal process has exited')
|
||||
await this.sandbox.pty.sendInput(this.pid, Buffer.from(data, 'utf8'))
|
||||
write(data: string): Promise<void> {
|
||||
return this.trackOperation(async (signal) => {
|
||||
if (this.topLevelExited) throw new Error('terminal process has exited')
|
||||
await this.sandbox.pty.sendInput(this.pid, Buffer.from(data, 'utf8'), { signal })
|
||||
})
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
|
||||
inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
|
||||
return this.trackOperation(signal => this.inspectForegroundOnce(signal))
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
signalForeground(signal: SubprocessTerminalSignal): Promise<number> {
|
||||
return this.trackOperation(async (operationSignal) => {
|
||||
const foreground = await this.inspectForegroundOnce(operationSignal)
|
||||
if (foreground === undefined) {
|
||||
throw new Error(`subprocess-e2b: cannot resolve foreground process group for terminal ${this.pid}`)
|
||||
}
|
||||
if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
|
||||
throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
|
||||
}
|
||||
await this.sandbox.commands.run(
|
||||
`kill -${signal.slice(3)} -- -${foreground.processGroupId}`,
|
||||
commandOpts(this.controlEnvs, operationSignal),
|
||||
)
|
||||
return foreground.processGroupId
|
||||
})
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
terminate(): Promise<void> {
|
||||
if (this.cleanup !== undefined) return this.cleanup
|
||||
this.terminating = true
|
||||
this.operationController.abort(new Error('subprocess-e2b: terminal is terminating'))
|
||||
const cleanup = this.closeAfterOperations()
|
||||
this.cleanup = cleanup
|
||||
void cleanup.catch((_cleanupFailure: unknown) => {
|
||||
this.cleanup = undefined
|
||||
})
|
||||
return cleanup
|
||||
}
|
||||
|
||||
private async inspectForegroundOnce(
|
||||
signal: AbortSignal,
|
||||
): Promise<SubprocessTerminalForeground | undefined> {
|
||||
try {
|
||||
const result = await this.sandbox.commands.run(
|
||||
`ps -o tpgid= -p ${this.pid}`,
|
||||
commandOpts(this.controlEnvs),
|
||||
commandOpts(this.controlEnvs, signal),
|
||||
)
|
||||
return {
|
||||
processGroupId: parsePositiveId(
|
||||
@@ -391,31 +433,20 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async signalForeground(signal: SubprocessTerminalSignal): Promise<number> {
|
||||
const foreground = await this.inspectForeground()
|
||||
if (foreground === undefined) {
|
||||
throw new Error(`subprocess-e2b: cannot resolve foreground process group for terminal ${this.pid}`)
|
||||
}
|
||||
if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
|
||||
throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
|
||||
}
|
||||
await this.sandbox.commands.run(
|
||||
`kill -${signal.slice(3)} -- -${foreground.processGroupId}`,
|
||||
commandOpts(this.controlEnvs),
|
||||
private trackOperation<T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
||||
if (this.terminating) return Promise.reject(new Error('subprocess-e2b: terminal is terminating'))
|
||||
const pending = operation(this.operationController.signal)
|
||||
this.operations.add(pending)
|
||||
void pending.then(
|
||||
() => { this.operations.delete(pending) },
|
||||
() => { this.operations.delete(pending) },
|
||||
)
|
||||
return foreground.processGroupId
|
||||
return pending
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
terminate(): Promise<void> {
|
||||
if (this.cleanup !== undefined) return this.cleanup
|
||||
const cleanup = this.closeOnce()
|
||||
this.cleanup = cleanup
|
||||
void cleanup.catch((_cleanupFailure: unknown) => {
|
||||
this.cleanup = undefined
|
||||
})
|
||||
return cleanup
|
||||
private async closeAfterOperations(): Promise<void> {
|
||||
if (this.operations.size > 0) await Promise.allSettled(this.operations)
|
||||
await this.closeOnce()
|
||||
}
|
||||
|
||||
private async waitForCommand(): Promise<SubprocessOutcome> {
|
||||
|
||||
Reference in New Issue
Block a user