subprocess.spec.ts 70 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752
  1. import { once } from 'node:events'
  2. import { Context } from 'cordis'
  3. import {
  4. CommandExitError,
  5. FileNotFoundError,
  6. SandboxNotFoundError,
  7. type CommandHandle,
  8. type CommandResult,
  9. type Sandbox,
  10. } from '@deepseek-ai/dsh-e2b'
  11. import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
  12. import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  13. import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
  14. import * as E2BSubprocessInvariant from '../src/invariant.ts'
  15. import { E2BBase64Decoder, E2B_OUTPUT_COMPLETE_FRAME, E2BOutputReader } from '../src/output.ts'
  16. import { E2BSubprocessHandle } from '../src/process.ts'
  17. import InvariantService from '@deepseek-ai/dsh-invariants'
  18. import { describe, expect, it, vi } from 'vitest'
  19. function commandError(exitCode: number): CommandExitError {
  20. return new CommandExitError({ exitCode, stdout: '', stderr: '', error: `exit ${exitCode}` })
  21. }
  22. interface StartOptions {
  23. background: true
  24. cwd: string
  25. stdin: boolean
  26. timeoutMs: number
  27. signal?: AbortSignal
  28. envs?: Record<string, string>
  29. onStdout?: (data: string) => void | Promise<void>
  30. onStderr?: (data: string) => void | Promise<void>
  31. }
  32. class FakeCommandHandle {
  33. pid = 4242
  34. readonly sent: Array<string | Uint8Array> = []
  35. closes = 0
  36. kills = 0
  37. disconnects = 0
  38. killError: unknown
  39. killResult = true
  40. disconnectError: unknown
  41. private readonly result = Promise.withResolvers<CommandResult>()
  42. private settled = false
  43. constructor(private readonly onKill: () => void = () => {}) {}
  44. wait(): Promise<CommandResult> {
  45. return this.result.promise
  46. }
  47. async sendStdin(data: string | Uint8Array): Promise<void> {
  48. this.sent.push(data)
  49. }
  50. async closeStdin(): Promise<void> {
  51. this.closes += 1
  52. }
  53. async kill(): Promise<boolean> {
  54. this.kills += 1
  55. if (this.killError !== undefined) throw this.killError
  56. this.onKill()
  57. return this.killResult
  58. }
  59. async disconnect(): Promise<void> {
  60. this.disconnects += 1
  61. if (this.disconnectError !== undefined) throw this.disconnectError
  62. }
  63. succeed(exitCode = 0): void {
  64. if (this.settled) return
  65. this.settled = true
  66. this.result.resolve({ exitCode, stdout: '', stderr: '' })
  67. }
  68. fail(exitCode: number): void {
  69. if (this.settled) return
  70. this.settled = true
  71. this.result.reject(commandError(exitCode))
  72. }
  73. crash(error: unknown): void {
  74. if (this.settled) return
  75. this.settled = true
  76. this.result.reject(error)
  77. }
  78. }
  79. class FakeSandbox {
  80. readonly handle: FakeCommandHandle
  81. readonly commandsSeen: string[] = []
  82. readonly writtenFiles: string[][] = []
  83. readonly writtenFileData = new Map<string, string>()
  84. readonly removed: string[] = []
  85. readonly directories: string[] = []
  86. startOptions: StartOptions | undefined
  87. backgroundError: unknown
  88. envError: unknown
  89. statusError: unknown
  90. nextRemoveError: unknown
  91. probeError: unknown
  92. signalError: unknown
  93. readonly signalErrors: unknown[] = []
  94. trapsTerm = false
  95. delaysKill = false
  96. delaysKillCompletion = false
  97. sdkKillStops = true
  98. alive = true
  99. zombieOnly = false
  100. ambient = 'PATH=/ambient/bin\0KEEP=safe\0UNICODE=你好\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
  101. environmentHome = '/home/user'
  102. environmentWire: string | undefined
  103. environmentRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
  104. processGroupId = '4242\n'
  105. exitStatus = ''
  106. statusReads = 0
  107. readonly processGroupReads: string[] = []
  108. afterStatusRead: (() => void) | undefined
  109. beforeProbe: (() => void) | undefined
  110. afterProbe: (() => void) | undefined
  111. private startGate: Promise<void> | undefined
  112. private openStart: (() => void) | undefined
  113. private processGroupReadGate: Promise<void> | undefined
  114. private openProcessGroupRead: (() => void) | undefined
  115. private signalGate: Promise<void> | undefined
  116. private openSignal: (() => void) | undefined
  117. constructor() {
  118. this.handle = new FakeCommandHandle(() => {
  119. if (this.sdkKillStops) {
  120. this.alive = false
  121. this.handle.fail(137)
  122. }
  123. })
  124. }
  125. deferStart(): void {
  126. const gate = Promise.withResolvers<undefined>()
  127. this.startGate = gate.promise
  128. this.openStart = () => { gate.resolve(undefined) }
  129. }
  130. releaseStart(): void {
  131. this.openStart?.()
  132. }
  133. deferProcessGroupRead(): void {
  134. const gate = Promise.withResolvers<undefined>()
  135. this.processGroupReadGate = gate.promise
  136. this.openProcessGroupRead = () => { gate.resolve(undefined) }
  137. }
  138. releaseProcessGroupRead(): void {
  139. this.openProcessGroupRead?.()
  140. }
  141. deferSignals(): void {
  142. const gate = Promise.withResolvers<undefined>()
  143. this.signalGate = gate.promise
  144. this.openSignal = () => { gate.resolve(undefined) }
  145. }
  146. releaseSignals(): void {
  147. this.openSignal?.()
  148. }
  149. finish(exitCode = 0): void {
  150. this.alive = false
  151. void this.completeOutput().then(
  152. () => {
  153. if (exitCode === 0) this.handle.succeed(0)
  154. else this.handle.fail(exitCode)
  155. },
  156. (error: unknown) => { this.handle.crash(error) },
  157. )
  158. }
  159. async completeOutput(): Promise<void> {
  160. await Promise.all([
  161. this.stdoutWire(`${E2B_OUTPUT_COMPLETE_FRAME}\n`),
  162. this.stderrWire(`${E2B_OUTPUT_COMPLETE_FRAME}\n`),
  163. ])
  164. }
  165. async stdout(data: string): Promise<void> {
  166. await this.stdoutWire(data.length === 0 ? '' : `${Buffer.from(data).toString('base64')}\n`)
  167. }
  168. async stderr(data: string): Promise<void> {
  169. await this.stderrWire(data.length === 0 ? '' : `${Buffer.from(data).toString('base64')}\n`)
  170. }
  171. async stdoutWire(data: string): Promise<void> {
  172. await this.startOptions?.onStdout?.(data)
  173. }
  174. async stderrWire(data: string): Promise<void> {
  175. await this.startOptions?.onStderr?.(data)
  176. }
  177. readonly sandbox = {
  178. sandboxId: 'fake',
  179. files: {
  180. makeDir: async (path: string): Promise<boolean> => {
  181. this.directories.push(path)
  182. return true
  183. },
  184. write: async (files: Array<{ path: string; data: string }>): Promise<object[]> => {
  185. this.writtenFiles.push(files.map(file => file.path))
  186. for (const file of files) this.writtenFileData.set(file.path, file.data)
  187. return files.map(() => ({}))
  188. },
  189. read: async (path: string): Promise<string> => {
  190. if (!path.endsWith('/exit-code')) {
  191. await this.processGroupReadGate
  192. return this.processGroupReads.shift() ?? this.processGroupId
  193. }
  194. if (this.statusError !== undefined) {
  195. const error = this.statusError
  196. this.statusError = undefined
  197. throw error
  198. }
  199. this.statusReads += 1
  200. this.afterStatusRead?.()
  201. return this.exitStatus
  202. },
  203. remove: async (path: string): Promise<void> => {
  204. this.removed.push(path)
  205. if (this.nextRemoveError !== undefined) {
  206. const error = this.nextRemoveError
  207. this.nextRemoveError = undefined
  208. throw error
  209. }
  210. },
  211. },
  212. commands: {
  213. run: async (command: string, options?: StartOptions | { signal?: AbortSignal }): Promise<CommandHandle | CommandResult> => {
  214. this.commandsSeen.push(command)
  215. if (command.includes('env -0 | base64')) {
  216. await this.environmentRequest?.(options?.signal)
  217. if (this.envError !== undefined) throw this.envError
  218. return {
  219. exitCode: 0,
  220. stdout: this.environmentWire ?? [this.environmentHome, this.ambient]
  221. .map(value => Buffer.from(value).toString('base64'))
  222. .join('\n'),
  223. stderr: '',
  224. }
  225. }
  226. if (command.startsWith('set -o pipefail; ps -eo pgid=,stat=')) {
  227. this.beforeProbe?.()
  228. if (options?.signal?.aborted === true) throw new DOMException('aborted', 'AbortError')
  229. if (this.probeError !== undefined) {
  230. const error = this.probeError
  231. this.probeError = undefined
  232. throw error
  233. }
  234. const stdout = this.alive && !this.zombieOnly ? 'live\n' : ''
  235. this.afterProbe?.()
  236. return { exitCode: 0, stdout, stderr: '' }
  237. }
  238. if (command.startsWith('kill -TERM ')) {
  239. await this.signalGate
  240. const error = this.signalErrors.shift() ?? this.signalError
  241. if (error !== undefined) {
  242. if (this.signalErrors.length === 0) this.signalError = undefined
  243. throw error
  244. }
  245. if (!this.trapsTerm) {
  246. this.alive = false
  247. this.handle.fail(143)
  248. }
  249. return { exitCode: 0, stdout: '', stderr: '' }
  250. }
  251. if (command.startsWith('kill -KILL ')) {
  252. await this.signalGate
  253. const error = this.signalErrors.shift() ?? this.signalError
  254. if (error !== undefined) {
  255. if (this.signalErrors.length === 0) this.signalError = undefined
  256. throw error
  257. }
  258. if (!this.delaysKill) this.alive = false
  259. if (!this.delaysKillCompletion) this.handle.fail(137)
  260. return { exitCode: 0, stdout: '', stderr: '' }
  261. }
  262. if ((options as StartOptions | undefined)?.background === true) {
  263. this.startOptions = options as StartOptions
  264. await this.startGate
  265. if (this.backgroundError !== undefined) throw this.backgroundError
  266. return this.handle as unknown as CommandHandle
  267. }
  268. return { exitCode: 0, stdout: '', stderr: '' }
  269. },
  270. },
  271. } as unknown as Sandbox
  272. }
  273. function spec(overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
  274. return {
  275. argv: ['bash', '-c', 'printf ok'],
  276. cwd: '/workspace',
  277. stdio: {
  278. stdin: 'ignore',
  279. stdout: { maxBytes: 4, spill: { maxBytes: 16 } },
  280. stderr: { maxBytes: 4 },
  281. },
  282. graceMs: 5,
  283. ...overrides,
  284. }
  285. }
  286. function runtime(fake: FakeSandbox, getSandbox: () => Promise<Sandbox> = async () => fake.sandbox): E2BSandboxService {
  287. return {
  288. cwd: '/workspace',
  289. runtimeRoot: '/workspace/.dsh-e2b',
  290. getSandbox,
  291. } as unknown as E2BSandboxService
  292. }
  293. async function flush(): Promise<void> {
  294. await new Promise(resolve => setTimeout(resolve, 0))
  295. }
  296. describe('E2BOutputReader', () => {
  297. it('decodes base64 across arbitrary callback boundaries and rejects malformed framing', () => {
  298. const decoder = new E2BBase64Decoder()
  299. expect(decoder.push('')).toEqual(Buffer.alloc(0))
  300. expect(decoder.push('5')).toEqual(Buffer.alloc(0))
  301. expect(decoder.push('L2')).toEqual(Buffer.alloc(0))
  302. expect(decoder.push('g\n').toString()).toBe('你')
  303. expect(decoder.push('YQ==\nYg==\n').toString()).toBe('ab')
  304. expect(decoder.push(`${Buffer.from([0, 255]).toString('base64')}\n`)).toEqual(Buffer.from([0, 255]))
  305. expect(decoder.push(`${E2B_OUTPUT_COMPLETE_FRAME}\n`)).toEqual(Buffer.alloc(0))
  306. decoder.finish()
  307. expect(() => new E2BBase64Decoder().push('%\n')).toThrow('invalid base64')
  308. expect(() => new E2BBase64Decoder().push('AB==\n')).toThrow('invalid base64')
  309. expect(() => decoder.push(`${E2B_OUTPUT_COMPLETE_FRAME}\n`)).toThrow('duplicate output transport completion')
  310. expect(() => decoder.push('YQ==\n')).toThrow('continued after completion')
  311. const truncated = new E2BBase64Decoder()
  312. truncated.push('YQ')
  313. expect(() => { truncated.finish() }).toThrow('truncated base64')
  314. expect(() => { new E2BBase64Decoder().finish() }).toThrow('incomplete output transport')
  315. const interrupted = new E2BBase64Decoder()
  316. interrupted.push('YQ')
  317. expect(() => { interrupted.finish(false) }).not.toThrow()
  318. })
  319. it('keeps a byte-exact tail with independent whole-stream cursors', () => {
  320. const reader = new E2BOutputReader(4, 10, '/remote/spill')
  321. reader.push(Buffer.alloc(0))
  322. reader.push(Buffer.from('ab'))
  323. reader.push(Buffer.from('cdef'))
  324. expect(reader.size).toBe(6)
  325. expect(reader.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true, spillPath: '/remote/spill' })
  326. expect(reader.readFrom(2)).toEqual({ text: 'cdef', nextOffset: 6, lossy: false })
  327. expect(reader.readFrom(5)).toEqual({ text: 'f', nextOffset: 6, lossy: false })
  328. expect(reader.readFrom(99)).toEqual({ text: '', nextOffset: 6, lossy: false })
  329. reader.invalidateSpill()
  330. expect(reader.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true })
  331. })
  332. it('drops whole head chunks and withholds absent or over-cap spills', () => {
  333. const withoutSpill = new E2BOutputReader(2, undefined, '/unused')
  334. withoutSpill.push(Buffer.from('ab'))
  335. withoutSpill.push(Buffer.from('cd'))
  336. expect(withoutSpill.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
  337. const overCap = new E2BOutputReader(2, 3, '/too-small')
  338. overCap.push(Buffer.from('abcd'))
  339. expect(overCap.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
  340. })
  341. })
  342. describe('E2BSubprocessHandle', () => {
  343. it('starts asynchronously, keeps secrets out of the command, and supports deferred piped stdin/output', async () => {
  344. const fake = new FakeSandbox()
  345. fake.processGroupId = '4343\n'
  346. fake.deferStart()
  347. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  348. argv: ['tool', 'argument with spaces'],
  349. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 8, spill: { maxBytes: 32 } } },
  350. env: {
  351. PATH: '/bin',
  352. 'FOO-BAR': 'hyphen-value',
  353. '--split-string': 'literal-value',
  354. DEEPSEEK_API_KEY: 'explicit-secret',
  355. DSH_MODE: 'test',
  356. // The seam's tombstone: an explicit undefined removes the ambient entry.
  357. KEEP: undefined,
  358. },
  359. }), '/workspace/.dsh-e2b/processes/one')
  360. expect(handle.pid).toBe(-1)
  361. handle.stdin!.write('hello')
  362. handle.stdin!.end()
  363. fake.releaseStart()
  364. await flush()
  365. expect(handle.pid).toBe(4343)
  366. expect(fake.handle.sent.map(value => String(value))).toEqual(['hello'])
  367. expect(fake.handle.closes).toBe(1)
  368. const controlEnvs = fake.startOptions?.envs
  369. expect(controlEnvs?.HOME).toMatch(/^\/\.dsh-e2b-control-/)
  370. expect(controlEnvs).toEqual({
  371. TERM: 'dumb',
  372. NPM_TOKEN: '',
  373. DSH_STALE: '',
  374. HOME: controlEnvs?.HOME,
  375. })
  376. const command = fake.commandsSeen.find(value => value.includes('exec "$dsh_e2b_env_bin" -i'))!
  377. expect(command).toContain('"$dsh_e2b_setsid" --wait -- "$dsh_e2b_bash" -c')
  378. expect(command).not.toContain('DEEPSEEK_API_KEY')
  379. expect(command).not.toContain('DSH_MODE')
  380. expect(command).not.toContain('FOO-BAR')
  381. expect(command).not.toContain('explicit-secret')
  382. expect(command).not.toContain('hyphen-value')
  383. expect(command).not.toContain('${!dsh_e2b_name}')
  384. const environmentProbe = fake.commandsSeen.find(value => value.includes('env -0 | base64'))
  385. expect(environmentProbe).toContain('getent passwd "$(id -u)"')
  386. expect(environmentProbe).toContain('test -n "$dsh_e2b_home" -a -d "$dsh_e2b_home"')
  387. expect(environmentProbe).not.toContain('"$PWD"')
  388. expect(command).toContain('mapfile -d')
  389. expect(command).toContain('dsh_e2b_node="$(command -v node)"')
  390. expect(command).toContain('"$dsh_e2b_env_bin" -i "$dsh_e2b_node" -e')
  391. expect(command).toContain('"$dsh_e2b_env_bin" -i -- "${dsh_e2b_env[@]}" "$@"')
  392. expect(command).toContain('exec "$dsh_e2b_env_bin" -i -- "${dsh_e2b_env[@]}"')
  393. expect(command).toContain('>&2 2>/dev/null')
  394. expect(command).not.toContain('2>/dev/null >&2')
  395. expect(command).toContain('base64')
  396. expect(fake.writtenFiles[0]).toEqual([
  397. '/workspace/.dsh-e2b/processes/one/pid',
  398. '/workspace/.dsh-e2b/processes/one/exit-code',
  399. '/workspace/.dsh-e2b/processes/one/environment',
  400. '/workspace/.dsh-e2b/processes/one/stderr.log',
  401. ])
  402. expect(fake.writtenFileData.get('/workspace/.dsh-e2b/processes/one/environment')).toBe(
  403. 'PATH=/bin\0UNICODE=你好\0HOME=/home/user\0FOO-BAR=hyphen-value\0--split-string=literal-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0',
  404. )
  405. let piped = ''
  406. handle.stdout!.on('data', (chunk) => { piped += String(chunk) })
  407. await fake.stdout('pipe-data')
  408. await fake.stderr('err')
  409. fake.finish()
  410. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  411. expect(piped).toBe('pipe-data')
  412. expect(handle.collected.stderr!.readFrom(0)).toMatchObject({ text: 'err', lossy: false })
  413. expect(fake.removed).toContain('/workspace/.dsh-e2b/processes/one/stderr.log')
  414. await expect(handle.waitForExit()).resolves.toBe(true)
  415. })
  416. it('rejects malformed environment entries before command start', async () => {
  417. for (const env of [{ 'BAD=NAME': 'x' }, { BAD: 'x\0INJECTED=1' }]) {
  418. const fake = new FakeSandbox()
  419. const handle = new E2BSubprocessHandle(runtime(fake), spec({ env }), '/runtime/invalid-environment')
  420. await expect(handle.done).rejects.toThrow('environment entries')
  421. expect(fake.startOptions).toBeUndefined()
  422. expect(fake.removed).toContain('/runtime/invalid-environment')
  423. }
  424. })
  425. it('preserves UTF-8 bytes when the ASCII transport is split across callbacks', async () => {
  426. const fake = new FakeSandbox()
  427. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  428. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
  429. }), '/runtime/split-utf8')
  430. await flush()
  431. const chunks: Buffer[] = []
  432. handle.stdout!.on('data', (chunk: Buffer) => { chunks.push(chunk) })
  433. for (const character of `${Buffer.from('A你好B').toString('base64')}\n`) {
  434. await fake.stdoutWire(character)
  435. }
  436. fake.finish()
  437. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  438. expect(Buffer.concat(chunks).toString('utf8')).toBe('A你好B')
  439. })
  440. it('rejects malformed output transport without confusing it with a consumer sink failure', async () => {
  441. const fake = new FakeSandbox()
  442. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/malformed-output')
  443. await flush()
  444. await fake.stdoutWire('%\n')
  445. fake.finish()
  446. await expect(handle.done).rejects.toThrow('invalid base64 output transport')
  447. const stderrFake = new FakeSandbox()
  448. const stderrHandle = new E2BSubprocessHandle(runtime(stderrFake), spec(), '/runtime/malformed-stderr')
  449. await flush()
  450. await stderrFake.stderrWire('%\n')
  451. stderrFake.finish()
  452. await expect(stderrHandle.done).rejects.toThrow('invalid base64 output transport')
  453. })
  454. it('rejects a naturally completed command whose encoder omits its completion frame', async () => {
  455. const fake = new FakeSandbox()
  456. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/incomplete-output')
  457. await flush()
  458. fake.alive = false
  459. fake.handle.succeed(0)
  460. await expect(handle.done).rejects.toThrow('incomplete output transport')
  461. })
  462. it('bounds descendant-held output draining and withholds the incomplete spill', async () => {
  463. const fake = new FakeSandbox()
  464. const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 5 }), '/runtime/drain-bound')
  465. await flush()
  466. await fake.stdout('leader-output')
  467. fake.exitStatus = '0\n'
  468. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  469. expect(fake.handle.disconnects).toBe(1)
  470. expect(handle.collected.stdout?.readFrom(0)).toEqual({
  471. text: 'tput',
  472. nextOffset: 13,
  473. lossy: true,
  474. })
  475. expect(fake.removed).toContain('/runtime/drain-bound/stdout.log')
  476. handle.terminate()
  477. await expect(handle.waitForExit()).resolves.toBe(true)
  478. })
  479. it('releases an inherited-output callback blocked on host backpressure at drain expiry', async () => {
  480. const fake = new FakeSandbox()
  481. const written: string[] = []
  482. const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: Uint8Array) => {
  483. written.push(Buffer.from(chunk).toString())
  484. return false
  485. }) as typeof process.stdout.write)
  486. try {
  487. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  488. graceMs: 5,
  489. stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 4 } },
  490. }), '/runtime/inherit-backpressure')
  491. await flush()
  492. let callbackSettled = false
  493. const blocked = fake.stdout('blocked bytes').then(() => { callbackSettled = true })
  494. await flush()
  495. expect(callbackSettled).toBe(false)
  496. fake.exitStatus = '0\n'
  497. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  498. await blocked
  499. expect(callbackSettled).toBe(true)
  500. expect(written.join('')).toBe('blocked bytes')
  501. expect(fake.handle.disconnects).toBe(1)
  502. handle.terminate()
  503. await expect(handle.waitForExit()).resolves.toBe(true)
  504. } finally {
  505. stdoutWrite.mockRestore()
  506. }
  507. })
  508. it('waits for lossless raw-pipe output after the direct status is published', async () => {
  509. const fake = new FakeSandbox()
  510. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  511. graceMs: 1,
  512. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
  513. }), '/runtime/pipe-drain')
  514. let output = ''
  515. handle.stdout!.on('data', (chunk) => { output += String(chunk) })
  516. await flush()
  517. fake.exitStatus = '0\n'
  518. let settled = false
  519. void handle.done.then(() => { settled = true })
  520. await new Promise(resolve => setTimeout(resolve, 50))
  521. expect(settled).toBe(false)
  522. expect(fake.handle.disconnects).toBe(0)
  523. expect(fake.statusReads).toBe(0)
  524. await fake.stdout('complete protocol frame')
  525. fake.finish()
  526. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  527. expect(output).toBe('complete protocol frame')
  528. expect(fake.statusReads).toBe(1)
  529. })
  530. it('accepts clean encoder completion inside the output-drain grace', async () => {
  531. const fake = new FakeSandbox()
  532. const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 100 }), '/runtime/drain-complete')
  533. await flush()
  534. fake.exitStatus = '0\n'
  535. fake.afterStatusRead = () => {
  536. fake.afterStatusRead = undefined
  537. setTimeout(() => { fake.finish() }, 0)
  538. }
  539. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  540. expect(fake.handle.disconnects).toBe(0)
  541. })
  542. it('preserves a published exit code when requested termination outlives output draining', async () => {
  543. const fake = new FakeSandbox()
  544. fake.trapsTerm = true
  545. fake.delaysKill = true
  546. fake.delaysKillCompletion = true
  547. fake.sdkKillStops = false
  548. const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 5 }), '/runtime/drain-signal')
  549. await flush()
  550. handle.terminate()
  551. await vi.waitFor(() => { expect(fake.commandsSeen).toContain('kill -KILL -- -4242') })
  552. fake.exitStatus = '0\n'
  553. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  554. expect(fake.handle.disconnects).toBe(1)
  555. fake.alive = false
  556. handle.terminate()
  557. await expect(handle.waitForExit()).resolves.toBe(true)
  558. })
  559. it('preserves a published nonzero exit code when termination settles the SDK inside the drain grace', async () => {
  560. const fake = new FakeSandbox()
  561. const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 100 }), '/runtime/drain-signal-settled')
  562. await flush()
  563. fake.exitStatus = '7\n'
  564. fake.afterStatusRead = () => {
  565. fake.afterStatusRead = undefined
  566. handle.terminate()
  567. }
  568. await expect(handle.done).resolves.toEqual({ exitCode: 7, signal: null })
  569. await expect(handle.waitForExit()).resolves.toBe(true)
  570. })
  571. it('rejects an invalid direct-command exit status', async () => {
  572. const fake = new FakeSandbox()
  573. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/invalid-status')
  574. await flush()
  575. fake.exitStatus = '999\n'
  576. await expect(handle.done).rejects.toThrow('invalid exit code')
  577. handle.terminate()
  578. await expect(handle.waitForExit()).resolves.toBe(true)
  579. })
  580. it('rolls back a published process group before rejecting a monitoring failure', async () => {
  581. const fake = new FakeSandbox()
  582. fake.statusError = new Error('status transport failed')
  583. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/status-failure')
  584. await expect(handle.done).rejects.toThrow('status transport failed')
  585. expect(fake.commandsSeen).toContain('kill -TERM -- -4242')
  586. expect(fake.alive).toBe(false)
  587. await expect(handle.waitForExit()).resolves.toBe(true)
  588. const failed = new FakeSandbox()
  589. failed.statusError = new Error('status transport failed')
  590. failed.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed'))
  591. failed.handle.killError = new Error('SDK kill failed')
  592. const retained = new E2BSubprocessHandle(runtime(failed), spec({ graceMs: 1 }), '/runtime/status-cleanup-failure')
  593. await expect(retained.done).rejects.toThrow(
  594. 'command monitoring failed and process-group rollback did not reach quiescence',
  595. )
  596. expect(failed.alive).toBe(true)
  597. failed.handle.killError = undefined
  598. retained.terminate()
  599. await expect(retained.waitForExit()).resolves.toBe(true)
  600. })
  601. it('surfaces deferred piped-stdin write and close failures as stream errors', async () => {
  602. const writeFake = new FakeSandbox()
  603. writeFake.deferStart()
  604. vi.spyOn(writeFake.handle, 'sendStdin').mockRejectedValueOnce('stdin rejected')
  605. const writeHandle = new E2BSubprocessHandle(runtime(writeFake), spec({
  606. stdio: { stdin: 'pipe', stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } },
  607. }), '/runtime/stdin-write-error')
  608. const writeError = once(writeHandle.stdin!, 'error')
  609. writeHandle.stdin!.write('input')
  610. writeFake.releaseStart()
  611. await expect(writeError).resolves.toMatchObject([{ message: 'stdin rejected' }])
  612. writeFake.finish()
  613. await writeHandle.done
  614. const closeFake = new FakeSandbox()
  615. vi.spyOn(closeFake.handle, 'closeStdin').mockRejectedValueOnce(new Error('close rejected'))
  616. const closeHandle = new E2BSubprocessHandle(runtime(closeFake), spec({
  617. stdio: { stdin: 'pipe', stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } },
  618. }), '/runtime/stdin-close-error')
  619. await flush()
  620. const closeError = once(closeHandle.stdin!, 'error')
  621. closeHandle.stdin!.end()
  622. await expect(closeError).resolves.toMatchObject([{ message: 'close rejected' }])
  623. closeFake.finish()
  624. await closeHandle.done
  625. })
  626. it('collects bounded tails, retains valid spills, and maps natural nonzero exits', async () => {
  627. const fake = new FakeSandbox()
  628. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  629. stdio: {
  630. stdin: { data: 'batch' },
  631. stdout: { maxBytes: 4, spill: { maxBytes: 16 } },
  632. stderr: { maxBytes: 3 },
  633. },
  634. }), '/runtime/two')
  635. await flush()
  636. await fake.stdout('abcdef')
  637. await fake.stderr('12345')
  638. fake.finish(7)
  639. await expect(handle.done).resolves.toEqual({ exitCode: 7, signal: null })
  640. expect(fake.handle.sent).toEqual(['batch'])
  641. expect(fake.handle.closes).toBe(1)
  642. expect(handle.collected.stdout!.readFrom(0)).toEqual({
  643. text: 'cdef',
  644. nextOffset: 6,
  645. lossy: true,
  646. spillPath: '/runtime/two/stdout.log',
  647. })
  648. expect(handle.collected.stderr!.readFrom(0)).toEqual({ text: '345', nextOffset: 5, lossy: true })
  649. expect(fake.removed).not.toContain('/runtime/two/stdout.log')
  650. })
  651. it('removes a spill once the complete stream exceeds its cap', async () => {
  652. const fake = new FakeSandbox()
  653. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  654. stdio: { stdin: 'ignore', stdout: { maxBytes: 2, spill: { maxBytes: 3 } }, stderr: 'inherit' },
  655. }), '/runtime/oversize')
  656. await flush()
  657. await fake.stdout('abcd')
  658. await fake.stderr('')
  659. fake.finish()
  660. await handle.done
  661. expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
  662. expect(fake.removed).toContain('/runtime/oversize/stdout.log')
  663. const command = fake.commandsSeen.find(value => value.includes('dsh_e2b_tee='))!
  664. expect(command).toContain('"$dsh_e2b_head" -c 3')
  665. expect(command).toContain('/runtime/oversize/stdout.log')
  666. expect(command).toContain('"$dsh_e2b_tee" --output-error=warn-nopipe')
  667. expect(command).not.toContain('tee -a')
  668. })
  669. it('contains remote spill-removal failures and routes empty inherited output', async () => {
  670. const fake = new FakeSandbox()
  671. fake.nextRemoveError = new Error('already removed')
  672. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  673. stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 4, spill: { maxBytes: 8 } } },
  674. }), '/runtime/remove-error')
  675. await flush()
  676. await fake.stdout('')
  677. await fake.stderr('')
  678. fake.finish()
  679. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  680. expect(fake.removed).toContain('/runtime/remove-error/stderr.log')
  681. })
  682. it('terminates a process group with TERM and reports the signal outcome', async () => {
  683. const fake = new FakeSandbox()
  684. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/term')
  685. await flush()
  686. handle.terminate()
  687. handle.terminate()
  688. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  689. await expect(handle.waitForExit()).resolves.toBe(true)
  690. expect(fake.commandsSeen).toContain('kill -TERM -- -4242')
  691. expect(fake.commandsSeen).not.toContain('kill -KILL -- -4242')
  692. const signals = fake.commandsSeen.filter(command => command.startsWith('kill -')).length
  693. fake.alive = true
  694. handle.terminate()
  695. await flush()
  696. expect(fake.alive).toBe(true)
  697. expect(fake.commandsSeen.filter(command => command.startsWith('kill -'))).toHaveLength(signals)
  698. })
  699. it('makes termination a permanent no-op after natural quiescence is observed', async () => {
  700. const fake = new FakeSandbox()
  701. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/natural-quiescence')
  702. await flush()
  703. fake.finish()
  704. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  705. await expect(handle.waitForExit()).resolves.toBe(true)
  706. const signals = fake.commandsSeen.filter(command => command.startsWith('kill -')).length
  707. fake.alive = true
  708. handle.terminate()
  709. await flush()
  710. expect(fake.alive).toBe(true)
  711. expect(fake.commandsSeen.filter(command => command.startsWith('kill -'))).toHaveLength(signals)
  712. })
  713. it('treats a zombie-only process group as quiescent', async () => {
  714. const fake = new FakeSandbox()
  715. fake.zombieOnly = true
  716. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/zombie-quiescence')
  717. await flush()
  718. await expect(handle.waitForExit()).resolves.toBe(true)
  719. expect(fake.commandsSeen).toContain(
  720. 'set -o pipefail; ps -eo pgid=,stat= | awk \'$1 == 4242 && $2 !~ /^[ZXx]/ { live=1 } END { if (live) print "live" }\'',
  721. )
  722. fake.finish()
  723. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  724. })
  725. it('keeps proven quiescence after a concurrent termination transport fails', async () => {
  726. const fake = new FakeSandbox()
  727. fake.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed'))
  728. fake.handle.killError = new Error('SDK kill failed')
  729. fake.deferSignals()
  730. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/quiescent-race')
  731. await flush()
  732. handle.terminate()
  733. await vi.waitFor(() => { expect(fake.commandsSeen).toContain('kill -TERM -- -4242') })
  734. fake.alive = false
  735. await expect(handle.waitForExit()).resolves.toBe(true)
  736. fake.probeError = new Error('post-quiescence probe failed')
  737. fake.releaseSignals()
  738. await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) })
  739. fake.finish()
  740. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  741. const signals = fake.commandsSeen.filter(command => command.startsWith('kill -')).length
  742. handle.terminate()
  743. await expect(handle.waitForExit()).resolves.toBe(true)
  744. expect(fake.commandsSeen.filter(command => command.startsWith('kill -'))).toHaveLength(signals)
  745. })
  746. it('escalates a TERM-trapping process group to KILL and uses the SDK kill as fallback', async () => {
  747. const fake = new FakeSandbox()
  748. fake.trapsTerm = true
  749. fake.handle.killError = new Error('already gone')
  750. const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/kill')
  751. await flush()
  752. handle.terminate()
  753. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  754. await expect(handle.waitForExit()).resolves.toBe(true)
  755. expect(fake.commandsSeen).toContain('kill -KILL -- -4242')
  756. expect(fake.handle.kills).toBe(1)
  757. })
  758. it('keeps force cleanup retryable until quiescence is proven', async () => {
  759. const fake = new FakeSandbox()
  760. fake.trapsTerm = true
  761. fake.delaysKill = true
  762. fake.delaysKillCompletion = true
  763. fake.sdkKillStops = false
  764. const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/termination-fence')
  765. await flush()
  766. handle.terminate()
  767. await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) })
  768. await expect(handle.waitForExit()).rejects.toThrow('remained live after force termination')
  769. expect(fake.alive).toBe(true)
  770. fake.delaysKill = false
  771. fake.delaysKillCompletion = false
  772. fake.sdkKillStops = true
  773. handle.terminate()
  774. await expect(handle.waitForExit()).resolves.toBe(true)
  775. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  776. })
  777. it('honors termination requested before asynchronous startup finishes', async () => {
  778. const fake = new FakeSandbox()
  779. fake.deferStart()
  780. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/deferred-kill')
  781. handle.terminate()
  782. fake.releaseStart()
  783. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  784. })
  785. it('aborts a stalled preparation request before reporting startup quiescence', async () => {
  786. const fake = new FakeSandbox()
  787. let preparationSignal: AbortSignal | undefined
  788. fake.environmentRequest = async (signal) => {
  789. preparationSignal = signal
  790. await new Promise<never>((_resolve, reject) => {
  791. const rejectAbort = (): void => {
  792. const reason: unknown = signal?.reason
  793. reject(reason instanceof Error ? reason : new Error(String(reason)))
  794. }
  795. if (signal?.aborted === true) {
  796. rejectAbort()
  797. return
  798. }
  799. signal?.addEventListener('abort', rejectAbort, { once: true })
  800. })
  801. }
  802. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/stalled-preparation')
  803. await vi.waitFor(() => { expect(preparationSignal).toBeDefined() })
  804. handle.terminate()
  805. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  806. await expect(handle.waitForExit()).resolves.toBe(true)
  807. expect(preparationSignal?.aborted).toBe(true)
  808. expect(fake.startOptions).toBeUndefined()
  809. })
  810. it('kills through the provisional SDK handle before process-group publication', async () => {
  811. const fake = new FakeSandbox()
  812. fake.deferProcessGroupRead()
  813. fake.signalErrors.push(commandError(1), commandError(1))
  814. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/pre-publication-kill')
  815. await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() })
  816. handle.terminate()
  817. await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) })
  818. expect(fake.alive).toBe(false)
  819. await expect(handle.waitForExit()).resolves.toBe(true)
  820. fake.releaseProcessGroupRead()
  821. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  822. })
  823. it('does not treat an unsuccessful SDK fallback as provisional group quiescence', async () => {
  824. const fake = new FakeSandbox()
  825. fake.deferProcessGroupRead()
  826. fake.trapsTerm = true
  827. fake.delaysKill = true
  828. fake.delaysKillCompletion = true
  829. fake.sdkKillStops = false
  830. fake.handle.killResult = false
  831. const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/provisional-sdk-false')
  832. await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() })
  833. handle.terminate()
  834. await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) })
  835. await expect(handle.waitForExit()).rejects.toThrow('remained live after force termination')
  836. fake.alive = false
  837. handle.terminate()
  838. await expect(handle.waitForExit()).resolves.toBe(true)
  839. fake.releaseProcessGroupRead()
  840. fake.finish()
  841. await handle.done
  842. })
  843. it('bounds a quiescence observer while provisional termination is awaiting the controller', async () => {
  844. const fake = new FakeSandbox()
  845. fake.deferProcessGroupRead()
  846. const reconnect = Promise.withResolvers<Sandbox>()
  847. let calls = 0
  848. const delayedRuntime = runtime(fake, async () => {
  849. calls += 1
  850. return calls === 1 ? fake.sandbox : await reconnect.promise
  851. })
  852. const handle = new E2BSubprocessHandle(delayedRuntime, spec(), '/runtime/pre-publication-observer')
  853. await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() })
  854. handle.terminate()
  855. const controller = new AbortController()
  856. const waiting = handle.waitForExit(controller.signal)
  857. await flush()
  858. controller.abort()
  859. await expect(waiting).resolves.toBe(false)
  860. reconnect.resolve(fake.sandbox)
  861. await expect(handle.waitForExit()).resolves.toBe(true)
  862. fake.releaseProcessGroupRead()
  863. await handle.done
  864. })
  865. it('proves a provisional group exit when the SDK kill fallback fails', async () => {
  866. const fake = new FakeSandbox()
  867. fake.deferProcessGroupRead()
  868. fake.trapsTerm = true
  869. fake.handle.killError = new Error('SDK kill unavailable')
  870. const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/pre-publication-group-kill')
  871. await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() })
  872. handle.terminate()
  873. await expect(handle.waitForExit()).resolves.toBe(true)
  874. fake.releaseProcessGroupRead()
  875. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  876. })
  877. it('reports failed provisional group and SDK force transports', async () => {
  878. const fake = new FakeSandbox()
  879. fake.deferProcessGroupRead()
  880. fake.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed'))
  881. fake.handle.killError = new Error('SDK kill failed')
  882. const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/pre-publication-failure')
  883. await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() })
  884. handle.terminate()
  885. await expect(handle.waitForExit()).rejects.toThrow('remained live after force termination')
  886. fake.handle.killError = undefined
  887. handle.terminate()
  888. await expect(handle.waitForExit()).resolves.toBe(true)
  889. fake.releaseProcessGroupRead()
  890. await handle.done
  891. const absentGroup = new FakeSandbox()
  892. absentGroup.deferProcessGroupRead()
  893. absentGroup.signalErrors.push(commandError(1), commandError(1))
  894. absentGroup.handle.killError = new Error('SDK kill failed without a provisional group')
  895. const absentHandle = new E2BSubprocessHandle(
  896. runtime(absentGroup),
  897. spec({ graceMs: 1 }),
  898. '/runtime/pre-publication-absent-group',
  899. )
  900. await vi.waitFor(() => { expect(absentGroup.startOptions).toBeDefined() })
  901. absentHandle.terminate()
  902. await expect(absentHandle.waitForExit()).rejects.toThrow('remained live after force termination')
  903. absentGroup.handle.killError = undefined
  904. absentHandle.terminate()
  905. await expect(absentHandle.waitForExit()).resolves.toBe(true)
  906. absentGroup.releaseProcessGroupRead()
  907. await absentHandle.done
  908. const optimisticSdk = new FakeSandbox()
  909. optimisticSdk.deferProcessGroupRead()
  910. optimisticSdk.signalErrors.push(commandError(1), commandError(1))
  911. optimisticSdk.sdkKillStops = false
  912. const optimisticHandle = new E2BSubprocessHandle(
  913. runtime(optimisticSdk),
  914. spec({ graceMs: 1 }),
  915. '/runtime/pre-publication-optimistic-sdk',
  916. )
  917. await vi.waitFor(() => { expect(optimisticSdk.startOptions).toBeDefined() })
  918. optimisticHandle.terminate()
  919. await expect(optimisticHandle.waitForExit()).rejects.toThrow('remained live after force termination')
  920. optimisticHandle.terminate()
  921. await expect(optimisticHandle.waitForExit()).resolves.toBe(true)
  922. optimisticSdk.releaseProcessGroupRead()
  923. await optimisticHandle.done
  924. })
  925. it('honors an already-aborted signal when constructing the asynchronous handle directly', async () => {
  926. const fake = new FakeSandbox()
  927. const handle = new E2BSubprocessHandle(runtime(fake), spec({ signal: AbortSignal.abort('stop') }), '/runtime/pre-aborted')
  928. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  929. })
  930. it('reacts to a signal that aborts after the remote command has started', async () => {
  931. const fake = new FakeSandbox()
  932. const controller = new AbortController()
  933. const handle = new E2BSubprocessHandle(runtime(fake), spec({ signal: controller.signal }), '/runtime/live-abort')
  934. await flush()
  935. controller.abort('stop')
  936. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  937. })
  938. it('can terminate a surviving process group after the command leader settles', async () => {
  939. const fake = new FakeSandbox()
  940. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/surviving-group')
  941. await flush()
  942. await fake.completeOutput()
  943. fake.handle.succeed(0)
  944. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  945. expect(fake.alive).toBe(true)
  946. handle.terminate()
  947. await flush()
  948. const signaled = fake.commandsSeen.includes('kill -TERM -- -4242')
  949. if (!signaled) fake.finish()
  950. await expect(handle.waitForExit()).resolves.toBe(true)
  951. expect(signaled).toBe(true)
  952. })
  953. it('bounds waitForExit while startup or a live group is pending', async () => {
  954. const fake = new FakeSandbox()
  955. fake.deferStart()
  956. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/wait')
  957. const beforeStart = new AbortController()
  958. const pending = handle.waitForExit(beforeStart.signal)
  959. beforeStart.abort()
  960. await expect(pending).resolves.toBe(false)
  961. await expect(handle.waitForExit(AbortSignal.abort())).resolves.toBe(false)
  962. fake.releaseStart()
  963. await flush()
  964. const live = new AbortController()
  965. const liveWait = handle.waitForExit(live.signal)
  966. live.abort()
  967. await expect(liveWait).resolves.toBe(false)
  968. fake.finish()
  969. await handle.done
  970. const terminatingFake = new FakeSandbox()
  971. terminatingFake.deferStart()
  972. const terminating = new E2BSubprocessHandle(runtime(terminatingFake), spec(), '/runtime/wait-termination-start')
  973. terminating.terminate()
  974. const beforeHandle = new AbortController()
  975. const handlePending = terminating.waitForExit(beforeHandle.signal)
  976. beforeHandle.abort()
  977. await expect(handlePending).resolves.toBe(false)
  978. terminatingFake.releaseStart()
  979. await terminating.done
  980. })
  981. it('bounds both sides of the liveness-poll abort race', async () => {
  982. const fake = new FakeSandbox()
  983. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/poll-abort')
  984. await flush()
  985. const beforeTick = new AbortController()
  986. fake.afterProbe = () => { beforeTick.abort(); fake.afterProbe = undefined }
  987. await expect(handle.waitForExit(beforeTick.signal)).resolves.toBe(false)
  988. const duringTick = new AbortController()
  989. fake.afterProbe = () => {
  990. fake.afterProbe = undefined
  991. setTimeout(() => { duringTick.abort() }, 0)
  992. }
  993. await expect(handle.waitForExit(duringTick.signal)).resolves.toBe(false)
  994. const duringProbe = new AbortController()
  995. fake.beforeProbe = () => { duringProbe.abort(); fake.beforeProbe = undefined }
  996. await expect(handle.waitForExit(duringProbe.signal)).resolves.toBe(false)
  997. let racedAbort = false
  998. const raceSignal = {
  999. get aborted() { return racedAbort },
  1000. addEventListener: () => { racedAbort = true },
  1001. removeEventListener: () => {},
  1002. } as unknown as AbortSignal
  1003. await expect(handle.waitForExit(raceSignal)).resolves.toBe(false)
  1004. fake.finish()
  1005. await handle.done
  1006. })
  1007. it('observes a live group across one successful bounded poll', async () => {
  1008. const fake = new FakeSandbox()
  1009. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/poll-success')
  1010. await flush()
  1011. setTimeout(() => { fake.finish() }, 1)
  1012. await expect(handle.waitForExit(new AbortController().signal)).resolves.toBe(true)
  1013. await handle.done
  1014. })
  1015. it('treats startup failure as no live tree and contains readiness rejection', async () => {
  1016. const fake = new FakeSandbox()
  1017. fake.backgroundError = new Error('start failed')
  1018. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/fail')
  1019. await expect(handle.done).rejects.toThrow('start failed')
  1020. expect(handle.pid).toBe(-1)
  1021. expect(fake.removed).toContain('/runtime/fail/environment')
  1022. expect(fake.removed).toContain('/runtime/fail')
  1023. await expect(handle.waitForExit()).resolves.toBe(true)
  1024. handle.terminate()
  1025. const unavailableHandle = new E2BSubprocessHandle(
  1026. runtime(new FakeSandbox(), async () => { throw new Error('sandbox unavailable') }),
  1027. spec(),
  1028. '/runtime/unavailable-start',
  1029. )
  1030. await expect(unavailableHandle.done).rejects.toThrow('sandbox unavailable')
  1031. await expect(unavailableHandle.waitForExit()).resolves.toBe(true)
  1032. const envFailure = new FakeSandbox()
  1033. envFailure.envError = new Error('ambient lookup failed')
  1034. const envHandle = new E2BSubprocessHandle(runtime(envFailure), spec(), '/runtime/env-failure')
  1035. await expect(envHandle.done).rejects.toThrow('ambient lookup failed')
  1036. expect(envFailure.removed).toEqual([])
  1037. const expectEnvironmentFailure = async (name: string, wire: string, message: string): Promise<void> => {
  1038. const fake = new FakeSandbox()
  1039. fake.environmentWire = wire
  1040. const failed = new E2BSubprocessHandle(runtime(fake), spec(), `/runtime/${name}`)
  1041. await expect(failed.done).rejects.toThrow(message)
  1042. }
  1043. const encodedEnvironment = Buffer.from('PATH=/bin\0').toString('base64')
  1044. const encodedHome = Buffer.from('/home/user').toString('base64')
  1045. await expectEnvironmentFailure('malformed-frame', '%', 'invalid base64')
  1046. await expectEnvironmentFailure('malformed-base64', `${encodedHome}\n%`, 'invalid base64')
  1047. await expectEnvironmentFailure(
  1048. 'invalid-utf8-home',
  1049. `${Buffer.from([0xff]).toString('base64')}\n${encodedEnvironment}`,
  1050. 'not valid UTF-8',
  1051. )
  1052. await expectEnvironmentFailure(
  1053. 'invalid-utf8-environment',
  1054. `${encodedHome}\n${Buffer.from([0xff]).toString('base64')}`,
  1055. 'not valid UTF-8',
  1056. )
  1057. await expectEnvironmentFailure(
  1058. 'relative-home',
  1059. `${Buffer.from('home/user').toString('base64')}\n${encodedEnvironment}`,
  1060. 'remote login home is invalid',
  1061. )
  1062. await expectEnvironmentFailure(
  1063. 'nul-home',
  1064. `${Buffer.from('/home/user\0tail').toString('base64')}\n${encodedEnvironment}`,
  1065. 'remote login home is invalid',
  1066. )
  1067. const cleanupFailure = new FakeSandbox()
  1068. cleanupFailure.backgroundError = new Error('start failed before credential consumption')
  1069. cleanupFailure.nextRemoveError = new Error('credential cleanup failed')
  1070. const cleanupHandle = new E2BSubprocessHandle(runtime(cleanupFailure), spec(), '/runtime/cleanup-failure')
  1071. await expect(cleanupHandle.done).rejects.toThrow('command failed and private state cleanup failed')
  1072. const absentState = new FakeSandbox()
  1073. absentState.backgroundError = new Error('start failed after external cleanup')
  1074. absentState.nextRemoveError = new FileNotFoundError('already removed')
  1075. const absentHandle = new E2BSubprocessHandle(runtime(absentState), spec(), '/runtime/absent-state')
  1076. await expect(absentHandle.done).rejects.toThrow('start failed after external cleanup')
  1077. })
  1078. it('bounds a readiness rejection with a still-live caller signal', async () => {
  1079. const fake = new FakeSandbox()
  1080. fake.deferStart()
  1081. fake.backgroundError = new Error('start failed')
  1082. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/fail-with-signal')
  1083. const waiting = handle.waitForExit(new AbortController().signal)
  1084. fake.releaseStart()
  1085. await expect(handle.done).rejects.toThrow('start failed')
  1086. await expect(waiting).resolves.toBe(true)
  1087. })
  1088. it('propagates an unavailable sandbox unless the caller aborts the wait', async () => {
  1089. const fake = new FakeSandbox()
  1090. let calls = 0
  1091. const unavailable = runtime(fake, async () => {
  1092. calls += 1
  1093. if (calls === 1) return fake.sandbox
  1094. throw new Error('connection unavailable')
  1095. })
  1096. const handle = new E2BSubprocessHandle(unavailable, spec(), '/runtime/unavailable')
  1097. await flush()
  1098. await expect(handle.waitForExit()).rejects.toThrow('connection unavailable')
  1099. fake.finish()
  1100. await handle.done
  1101. })
  1102. it('returns false when the caller aborts while reconnecting for liveness', async () => {
  1103. const fake = new FakeSandbox()
  1104. const reconnect = Promise.withResolvers<Sandbox>()
  1105. let calls = 0
  1106. const unavailable = runtime(fake, async () => {
  1107. calls += 1
  1108. return calls === 1 ? fake.sandbox : await reconnect.promise
  1109. })
  1110. const handle = new E2BSubprocessHandle(unavailable, spec(), '/runtime/reconnect-abort')
  1111. await flush()
  1112. const controller = new AbortController()
  1113. const waiting = handle.waitForExit(controller.signal)
  1114. await flush()
  1115. controller.abort()
  1116. reconnect.reject(new Error('connection unavailable'))
  1117. await expect(waiting).resolves.toBe(false)
  1118. fake.finish()
  1119. await handle.done
  1120. })
  1121. it('returns false when a liveness request itself is aborted and surfaces other probe failures', async () => {
  1122. const fake = new FakeSandbox()
  1123. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/probe')
  1124. await flush()
  1125. const controller = new AbortController()
  1126. controller.abort()
  1127. await expect(handle.waitForExit(controller.signal)).resolves.toBe(false)
  1128. fake.probeError = new Error('probe failed')
  1129. await expect(handle.waitForExit()).rejects.toThrow('probe failed')
  1130. fake.finish()
  1131. await handle.done
  1132. })
  1133. it('treats a timeout-killed sandbox as quiescent during liveness probing', async () => {
  1134. const fake = new FakeSandbox()
  1135. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/expired-sandbox')
  1136. await flush()
  1137. fake.finish()
  1138. await handle.done
  1139. fake.probeError = new SandboxNotFoundError('sandbox expired')
  1140. await expect(handle.waitForExit()).resolves.toBe(true)
  1141. })
  1142. it('treats a missing sandbox handle as quiescent during liveness acquisition', async () => {
  1143. const fake = new FakeSandbox()
  1144. let calls = 0
  1145. const handle = new E2BSubprocessHandle(runtime(fake, async () => {
  1146. calls += 1
  1147. if (calls === 1) return fake.sandbox
  1148. throw new SandboxNotFoundError('sandbox expired')
  1149. }), spec(), '/runtime/expired-acquisition')
  1150. await flush()
  1151. await expect(handle.waitForExit()).resolves.toBe(true)
  1152. await fake.completeOutput()
  1153. fake.alive = false
  1154. fake.handle.succeed(0)
  1155. await handle.done
  1156. })
  1157. it('treats sandbox loss during termination as quiescent', async () => {
  1158. const fake = new FakeSandbox()
  1159. let calls = 0
  1160. const handle = new E2BSubprocessHandle(runtime(fake, async () => {
  1161. calls += 1
  1162. if (calls === 1) return fake.sandbox
  1163. throw new SandboxNotFoundError('sandbox expired')
  1164. }), spec(), '/runtime/expired-termination')
  1165. await flush()
  1166. await fake.completeOutput()
  1167. handle.terminate()
  1168. await expect(handle.waitForExit()).resolves.toBe(true)
  1169. fake.alive = false
  1170. fake.handle.succeed(0)
  1171. await handle.done
  1172. })
  1173. it('makes batch stdin close failures best-effort', async () => {
  1174. const fake = new FakeSandbox()
  1175. vi.spyOn(fake.handle, 'sendStdin').mockRejectedValueOnce(new Error('closed'))
  1176. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  1177. stdio: { stdin: { data: 'ignored' }, stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } },
  1178. }), '/runtime/stdin-closed')
  1179. await flush()
  1180. fake.finish()
  1181. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  1182. })
  1183. it('rejects malformed SDK process ids and non-command settlement failures', async () => {
  1184. const invalidPid = new FakeSandbox()
  1185. invalidPid.handle.pid = 0
  1186. const invalid = new E2BSubprocessHandle(runtime(invalidPid), spec(), '/runtime/invalid-pid')
  1187. await expect(invalid.done).rejects.toThrow(/invalid command pid 0/)
  1188. expect(invalidPid.handle.kills).toBe(1)
  1189. expect(invalidPid.removed).toContain('/runtime/invalid-pid/environment')
  1190. await expect(invalid.waitForExit()).resolves.toBe(true)
  1191. const failedRollback = new FakeSandbox()
  1192. failedRollback.handle.pid = 0
  1193. failedRollback.handle.killError = new Error('invalid handle kill failed')
  1194. const retained = new E2BSubprocessHandle(runtime(failedRollback), spec(), '/runtime/invalid-pid-retained')
  1195. await expect(retained.done).rejects.toThrow('invalid command pid rollback did not reach quiescence')
  1196. await expect(retained.waitForExit()).rejects.toThrow('invalid handle kill failed')
  1197. failedRollback.handle.killError = undefined
  1198. retained.terminate()
  1199. await expect(retained.waitForExit()).resolves.toBe(true)
  1200. const crashedFake = new FakeSandbox()
  1201. const crashed = new E2BSubprocessHandle(runtime(crashedFake), spec(), '/runtime/crashed')
  1202. await flush()
  1203. crashedFake.alive = false
  1204. crashedFake.handle.crash(new Error('command transport failed'))
  1205. await expect(crashed.done).rejects.toThrow('command transport failed')
  1206. })
  1207. it('rejects invalid or absent process-group publication', async () => {
  1208. const invalidGroup = new FakeSandbox()
  1209. invalidGroup.processGroupId = 'not-a-pid\n'
  1210. invalidGroup.delaysKill = true
  1211. invalidGroup.sdkKillStops = false
  1212. invalidGroup.afterProbe = () => { invalidGroup.alive = false }
  1213. const invalid = new E2BSubprocessHandle(runtime(invalidGroup), spec(), '/runtime/invalid-group')
  1214. await expect(invalid.done).rejects.toThrow(/invalid process-group id/)
  1215. expect(invalidGroup.handle.kills).toBe(1)
  1216. expect(invalidGroup.commandsSeen).toContain('kill -KILL -- -4242')
  1217. await expect(invalid.waitForExit()).resolves.toBe(true)
  1218. // A rewritten pid file must not aim the kill at every process (`-- -1`).
  1219. const unsafeGroup = new FakeSandbox()
  1220. unsafeGroup.processGroupId = '1\n'
  1221. unsafeGroup.delaysKill = true
  1222. unsafeGroup.sdkKillStops = false
  1223. unsafeGroup.afterProbe = () => { unsafeGroup.alive = false }
  1224. const unsafe = new E2BSubprocessHandle(runtime(unsafeGroup), spec(), '/runtime/unsafe-group')
  1225. await expect(unsafe.done).rejects.toThrow(/unsafe published process-group id 1/)
  1226. expect(unsafeGroup.commandsSeen).not.toContain('kill -KILL -- -1')
  1227. await expect(unsafe.waitForExit()).resolves.toBe(true)
  1228. const absentGroup = new FakeSandbox()
  1229. absentGroup.processGroupId = ''
  1230. const absent = new E2BSubprocessHandle(runtime(absentGroup), spec(), '/runtime/absent-group')
  1231. await flush()
  1232. absentGroup.finish()
  1233. await expect(absent.done).rejects.toThrow(/exited before publishing/)
  1234. expect(absentGroup.handle.kills).toBe(1)
  1235. expect(absentGroup.commandsSeen).toContain('kill -KILL -- -4242')
  1236. await expect(absent.waitForExit()).resolves.toBe(true)
  1237. })
  1238. it('preserves publication failure and reports cleanup that cannot be verified', async () => {
  1239. const fake = new FakeSandbox()
  1240. fake.processGroupId = 'not-a-pid\n'
  1241. fake.signalError = new Error('rollback signal failed')
  1242. fake.handle.killError = new Error('SDK kill failed')
  1243. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/failed-rollback')
  1244. let failure: unknown
  1245. try {
  1246. await handle.done
  1247. } catch (error: unknown) {
  1248. failure = error
  1249. }
  1250. expect(failure).toBeInstanceOf(AggregateError)
  1251. if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError')
  1252. expect(failure.message).toBe('subprocess-e2b: process-group publication failed and rollback did not reach quiescence')
  1253. const failures = Array.from(failure.errors as Iterable<unknown>)
  1254. expect(failures).toHaveLength(2)
  1255. expect(failures[0]).toBeInstanceOf(Error)
  1256. expect(failures[1]).toBeInstanceOf(Error)
  1257. if (!(failures[0] instanceof Error) || !(failures[1] instanceof Error)) throw new Error('expected nested errors')
  1258. expect(failures[0].message).toContain('invalid process-group id')
  1259. expect(failures[1].message).toContain('remained live after force termination')
  1260. expect(fake.handle.kills).toBe(1)
  1261. const bounded = new AbortController()
  1262. const waiting = handle.waitForExit(bounded.signal)
  1263. bounded.abort()
  1264. await expect(waiting).resolves.toBe(false)
  1265. handle.terminate()
  1266. await expect(handle.waitForExit()).resolves.toBe(true)
  1267. expect(fake.commandsSeen).toContain('kill -TERM -- -4242')
  1268. const naturallyGone = new FakeSandbox()
  1269. naturallyGone.processGroupId = 'not-a-pid\n'
  1270. naturallyGone.signalError = new Error('rollback signal failed')
  1271. naturallyGone.handle.killError = new Error('SDK kill failed')
  1272. const observed = new E2BSubprocessHandle(runtime(naturallyGone), spec(), '/runtime/failed-rollback-observed')
  1273. await expect(observed.done).rejects.toThrow('process-group publication failed')
  1274. naturallyGone.alive = false
  1275. await expect(observed.waitForExit()).resolves.toBe(true)
  1276. })
  1277. it('waits for delayed process-group publication', async () => {
  1278. const fake = new FakeSandbox()
  1279. fake.processGroupReads.push('', '4242\n')
  1280. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/delayed-group')
  1281. await vi.waitFor(() => { expect(handle.pid).toBe(4242) })
  1282. fake.finish()
  1283. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  1284. })
  1285. it('handles output backpressure and contains a stderr sink failure', async () => {
  1286. const fake = new FakeSandbox()
  1287. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  1288. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
  1289. }), '/runtime/backpressure')
  1290. await flush()
  1291. handle.stdout!.on('error', () => {})
  1292. const stdoutWrite = vi.spyOn(handle.stdout!, 'write').mockReturnValueOnce(false)
  1293. const stdoutPending = fake.stdout('blocked')
  1294. queueMicrotask(() => { handle.stdout!.emit('drain') })
  1295. await stdoutPending
  1296. stdoutWrite.mockRestore()
  1297. handle.stderr!.on('error', () => {})
  1298. const stderrWrite = vi.spyOn(handle.stderr!, 'write').mockReturnValueOnce(false)
  1299. const stderrPending = fake.stderr('broken')
  1300. queueMicrotask(() => { handle.stderr!.emit('error', new Error('sink failed')) })
  1301. await stderrPending
  1302. stderrWrite.mockRestore()
  1303. fake.finish()
  1304. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  1305. })
  1306. it('settles output backpressure when the consumer closes the pipe', async () => {
  1307. const fake = new FakeSandbox()
  1308. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  1309. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
  1310. }), '/runtime/backpressure-close')
  1311. await flush()
  1312. const stdoutWrite = vi.spyOn(handle.stdout!, 'write').mockReturnValueOnce(false)
  1313. const pending = fake.stdout('discarded')
  1314. queueMicrotask(() => { handle.stdout!.destroy() })
  1315. await pending
  1316. stdoutWrite.mockRestore()
  1317. fake.finish()
  1318. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  1319. })
  1320. it('breaks output backpressure when termination owns the command', async () => {
  1321. const fake = new FakeSandbox()
  1322. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  1323. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
  1324. }), '/runtime/backpressure-termination')
  1325. await flush()
  1326. const stdoutWrite = vi.spyOn(handle.stdout!, 'write').mockReturnValueOnce(false)
  1327. let released = false
  1328. const pending = fake.stdout('blocked').then(() => { released = true })
  1329. await Promise.resolve()
  1330. handle.terminate()
  1331. await flush()
  1332. const releasedByTermination = released
  1333. if (!released) handle.stdout!.emit('drain')
  1334. await pending
  1335. stdoutWrite.mockRestore()
  1336. await handle.done
  1337. expect(releasedByTermination).toBe(true)
  1338. })
  1339. it('settles backpressure when a synchronous pipe write starts termination', async () => {
  1340. const fake = new FakeSandbox()
  1341. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  1342. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
  1343. }), '/runtime/backpressure-synchronous-termination')
  1344. await flush()
  1345. const stdoutWrite = vi.spyOn(handle.stdout!, 'write').mockImplementationOnce(() => {
  1346. handle.terminate()
  1347. return false
  1348. })
  1349. await expect(fake.stdout('blocked')).resolves.toBeUndefined()
  1350. stdoutWrite.mockRestore()
  1351. await handle.done
  1352. })
  1353. it('contains a pipe callback failure instead of rejecting command settlement', async () => {
  1354. const fake = new FakeSandbox()
  1355. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  1356. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
  1357. }), '/runtime/pipe-error')
  1358. await flush()
  1359. const emitted = once(handle.stdout!, 'error')
  1360. handle.stdout!.destroy(new Error('consumer failed'))
  1361. await emitted
  1362. await fake.stdout('late output')
  1363. fake.finish()
  1364. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  1365. })
  1366. it('contains an already-gone group signal and escalates after a TERM transport failure', async () => {
  1367. const gone = new FakeSandbox()
  1368. gone.trapsTerm = true
  1369. gone.signalError = commandError(1)
  1370. const goneHandle = new E2BSubprocessHandle(runtime(gone), spec({ graceMs: 1 }), '/runtime/gone-signal')
  1371. await flush()
  1372. goneHandle.terminate()
  1373. await expect(goneHandle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  1374. const failed = new FakeSandbox()
  1375. failed.signalError = new Error('signal transport failed')
  1376. const failedHandle = new E2BSubprocessHandle(runtime(failed), spec(), '/runtime/failed-signal')
  1377. await flush()
  1378. failedHandle.terminate()
  1379. await expect(failedHandle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  1380. expect(failed.commandsSeen).toContain('kill -KILL -- -4242')
  1381. })
  1382. it('allows termination retry after both force transports fail', async () => {
  1383. const fake = new FakeSandbox()
  1384. fake.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed'))
  1385. fake.handle.killError = new Error('SDK kill failed')
  1386. const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/retry-signal')
  1387. await flush()
  1388. handle.terminate()
  1389. await expect(handle.waitForExit()).rejects.toThrow('remained live after force termination')
  1390. fake.handle.killError = undefined
  1391. handle.terminate()
  1392. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  1393. expect(fake.commandsSeen.filter(command => command.startsWith('kill -TERM '))).toHaveLength(2)
  1394. const missingGroup = new FakeSandbox()
  1395. missingGroup.trapsTerm = true
  1396. missingGroup.signalErrors.push(undefined, commandError(1))
  1397. missingGroup.handle.killError = new Error('SDK kill failed after group exit race')
  1398. const raced = new E2BSubprocessHandle(runtime(missingGroup), spec({ graceMs: 1 }), '/runtime/group-exit-race')
  1399. await flush()
  1400. raced.terminate()
  1401. await expect(raced.waitForExit()).rejects.toThrow('remained live after force termination')
  1402. missingGroup.handle.killError = undefined
  1403. raced.terminate()
  1404. await expect(raced.waitForExit()).resolves.toBe(true)
  1405. })
  1406. it('rejects an optimistic SDK kill while descendants survive a failed group KILL', async () => {
  1407. const fake = new FakeSandbox()
  1408. fake.trapsTerm = true
  1409. fake.sdkKillStops = false
  1410. fake.signalErrors.push(undefined, new Error('KILL transport failed'))
  1411. const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/optimistic-sdk-kill')
  1412. await flush()
  1413. handle.terminate()
  1414. await expect(handle.waitForExit()).rejects.toThrow('remained live after force termination')
  1415. expect(fake.alive).toBe(true)
  1416. fake.sdkKillStops = true
  1417. handle.terminate()
  1418. await expect(handle.waitForExit()).resolves.toBe(true)
  1419. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  1420. })
  1421. })
  1422. describe('E2BSubprocessService', () => {
  1423. async function service(
  1424. fake = new FakeSandbox(),
  1425. providedRuntime: E2BSandboxService = runtime(fake),
  1426. ): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>> }> {
  1427. const ctx = new Context()
  1428. ctx.provide('e2b', providedRuntime)
  1429. const fiber = await ctx.plugin(E2BSubprocessService)
  1430. return { ctx, fiber }
  1431. }
  1432. it('registers handles and disposal terminates and joins live remote groups regardless of sandbox policy', async () => {
  1433. const fake = new FakeSandbox()
  1434. fake.trapsTerm = true
  1435. const { ctx, fiber } = await service(fake)
  1436. const handle = ctx.subprocess.spawn(spec({ graceMs: 1 }))
  1437. await flush()
  1438. await fiber.dispose()
  1439. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  1440. expect(fake.alive).toBe(false)
  1441. })
  1442. it('awaits SDK settlement after the remote process group becomes quiescent', async () => {
  1443. const fake = new FakeSandbox()
  1444. fake.trapsTerm = true
  1445. const { ctx, fiber } = await service(fake)
  1446. const handle = ctx.subprocess.spawn(spec())
  1447. await flush()
  1448. fake.alive = false
  1449. let disposed = false
  1450. const disposing = fiber.dispose().then(() => { disposed = true })
  1451. await flush()
  1452. expect(disposed).toBe(false)
  1453. fake.finish()
  1454. await disposing
  1455. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  1456. })
  1457. it('reports a failed termination transaction from disposal instead of waiting on done', async () => {
  1458. const fake = new FakeSandbox()
  1459. fake.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed'))
  1460. fake.handle.killError = new Error('SDK kill failed')
  1461. const { ctx, fiber } = await service(fake)
  1462. const handle = ctx.subprocess.spawn(spec({ graceMs: 1 }))
  1463. await flush()
  1464. await expect(fiber.dispose()).resolves.toBeUndefined()
  1465. await expect(handle.waitForExit()).rejects.toThrow('remained live after force termination')
  1466. fake.handle.killError = undefined
  1467. handle.terminate()
  1468. await expect(handle.waitForExit()).resolves.toBe(true)
  1469. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  1470. })
  1471. it('aggregates sibling cleanup failures instead of reporting only the first', async () => {
  1472. const { ctx, fiber } = await service()
  1473. const disposalErrors: unknown[] = []
  1474. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  1475. const first = {
  1476. terminate: vi.fn(),
  1477. waitForExit: vi.fn(async () => { throw new Error('first cleanup failed') }),
  1478. done: Promise.resolve({ exitCode: 0, signal: null }),
  1479. } as unknown as E2BSubprocessHandle
  1480. const second = {
  1481. terminate: vi.fn(),
  1482. waitForExit: vi.fn(async () => { throw new Error('second cleanup failed') }),
  1483. done: Promise.resolve({ exitCode: 0, signal: null }),
  1484. } as unknown as E2BSubprocessHandle
  1485. const live = (ctx.subprocess as unknown as { live: Set<E2BSubprocessHandle> }).live
  1486. live.add(first)
  1487. live.add(second)
  1488. await fiber.dispose()
  1489. const failure = disposalErrors[0]
  1490. expect(failure).toBeInstanceOf(AggregateError)
  1491. if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError')
  1492. expect(failure.errors.map(error => (error as Error).message).sort()).toEqual([
  1493. 'first cleanup failed',
  1494. 'second cleanup failed',
  1495. ])
  1496. })
  1497. it('waits for every owned cleanup before reporting a disposal failure', async () => {
  1498. const { ctx, fiber } = await service()
  1499. const failed = {
  1500. terminate: vi.fn(),
  1501. waitForExit: vi.fn(async () => { throw new Error('cleanup failed') }),
  1502. done: Promise.resolve({ exitCode: 0, signal: null }),
  1503. } as unknown as E2BSubprocessHandle
  1504. let finishCleanup!: () => void
  1505. const cleanup = new Promise<boolean>((resolve) => {
  1506. finishCleanup = () => { resolve(true) }
  1507. })
  1508. const draining = {
  1509. terminate: vi.fn(),
  1510. waitForExit: vi.fn(() => cleanup),
  1511. done: Promise.resolve({ exitCode: 0, signal: null }),
  1512. } as unknown as E2BSubprocessHandle
  1513. const live = (ctx.subprocess as unknown as { live: Set<E2BSubprocessHandle> }).live
  1514. live.add(failed)
  1515. live.add(draining)
  1516. let disposed = false
  1517. const disposing = fiber.dispose().then(() => { disposed = true })
  1518. await flush()
  1519. expect(disposed).toBe(false)
  1520. finishCleanup()
  1521. await disposing
  1522. expect(live).toEqual(new Set([failed]))
  1523. })
  1524. it('releases naturally settled handles before later service disposal', async () => {
  1525. const fake = new FakeSandbox()
  1526. const { ctx, fiber } = await service(fake)
  1527. const handle = ctx.subprocess.spawn(spec())
  1528. await flush()
  1529. fake.finish()
  1530. await handle.done
  1531. await flush()
  1532. const signalsBefore = fake.commandsSeen.filter(command => command.startsWith('kill -')).length
  1533. await fiber.dispose()
  1534. expect(fake.commandsSeen.filter(command => command.startsWith('kill -')).length).toBe(signalsBefore)
  1535. })
  1536. it('contains a release liveness failure and retries quiescence during disposal', async () => {
  1537. const fake = new FakeSandbox()
  1538. let calls = 0
  1539. const reconnecting = runtime(fake, async () => {
  1540. calls += 1
  1541. if (calls === 2) throw new Error('transient liveness failure')
  1542. return fake.sandbox
  1543. })
  1544. const { ctx, fiber } = await service(fake, reconnecting)
  1545. const handle = ctx.subprocess.spawn(spec())
  1546. await flush()
  1547. fake.finish()
  1548. await handle.done
  1549. await flush()
  1550. await fiber.dispose()
  1551. expect(calls).toBeGreaterThanOrEqual(3)
  1552. })
  1553. it('contains spawn rejection while disposal is joining the pending handle', async () => {
  1554. const fake = new FakeSandbox()
  1555. fake.deferStart()
  1556. fake.backgroundError = new Error('start failed during disposal')
  1557. const { ctx, fiber } = await service(fake)
  1558. const subprocess = ctx.subprocess
  1559. const handle = subprocess.spawn(spec())
  1560. await vi.waitFor(() => { expect(fake.startOptions).toBeDefined() })
  1561. const disposing = fiber.dispose()
  1562. await flush()
  1563. expect(() => subprocess.spawn(spec())).toThrow('service is disposing')
  1564. fake.releaseStart()
  1565. await expect(disposing).resolves.toBeUndefined()
  1566. await expect(handle.done).rejects.toThrow('start failed during disposal')
  1567. })
  1568. it('validates synchronous spawn preconditions', async () => {
  1569. const { ctx } = await service()
  1570. expect(() => ctx.subprocess.spawn(spec({ argv: [] }))).toThrow(/non-empty program/)
  1571. expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort('stop') }))).toThrow(/aborted before spawn/)
  1572. })
  1573. it('registers the package-owned empty invariant installer', async () => {
  1574. const ctx = new Context()
  1575. await ctx.plugin(InvariantService, { enabled: true })
  1576. const fiber = await ctx.plugin(E2BSubprocessInvariant).await()
  1577. await fiber.dispose()
  1578. })
  1579. })