process.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  1. /** One asynchronously-started E2B command projected onto the subprocess seam. */
  2. import { Buffer } from 'node:buffer'
  3. import { PassThrough, Writable } from 'node:stream'
  4. import { posix } from 'node:path'
  5. import {
  6. CommandExitError,
  7. e2bControlEnvs,
  8. FileNotFoundError,
  9. SandboxNotFoundError,
  10. quoteE2BShellArg,
  11. } from '@deepseek-ai/dsh-e2b'
  12. import type { CommandHandle, CommandResult, Sandbox } from '@deepseek-ai/dsh-e2b'
  13. import type {
  14. SubprocessCollect,
  15. SubprocessHandle,
  16. SubprocessOutcome,
  17. SubprocessOutputMode,
  18. SubprocessSpawnSpec,
  19. } from '@deepseek-ai/dsh-subprocess'
  20. import type E2BRuntime from '@deepseek-ai/dsh-e2b'
  21. import { bootstrapEnvironment, readRemoteEnvironment, serializeRemoteEnvironment } from './environment.ts'
  22. import { E2BBase64Decoder, E2B_OUTPUT_COMPLETE_FRAME, E2BOutputReader } from './output.ts'
  23. import { asError, commandOpts, signalRemoteGroups, waitTick } from './remote.ts'
  24. const OUTPUT_ENCODER_SOURCE = [
  25. '(async () => {',
  26. ' for await (const chunk of process.stdin) {',
  27. " if (!process.stdout.write(chunk.toString('base64') + '\\n')) {",
  28. " await new Promise(resolve => process.stdout.once('drain', resolve))",
  29. ' }',
  30. ' }',
  31. ` if (!process.stdout.write(${JSON.stringify(E2B_OUTPUT_COMPLETE_FRAME)} + '\\n')) {`,
  32. " await new Promise(resolve => process.stdout.once('drain', resolve))",
  33. ' }',
  34. '})().catch(() => { process.exitCode = 1 })',
  35. ].join('\n')
  36. function isCollect(mode: SubprocessOutputMode): mode is SubprocessCollect {
  37. return mode !== 'pipe' && mode !== 'inherit'
  38. }
  39. function hasSpill(mode: SubprocessOutputMode): mode is SubprocessCollect & { spill: { maxBytes: number } } {
  40. return isCollect(mode) && mode.spill !== undefined
  41. }
  42. function isValidProcessId(value: number): boolean {
  43. return Number.isSafeInteger(value) && value > 0
  44. }
  45. class DeferredStdin extends Writable {
  46. constructor(private readonly ready: Promise<CommandHandle>) {
  47. super({ decodeStrings: false })
  48. }
  49. override _write(chunk: string | Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
  50. void this.ready.then(handle => handle.sendStdin(chunk)).then(
  51. () => { callback() },
  52. (error: unknown) => { callback(asError(error)) },
  53. )
  54. }
  55. override _final(callback: (error?: Error | null) => void): void {
  56. void this.ready.then(handle => handle.closeStdin()).then(
  57. () => { callback() },
  58. (error: unknown) => { callback(asError(error)) },
  59. )
  60. }
  61. }
  62. interface RemotePaths {
  63. pid: string
  64. status: string
  65. environment: string
  66. stdout: string
  67. stderr: string
  68. }
  69. type CommandSettlement =
  70. | { kind: 'result'; result: CommandResult }
  71. | { kind: 'error'; error: unknown }
  72. function withinMs(settlement: Promise<CommandSettlement>, timeoutMs: number): Promise<CommandSettlement | undefined> {
  73. return new Promise<CommandSettlement | undefined>((resolve) => {
  74. const timer = setTimeout(() => { resolve(undefined) }, timeoutMs)
  75. void settlement.then((value) => {
  76. clearTimeout(timer)
  77. resolve(value)
  78. })
  79. })
  80. }
  81. function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string {
  82. const encoder = `"$dsh_e2b_env_bin" -i "$dsh_e2b_node" -e ${quoteE2BShellArg(OUTPUT_ENCODER_SOURCE)}`
  83. const stdoutRedirect = hasSpill(spec.stdio.stdout)
  84. ? `> >("$dsh_e2b_tee" --output-error=warn-nopipe >("$dsh_e2b_head" -c ${spec.stdio.stdout.spill.maxBytes} > ${quoteE2BShellArg(paths.stdout)}) | ${encoder} 2>/dev/null)`
  85. : `> >(${encoder} 2>/dev/null)`
  86. const stderrRedirect = hasSpill(spec.stdio.stderr)
  87. ? `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)`
  88. : `2> >(${encoder} >&2 2>/dev/null)`
  89. const inner = [
  90. 'set +e',
  91. 'dsh_e2b_env_bin=$1',
  92. 'dsh_e2b_node=$2',
  93. 'dsh_e2b_ps=$3',
  94. 'dsh_e2b_tr=$4',
  95. 'dsh_e2b_tee=$5',
  96. 'dsh_e2b_head=$6',
  97. 'dsh_e2b_rm=$7',
  98. 'shift 7',
  99. 'dsh_e2b_pgid="$("$dsh_e2b_ps" -o pgid= -p "$$" | "$dsh_e2b_tr" -d " ")"',
  100. `printf '%s\\n' "$dsh_e2b_pgid" > ${quoteE2BShellArg(paths.pid)}`,
  101. `mapfile -d '' -t dsh_e2b_env < ${quoteE2BShellArg(paths.environment)}`,
  102. `"$dsh_e2b_rm" -f -- ${quoteE2BShellArg(paths.environment)}`,
  103. `"$dsh_e2b_env_bin" -i -- "\${dsh_e2b_env[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(),
  104. 'dsh_e2b_status=$?',
  105. `printf '%s\\n' "$dsh_e2b_status" > ${quoteE2BShellArg(paths.status)}`,
  106. 'wait',
  107. 'exit "$dsh_e2b_status"',
  108. ].join('\n')
  109. const argv = spec.argv.map(quoteE2BShellArg).join(' ')
  110. const bootstrap = [
  111. `mapfile -d '' -t dsh_e2b_env < ${quoteE2BShellArg(paths.environment)}`,
  112. 'dsh_e2b_env_bin="$(command -v env)"',
  113. 'dsh_e2b_setsid="$(command -v setsid)"',
  114. 'dsh_e2b_bash="$(command -v bash)"',
  115. 'dsh_e2b_node="$(command -v node)"',
  116. 'dsh_e2b_ps="$(command -v ps)"',
  117. 'dsh_e2b_tr="$(command -v tr)"',
  118. 'dsh_e2b_tee="$(command -v tee)"',
  119. 'dsh_e2b_head="$(command -v head)"',
  120. 'dsh_e2b_rm="$(command -v rm)"',
  121. '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" "$dsh_e2b_rm"; do',
  122. ' [[ "$dsh_e2b_tool" == /* && -x "$dsh_e2b_tool" ]] || exit 125',
  123. 'done',
  124. `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" "$dsh_e2b_rm" ${argv}`,
  125. ].join('\n')
  126. return bootstrap
  127. }
  128. const WAIT_ABORTED = Symbol('wait aborted')
  129. function waitWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T | typeof WAIT_ABORTED> {
  130. if (signal === undefined) return promise
  131. if (signal.aborted) return Promise.resolve(WAIT_ABORTED)
  132. return new Promise<T | typeof WAIT_ABORTED>((resolve) => {
  133. const onAbort = (): void => { cleanup(); resolve(WAIT_ABORTED) }
  134. const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
  135. signal.addEventListener('abort', onAbort, { once: true })
  136. if (signal.aborted) {
  137. onAbort()
  138. return
  139. }
  140. void promise.then((value) => { cleanup(); resolve(value) })
  141. })
  142. }
  143. /** E2B-backed subprocess handle with deferred remote PID acquisition. */
  144. export class E2BSubprocessHandle implements SubprocessHandle {
  145. readonly stdin: Writable | undefined
  146. readonly stdout: PassThrough | undefined
  147. readonly stderr: PassThrough | undefined
  148. readonly collected: SubprocessHandle['collected']
  149. readonly done: Promise<SubprocessOutcome>
  150. private readonly commandState = Promise.withResolvers<CommandHandle | undefined>()
  151. private readonly readyState = Promise.withResolvers<CommandHandle>()
  152. private readonly stdoutDecoder = new E2BBase64Decoder()
  153. private readonly stderrDecoder = new E2BBase64Decoder()
  154. private readonly terminationController = new AbortController()
  155. /** Releases output waits that survive the command outcome, so blocked SDK callbacks settle. */
  156. private readonly outputReleased = new AbortController()
  157. private readonly stdoutReader: E2BOutputReader | undefined
  158. private readonly stderrReader: E2BOutputReader | undefined
  159. private readonly paths: RemotePaths
  160. private controlEnvs: Record<string, string> = {}
  161. private remotePid = -1
  162. private outputTransportError: Error | undefined
  163. private outputDrainExpired = false
  164. private stateDirectoryCreated = false
  165. private quiescenceProven = false
  166. private terminationAttempt: Promise<void> | undefined
  167. private terminationFailure: Error | undefined
  168. private terminationSignal: NodeJS.Signals | null = null
  169. /**
  170. * Begin an E2B command without blocking the synchronous subprocess spawn call.
  171. * @param runtime - Shared E2B sandbox owner.
  172. * @param spec - Fully resolved subprocess request.
  173. * @param stateDir - Remote directory retaining process identity, status, and valid spills.
  174. * @param pollMs - Remote status/liveness poll cadence.
  175. */
  176. constructor(
  177. private readonly runtime: E2BRuntime,
  178. private readonly spec: SubprocessSpawnSpec,
  179. readonly stateDir: string,
  180. private readonly pollMs: number,
  181. ) {
  182. this.paths = {
  183. pid: posix.join(stateDir, 'pid'),
  184. status: posix.join(stateDir, 'exit-code'),
  185. environment: posix.join(stateDir, 'environment'),
  186. stdout: posix.join(stateDir, 'stdout.log'),
  187. stderr: posix.join(stateDir, 'stderr.log'),
  188. }
  189. const outMode = spec.stdio.stdout
  190. const errMode = spec.stdio.stderr
  191. this.stdout = outMode === 'pipe' ? new PassThrough() : undefined
  192. this.stderr = errMode === 'pipe' ? new PassThrough() : undefined
  193. this.stdoutReader = isCollect(outMode)
  194. ? new E2BOutputReader(outMode.maxBytes, outMode.spill?.maxBytes, this.paths.stdout)
  195. : undefined
  196. this.stderrReader = isCollect(errMode)
  197. ? new E2BOutputReader(errMode.maxBytes, errMode.spill?.maxBytes, this.paths.stderr)
  198. : undefined
  199. this.collected = {
  200. ...(this.stdoutReader !== undefined ? { stdout: this.stdoutReader } : {}),
  201. ...(this.stderrReader !== undefined ? { stderr: this.stderrReader } : {}),
  202. }
  203. this.stdin = spec.stdio.stdin === 'pipe' ? new DeferredStdin(this.readyState.promise) : undefined
  204. void this.readyState.promise.catch(() => {})
  205. spec.signal?.addEventListener('abort', this.onAbort, { once: true })
  206. this.done = this.run()
  207. void this.done.catch(() => {})
  208. if (spec.signal?.aborted === true) this.terminate()
  209. }
  210. /** Remote process id after start; `-1` while E2B startup is pending or after it fails. */
  211. get pid(): number {
  212. return this.remotePid
  213. }
  214. /** @inheritdoc */
  215. terminate(): void {
  216. if (this.quiescenceProven || this.terminationAttempt !== undefined) return
  217. this.terminationController.abort(new Error('subprocess-e2b: command terminated'))
  218. this.stdout?.destroy()
  219. this.stderr?.destroy()
  220. this.terminationFailure = undefined
  221. const attempt = this.terminateRemote()
  222. this.terminationAttempt = attempt
  223. void attempt.then(
  224. () => { this.terminationAttempt = undefined },
  225. (error: unknown) => {
  226. if (!this.quiescenceProven) this.terminationFailure = asError(error)
  227. this.terminationAttempt = undefined
  228. },
  229. )
  230. }
  231. /** @inheritdoc */
  232. async waitForExit(signal?: AbortSignal): Promise<boolean> {
  233. if (this.quiescenceProven) return true
  234. let handle: CommandHandle | undefined
  235. if (this.terminationController.signal.aborted) {
  236. const observed = await waitWithSignal(this.commandState.promise, signal)
  237. if (observed === WAIT_ABORTED) return false
  238. handle = observed
  239. if (handle === undefined) {
  240. this.markQuiescent()
  241. return true
  242. }
  243. if (this.remotePid <= 0) {
  244. const attempt = this.terminationAttempt
  245. if (attempt !== undefined && await waitWithSignal(attempt.catch(() => undefined), signal) === WAIT_ABORTED) {
  246. return false
  247. }
  248. this.throwTerminationFailure()
  249. // Successful pre-publication termination records quiescence; its only other outcome is the failure above.
  250. return true
  251. }
  252. } else {
  253. const observed = await waitWithSignal(
  254. this.readyState.promise.catch(() => this.commandState.promise),
  255. signal,
  256. )
  257. if (observed === WAIT_ABORTED) return false
  258. handle = observed
  259. if (handle === undefined) {
  260. this.markQuiescent()
  261. return true
  262. }
  263. }
  264. this.throwTerminationFailure()
  265. let sandbox: Sandbox
  266. try {
  267. sandbox = await this.runtime.getSandbox()
  268. } catch (error: unknown) {
  269. if (signal?.aborted === true) return false
  270. if (error instanceof SandboxNotFoundError) {
  271. this.markQuiescent()
  272. return true
  273. }
  274. throw error
  275. }
  276. const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
  277. while (await this.groupAlive(sandbox, processGroupId, signal)) {
  278. this.throwTerminationFailure()
  279. if (!await waitTick(this.pollMs, signal)) return false
  280. }
  281. this.throwTerminationFailure()
  282. if (signal?.aborted === true) return false
  283. this.markQuiescent()
  284. return true
  285. }
  286. private readonly onAbort = (): void => { this.terminate() }
  287. private markQuiescent(): void {
  288. this.quiescenceProven = true
  289. this.terminationFailure = undefined
  290. }
  291. private async run(): Promise<SubprocessOutcome> {
  292. let sandbox: Sandbox | undefined
  293. let preparing = true
  294. try {
  295. sandbox = await this.runtime.getSandbox()
  296. await this.prepareState(sandbox)
  297. preparing = false
  298. const handle = await sandbox.commands.run(
  299. commandText(this.spec, this.paths),
  300. {
  301. background: true,
  302. cwd: this.spec.cwd,
  303. envs: e2bControlEnvs(this.controlEnvs),
  304. stdin: this.spec.stdio.stdin !== 'ignore',
  305. timeoutMs: 0,
  306. onStdout: async (data) => { await this.dispatchOutput('stdout', data) },
  307. onStderr: async (data) => { await this.dispatchOutput('stderr', data) },
  308. },
  309. )
  310. const completion = handle.wait()
  311. void completion.catch(() => {})
  312. if (!isValidProcessId(handle.pid)) {
  313. const invalidPid = new Error(`subprocess-e2b: E2B returned invalid command pid ${handle.pid}`)
  314. try {
  315. await handle.kill()
  316. this.markQuiescent()
  317. } catch (cleanupError: unknown) {
  318. this.terminationFailure = asError(cleanupError)
  319. this.commandState.resolve(handle)
  320. throw new AggregateError(
  321. [invalidPid, cleanupError],
  322. 'subprocess-e2b: invalid command pid rollback did not reach quiescence',
  323. )
  324. }
  325. throw invalidPid
  326. }
  327. this.commandState.resolve(handle)
  328. try {
  329. this.remotePid = await this.waitForProcessGroupId(sandbox, completion)
  330. } catch (error: unknown) {
  331. try {
  332. await this.rollbackUnpublishedGroup(sandbox, handle)
  333. } catch (cleanupError: unknown) {
  334. throw new AggregateError(
  335. [error, cleanupError],
  336. 'subprocess-e2b: process-group publication failed and rollback did not reach quiescence',
  337. )
  338. }
  339. throw error
  340. }
  341. this.readyState.resolve(handle)
  342. await this.writeBatchStdin(handle)
  343. const outcome = await this.waitForCommand(sandbox, handle, completion)
  344. if (this.outputTransportError !== undefined) throw this.outputTransportError
  345. const requireCompleteOutput = this.terminationSignal === null && !this.outputDrainExpired
  346. this.stdoutDecoder.finish(requireCompleteOutput)
  347. this.stderrDecoder.finish(requireCompleteOutput)
  348. await this.finalizeSpills(sandbox)
  349. return outcome
  350. } catch (error: unknown) {
  351. const canceledPreparation = preparing && this.terminationController.signal.aborted
  352. let failure = await this.rollbackPublishedFailure(error)
  353. if (sandbox !== undefined && this.stateDirectoryCreated) {
  354. try {
  355. await this.removeFailedState(sandbox)
  356. } catch (cleanupError: unknown) {
  357. failure = new AggregateError(
  358. [failure, cleanupError],
  359. 'subprocess-e2b: command failed and private state cleanup failed',
  360. )
  361. }
  362. }
  363. this.commandState.resolve(undefined)
  364. this.readyState.reject(failure)
  365. if (canceledPreparation && failure === error) return { exitCode: null, signal: 'SIGTERM' }
  366. throw failure
  367. } finally {
  368. this.spec.signal?.removeEventListener('abort', this.onAbort)
  369. this.stdout?.end()
  370. this.stderr?.end()
  371. }
  372. }
  373. private async prepareState(sandbox: Sandbox): Promise<void> {
  374. const signal = this.terminationController.signal
  375. const ambient = await readRemoteEnvironment(sandbox, signal)
  376. this.controlEnvs = bootstrapEnvironment(ambient)
  377. // Own the directory before the request: a cancellation racing a committed
  378. // creation must still enter cleanup (removal tolerates an absent path).
  379. this.stateDirectoryCreated = true
  380. await sandbox.files.makeDir(this.stateDir, { signal })
  381. await sandbox.commands.run(
  382. `chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`,
  383. commandOpts(this.controlEnvs, signal),
  384. )
  385. const files = [
  386. { path: this.paths.pid, data: '' },
  387. { path: this.paths.status, data: '' },
  388. { path: this.paths.environment, data: serializeRemoteEnvironment(ambient, this.spec.env) },
  389. ...(hasSpill(this.spec.stdio.stdout) ? [{ path: this.paths.stdout, data: '' }] : []),
  390. ...(hasSpill(this.spec.stdio.stderr) ? [{ path: this.paths.stderr, data: '' }] : []),
  391. ]
  392. await sandbox.files.write(files, { signal })
  393. await sandbox.commands.run(
  394. `chmod 600 -- ${files.map(file => quoteE2BShellArg(file.path)).join(' ')}`,
  395. commandOpts(this.controlEnvs, signal),
  396. )
  397. signal.throwIfAborted()
  398. }
  399. private async writeBatchStdin(handle: CommandHandle): Promise<void> {
  400. if (typeof this.spec.stdio.stdin !== 'object') return
  401. try {
  402. await handle.sendStdin(this.spec.stdio.stdin.data)
  403. await handle.closeStdin()
  404. } catch (_processClosedItsInput) {
  405. // Like the local adapter, batch stdin is best-effort; exit and output remain authoritative.
  406. }
  407. }
  408. private async dispatchOutput(stream: 'stdout' | 'stderr', data: string): Promise<void> {
  409. let bytes: Buffer
  410. try {
  411. bytes = stream === 'stdout' ? this.stdoutDecoder.push(data) : this.stderrDecoder.push(data)
  412. } catch (error: unknown) {
  413. this.outputTransportError ??= asError(error)
  414. const target = stream === 'stdout' ? this.stdout : this.stderr
  415. target?.destroy(this.outputTransportError)
  416. return
  417. }
  418. try {
  419. if (stream === 'stdout') {
  420. this.stdoutReader?.push(bytes)
  421. await this.writeOutput(this.stdout, this.spec.stdio.stdout === 'inherit' ? process.stdout : undefined, bytes)
  422. return
  423. }
  424. this.stderrReader?.push(bytes)
  425. await this.writeOutput(this.stderr, this.spec.stdio.stderr === 'inherit' ? process.stderr : undefined, bytes)
  426. } catch (error: unknown) {
  427. const target = stream === 'stdout' ? this.stdout : this.stderr
  428. target?.destroy(asError(error))
  429. }
  430. }
  431. private async writeOutput(pipe: PassThrough | undefined, inherited: NodeJS.WriteStream | undefined, data: Uint8Array): Promise<void> {
  432. const target = pipe ?? inherited
  433. if (target === undefined || data.length === 0 || this.terminationController.signal.aborted) return
  434. if (target.destroyed) throw new Error('subprocess output stream is closed')
  435. if (target.write(data)) return
  436. await new Promise<void>((resolve, reject) => {
  437. const onDrain = (): void => { cleanup(); resolve() }
  438. const onClose = (): void => { cleanup(); resolve() }
  439. const onRelease = (): void => { cleanup(); resolve() }
  440. const onError = (error: Error): void => { cleanup(); reject(error) }
  441. const cleanup = (): void => {
  442. target.removeListener('drain', onDrain)
  443. target.removeListener('close', onClose)
  444. target.removeListener('error', onError)
  445. this.terminationController.signal.removeEventListener('abort', onRelease)
  446. this.outputReleased.signal.removeEventListener('abort', onRelease)
  447. }
  448. target.once('drain', onDrain)
  449. target.once('close', onClose)
  450. target.once('error', onError)
  451. this.terminationController.signal.addEventListener('abort', onRelease, { once: true })
  452. this.outputReleased.signal.addEventListener('abort', onRelease, { once: true })
  453. if (this.terminationController.signal.aborted || this.outputReleased.signal.aborted) onRelease()
  454. })
  455. }
  456. private async waitForProcessGroupId(sandbox: Sandbox, completion: Promise<CommandResult>): Promise<number> {
  457. const commandSettled = completion.then(
  458. () => true,
  459. () => true,
  460. )
  461. while (true) {
  462. // TODO(e2b-publication-cancel): Join cancellation to the existing
  463. // termination transaction before aborting an in-flight SDK file read.
  464. const raw = await sandbox.files.read(this.paths.pid)
  465. const value = raw.trim()
  466. if (value.length > 0) {
  467. const pid = Number(value)
  468. if (!/^[1-9][0-9]*$/.test(value) || !Number.isSafeInteger(pid)) {
  469. throw new Error(`subprocess-e2b: remote wrapper published invalid process-group id ${JSON.stringify(value)}`)
  470. }
  471. // A same-UID sandbox process can rewrite this file; refuse ids whose
  472. // negative form addresses every process (`kill -- -1`) or init's group.
  473. if (pid <= 1) {
  474. throw new Error(`subprocess-e2b: unsafe published process-group id ${pid}`)
  475. }
  476. return pid
  477. }
  478. const settled = await Promise.race([commandSettled, waitTick(this.pollMs).then(() => false)])
  479. if (settled) throw new Error('subprocess-e2b: remote command exited before publishing its process-group id')
  480. }
  481. }
  482. private async waitForCommand(
  483. sandbox: Sandbox,
  484. handle: CommandHandle,
  485. completion: Promise<CommandResult>,
  486. ): Promise<SubprocessOutcome> {
  487. const settlement = completion.then<CommandSettlement, CommandSettlement>(
  488. result => ({ kind: 'result', result }),
  489. (error: unknown) => ({ kind: 'error', error }),
  490. )
  491. const hasPipeOutput = this.spec.stdio.stdout === 'pipe' || this.spec.stdio.stderr === 'pipe'
  492. let completed = hasPipeOutput ? await settlement : undefined
  493. while (true) {
  494. const rawStatus = (await sandbox.files.read(this.paths.status)).trim()
  495. if (rawStatus.length > 0) {
  496. const exitCode = Number(rawStatus)
  497. if (!/^(?:0|[1-9][0-9]*)$/.test(rawStatus) || !Number.isSafeInteger(exitCode) || exitCode > 255) {
  498. throw new Error(`subprocess-e2b: remote wrapper published invalid exit code ${JSON.stringify(rawStatus)}`)
  499. }
  500. if (completed !== undefined) return this.commandOutcome(completed, exitCode)
  501. const drained = await withinMs(settlement, this.spec.graceMs)
  502. if (drained !== undefined) return this.commandOutcome(drained, exitCode)
  503. this.outputDrainExpired = true
  504. this.stdoutReader?.invalidateSpill()
  505. this.stderrReader?.invalidateSpill()
  506. // Release inherited-output waits so a callback blocked on host
  507. // backpressure cannot keep the disconnected SDK settlement pending.
  508. this.outputReleased.abort(new Error('subprocess-e2b: output drain grace expired'))
  509. await handle.disconnect()
  510. return { exitCode, signal: null }
  511. }
  512. if (completed !== undefined) return this.commandOutcome(completed)
  513. // TODO(e2b-status-watch): Replace collect/inherit control-plane polling
  514. // when E2B can observe direct-command exit independently of descendant-held output.
  515. completed = await Promise.race([settlement, waitTick(this.pollMs).then(() => undefined)])
  516. }
  517. }
  518. private commandOutcome(settlement: CommandSettlement, publishedExitCode?: number): SubprocessOutcome {
  519. if (settlement.kind === 'result') {
  520. return { exitCode: publishedExitCode ?? settlement.result.exitCode, signal: null }
  521. }
  522. if (settlement.error instanceof CommandExitError) {
  523. if (publishedExitCode !== undefined) return { exitCode: publishedExitCode, signal: null }
  524. return this.terminationSignal === null
  525. ? { exitCode: settlement.error.exitCode, signal: null }
  526. : { exitCode: null, signal: this.terminationSignal }
  527. }
  528. throw settlement.error
  529. }
  530. private async rollbackPublishedFailure(error: unknown): Promise<unknown> {
  531. if (this.remotePid <= 0 || this.quiescenceProven) return error
  532. this.terminate()
  533. try {
  534. await this.waitForExit()
  535. return error
  536. } catch (cleanupError: unknown) {
  537. return new AggregateError(
  538. [asError(error), asError(cleanupError)],
  539. 'subprocess-e2b: command monitoring failed and process-group rollback did not reach quiescence',
  540. )
  541. }
  542. }
  543. private async rollbackUnpublishedGroup(sandbox: Sandbox, handle: CommandHandle): Promise<void> {
  544. // The bootstrap ends in an exec chain through the scrubbed environment and
  545. // `setsid`, so E2B's command PID is the provisional group id even before the
  546. // private publication file can be trusted. Kill that group before the SDK-PID
  547. // fallback, then prove no group member survived before rejecting startup.
  548. await this.forceKillGroup(sandbox, handle, handle.pid)
  549. this.markQuiescent()
  550. }
  551. private async terminateRemote(): Promise<void> {
  552. try {
  553. await this.terminateRemoteInSandbox()
  554. } catch (error: unknown) {
  555. if (error instanceof SandboxNotFoundError) {
  556. this.markQuiescent()
  557. return
  558. }
  559. throw error
  560. }
  561. }
  562. private async terminateRemoteInSandbox(): Promise<void> {
  563. const handle = await this.commandState.promise
  564. if (handle === undefined) {
  565. this.markQuiescent()
  566. return
  567. }
  568. if (!isValidProcessId(handle.pid) && this.remotePid <= 0) {
  569. await handle.kill()
  570. this.markQuiescent()
  571. return
  572. }
  573. const sandbox = await this.runtime.getSandbox()
  574. const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
  575. await this.terminateGroup(sandbox, handle, processGroupId)
  576. }
  577. private async terminateGroup(sandbox: Sandbox, handle: CommandHandle, processGroupId: number): Promise<void> {
  578. this.terminationSignal = 'SIGTERM'
  579. try {
  580. await signalRemoteGroups(sandbox, this.controlEnvs, [processGroupId], 'TERM')
  581. if (await this.waitForGroupExit(sandbox, processGroupId)) {
  582. this.markQuiescent()
  583. return
  584. }
  585. } catch (_gracefulTerminationFailure) {
  586. // Failed TERM delivery or observation cannot prove exit; force cleanup still owns the group.
  587. }
  588. this.terminationSignal = 'SIGKILL'
  589. await this.forceKillGroup(sandbox, handle, processGroupId)
  590. this.markQuiescent()
  591. }
  592. private async forceKillGroup(sandbox: Sandbox, handle: CommandHandle, processGroupId: number): Promise<void> {
  593. try {
  594. await signalRemoteGroups(sandbox, this.controlEnvs, [processGroupId], 'KILL')
  595. } catch (_processGroupKillFailure) {
  596. // SDK kill and the final liveness probe remain independent cleanup paths.
  597. }
  598. try {
  599. await handle.kill()
  600. } catch (_sdkKillFailure) {
  601. // The final liveness probe, not either transport's self-report, proves cleanup.
  602. }
  603. if (await this.waitForGroupExit(sandbox, processGroupId)) return
  604. throw new Error(`subprocess-e2b: remote process group ${processGroupId} remained live after force termination`)
  605. }
  606. private async waitForGroupExit(sandbox: Sandbox, processGroupId: number): Promise<boolean> {
  607. const deadline = Date.now() + this.spec.graceMs
  608. while (await this.groupAlive(sandbox, processGroupId)) {
  609. if (Date.now() >= deadline) return false
  610. await waitTick(this.pollMs)
  611. }
  612. return true
  613. }
  614. private throwTerminationFailure(): void {
  615. if (this.terminationFailure !== undefined) throw this.terminationFailure
  616. }
  617. private async groupAlive(sandbox: Sandbox, pid: number, signal?: AbortSignal): Promise<boolean> {
  618. const result = await sandbox.commands.run(
  619. `set -o pipefail; ps -eo pgid=,stat= | awk '$1 == ${pid} && $2 !~ /^[ZXx]/ { live=1 } END { if (live) print "live" }'`,
  620. commandOpts(this.controlEnvs, signal),
  621. ).catch((error: unknown) => {
  622. if (signal?.aborted === true) return undefined
  623. if (error instanceof SandboxNotFoundError) return { exitCode: 0, stdout: '', stderr: '' }
  624. throw error
  625. })
  626. return result?.stdout.trim() === 'live'
  627. }
  628. private async finalizeSpills(sandbox: Sandbox): Promise<void> {
  629. const removals: Promise<void>[] = []
  630. const collect = (mode: SubprocessOutputMode, reader: E2BOutputReader | undefined, path: string): void => {
  631. if (!hasSpill(mode)) return
  632. // A spill mode is a collect mode, so construction always created its reader.
  633. const size = (reader as E2BOutputReader).size
  634. if (this.outputDrainExpired || size <= mode.maxBytes || size > mode.spill.maxBytes) {
  635. removals.push(sandbox.files.remove(path).catch((_adapterPrivateSpillRemovalFailure: unknown) => {
  636. // The command outcome is authoritative; owner teardown bounds private residue.
  637. }))
  638. }
  639. }
  640. collect(this.spec.stdio.stdout, this.stdoutReader, this.paths.stdout)
  641. collect(this.spec.stdio.stderr, this.stderrReader, this.paths.stderr)
  642. await Promise.all(removals)
  643. }
  644. private async removeFailedState(sandbox: Sandbox): Promise<void> {
  645. const failures: Error[] = []
  646. for (const path of [this.paths.environment, this.stateDir]) {
  647. try {
  648. await sandbox.files.remove(path)
  649. } catch (error: unknown) {
  650. if (!(error instanceof FileNotFoundError)) failures.push(asError(error))
  651. }
  652. }
  653. if (failures.length > 0) {
  654. throw new AggregateError(failures, 'subprocess-e2b: failed to remove private command state')
  655. }
  656. }
  657. }