subprocess.spec.ts 56 KB

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