subprocess.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725
  1. import { once } from 'node:events'
  2. import { Context } from 'cordis'
  3. import {
  4. CommandExitError,
  5. type CommandHandle,
  6. type CommandResult,
  7. type Sandbox,
  8. } from '@deepseek-ai/dsh-e2b'
  9. import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
  10. import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  11. import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
  12. import * as E2BSubprocessInvariant from '../src/invariant.ts'
  13. import { E2BOutputReader } from '../src/output.ts'
  14. import { E2BSubprocessHandle } from '../src/process.ts'
  15. import InvariantService from '@deepseek-ai/dsh-invariants'
  16. import { describe, expect, it, vi } from 'vitest'
  17. function commandError(exitCode: number): CommandExitError {
  18. return new CommandExitError({ exitCode, stdout: '', stderr: '', error: `exit ${exitCode}` })
  19. }
  20. interface StartOptions {
  21. background: true
  22. cwd: string
  23. stdin: boolean
  24. timeoutMs: number
  25. signal?: AbortSignal
  26. envs?: Record<string, string>
  27. onStdout?: (data: string) => void | Promise<void>
  28. onStderr?: (data: string) => void | Promise<void>
  29. }
  30. class FakeCommandHandle {
  31. pid = 4242
  32. readonly sent: Array<string | Uint8Array> = []
  33. closes = 0
  34. kills = 0
  35. killError: unknown
  36. private readonly result = Promise.withResolvers<CommandResult>()
  37. private settled = false
  38. wait(): Promise<CommandResult> {
  39. return this.result.promise
  40. }
  41. async sendStdin(data: string | Uint8Array): Promise<void> {
  42. this.sent.push(data)
  43. }
  44. async closeStdin(): Promise<void> {
  45. this.closes += 1
  46. }
  47. async kill(): Promise<boolean> {
  48. this.kills += 1
  49. if (this.killError !== undefined) throw this.killError
  50. return true
  51. }
  52. succeed(exitCode = 0): void {
  53. if (this.settled) return
  54. this.settled = true
  55. this.result.resolve({ exitCode, stdout: '', stderr: '' })
  56. }
  57. fail(exitCode: number): void {
  58. if (this.settled) return
  59. this.settled = true
  60. this.result.reject(commandError(exitCode))
  61. }
  62. crash(error: unknown): void {
  63. if (this.settled) return
  64. this.settled = true
  65. this.result.reject(error)
  66. }
  67. }
  68. class FakeSandbox {
  69. readonly handle = new FakeCommandHandle()
  70. readonly commandsSeen: string[] = []
  71. readonly writtenFiles: string[][] = []
  72. readonly removed: string[] = []
  73. readonly directories: string[] = []
  74. startOptions: StartOptions | undefined
  75. backgroundError: unknown
  76. nextRemoveError: unknown
  77. probeError: unknown
  78. signalError: unknown
  79. trapsTerm = false
  80. alive = true
  81. processGroupId = '4242\n'
  82. readonly processGroupReads: string[] = []
  83. beforeProbe: (() => void) | undefined
  84. afterProbe: (() => void) | undefined
  85. private startGate: Promise<void> | undefined
  86. private openStart: (() => void) | undefined
  87. deferStart(): void {
  88. const gate = Promise.withResolvers<undefined>()
  89. this.startGate = gate.promise
  90. this.openStart = () => { gate.resolve(undefined) }
  91. }
  92. releaseStart(): void {
  93. this.openStart?.()
  94. }
  95. finish(exitCode = 0): void {
  96. this.alive = false
  97. if (exitCode === 0) this.handle.succeed(0)
  98. else this.handle.fail(exitCode)
  99. }
  100. async stdout(data: string): Promise<void> {
  101. await this.startOptions?.onStdout?.(data)
  102. }
  103. async stderr(data: string): Promise<void> {
  104. await this.startOptions?.onStderr?.(data)
  105. }
  106. readonly sandbox = {
  107. sandboxId: 'fake',
  108. files: {
  109. makeDir: async (path: string): Promise<boolean> => {
  110. this.directories.push(path)
  111. return true
  112. },
  113. write: async (files: Array<{ path: string; data: string }>): Promise<object[]> => {
  114. this.writtenFiles.push(files.map(file => file.path))
  115. return files.map(() => ({}))
  116. },
  117. read: async (): Promise<string> => this.processGroupReads.shift() ?? this.processGroupId,
  118. remove: async (path: string): Promise<void> => {
  119. this.removed.push(path)
  120. if (this.nextRemoveError !== undefined) {
  121. const error = this.nextRemoveError
  122. this.nextRemoveError = undefined
  123. throw error
  124. }
  125. },
  126. },
  127. commands: {
  128. run: async (command: string, options?: StartOptions | { signal?: AbortSignal }): Promise<CommandHandle | CommandResult> => {
  129. this.commandsSeen.push(command)
  130. if (command.startsWith('kill -0 ')) {
  131. this.beforeProbe?.()
  132. if (options?.signal?.aborted === true) throw new DOMException('aborted', 'AbortError')
  133. if (this.probeError !== undefined) {
  134. const error = this.probeError
  135. this.probeError = undefined
  136. throw error
  137. }
  138. if (!this.alive) throw commandError(1)
  139. this.afterProbe?.()
  140. return { exitCode: 0, stdout: '', stderr: '' }
  141. }
  142. if (command.startsWith('kill -TERM ')) {
  143. if (this.signalError !== undefined) {
  144. const error = this.signalError
  145. this.signalError = undefined
  146. throw error
  147. }
  148. if (!this.trapsTerm) {
  149. this.alive = false
  150. this.handle.fail(143)
  151. }
  152. return { exitCode: 0, stdout: '', stderr: '' }
  153. }
  154. if (command.startsWith('kill -KILL ')) {
  155. if (this.signalError !== undefined) {
  156. const error = this.signalError
  157. this.signalError = undefined
  158. throw error
  159. }
  160. this.alive = false
  161. this.handle.fail(137)
  162. return { exitCode: 0, stdout: '', stderr: '' }
  163. }
  164. if ((options as StartOptions | undefined)?.background === true) {
  165. this.startOptions = options as StartOptions
  166. await this.startGate
  167. if (this.backgroundError !== undefined) throw this.backgroundError
  168. return this.handle as unknown as CommandHandle
  169. }
  170. return { exitCode: 0, stdout: '', stderr: '' }
  171. },
  172. },
  173. } as unknown as Sandbox
  174. }
  175. function spec(overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
  176. return {
  177. argv: ['bash', '-c', 'printf ok'],
  178. cwd: '/workspace',
  179. stdio: {
  180. stdin: 'ignore',
  181. stdout: { maxBytes: 4, spill: { maxBytes: 16 } },
  182. stderr: { maxBytes: 4 },
  183. },
  184. graceMs: 5,
  185. ...overrides,
  186. }
  187. }
  188. function runtime(fake: FakeSandbox, getSandbox: () => Promise<Sandbox> = async () => fake.sandbox): E2BSandboxService {
  189. return {
  190. cwd: '/workspace',
  191. runtimeRoot: '/workspace/.dsh-e2b',
  192. disposeMode: 'kill',
  193. getSandbox,
  194. } as unknown as E2BSandboxService
  195. }
  196. async function flush(): Promise<void> {
  197. await new Promise(resolve => setTimeout(resolve, 0))
  198. }
  199. describe('E2BOutputReader', () => {
  200. it('keeps a byte-exact tail with independent whole-stream cursors', () => {
  201. const reader = new E2BOutputReader(4, 10, '/remote/spill')
  202. reader.push('')
  203. reader.push('ab')
  204. reader.push('cdef')
  205. expect(reader.size).toBe(6)
  206. expect(reader.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true, spillPath: '/remote/spill' })
  207. expect(reader.readFrom(2)).toEqual({ text: 'cdef', nextOffset: 6, lossy: false })
  208. expect(reader.readFrom(5)).toEqual({ text: 'f', nextOffset: 6, lossy: false })
  209. expect(reader.readFrom(99)).toEqual({ text: '', nextOffset: 6, lossy: false })
  210. })
  211. it('drops whole head chunks and withholds absent or over-cap spills', () => {
  212. const withoutSpill = new E2BOutputReader(2, undefined, '/unused')
  213. withoutSpill.push('ab')
  214. withoutSpill.push('cd')
  215. expect(withoutSpill.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
  216. const overCap = new E2BOutputReader(2, 3, '/too-small')
  217. overCap.push('abcd')
  218. expect(overCap.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
  219. expect(() => overCap.readFrom(-1)).toThrow(/non-negative safe integer/)
  220. expect(() => overCap.readFrom(1.5)).toThrow(/non-negative safe integer/)
  221. })
  222. })
  223. describe('E2BSubprocessHandle', () => {
  224. it('starts asynchronously, keeps secrets out of the command, and supports deferred piped stdin/output', async () => {
  225. const fake = new FakeSandbox()
  226. fake.processGroupId = '4343\n'
  227. fake.deferStart()
  228. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  229. argv: ['tool', 'argument with spaces'],
  230. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 8, spill: { maxBytes: 32 } } },
  231. env: { PATH: '/bin', DEEPSEEK_API_KEY: 'explicit-secret', DSH_MODE: 'test' },
  232. }), '/workspace/.dsh-e2b/processes/one')
  233. expect(handle.pid).toBe(-1)
  234. handle.stdin!.write('hello')
  235. handle.stdin!.end()
  236. fake.releaseStart()
  237. await flush()
  238. expect(handle.pid).toBe(4343)
  239. expect(fake.handle.sent.map(value => String(value))).toEqual(['hello'])
  240. expect(fake.handle.closes).toBe(1)
  241. expect(fake.startOptions?.envs).toEqual({ PATH: '/bin', DEEPSEEK_API_KEY: 'explicit-secret', DSH_MODE: 'test' })
  242. const command = fake.commandsSeen.find(value => value.startsWith('exec setsid'))!
  243. expect(command).toContain('exec setsid --wait -- bash -c')
  244. expect(command).toContain('DEEPSEEK_API_KEY')
  245. expect(command).toContain('DSH_MODE')
  246. expect(command).not.toContain('explicit-secret')
  247. expect(fake.writtenFiles[0]).toEqual([
  248. '/workspace/.dsh-e2b/processes/one/pid',
  249. '/workspace/.dsh-e2b/processes/one/exit-code',
  250. '/workspace/.dsh-e2b/processes/one/stderr.log',
  251. ])
  252. let piped = ''
  253. handle.stdout!.on('data', (chunk) => { piped += String(chunk) })
  254. await fake.stdout('pipe-data')
  255. await fake.stderr('err')
  256. fake.finish()
  257. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  258. expect(piped).toBe('pipe-data')
  259. expect(handle.collected.stderr!.readFrom(0)).toMatchObject({ text: 'err', lossy: false })
  260. expect(fake.removed).toContain('/workspace/.dsh-e2b/processes/one/stderr.log')
  261. await expect(handle.waitForExit()).resolves.toBe(true)
  262. })
  263. it('surfaces deferred piped-stdin write and close failures as stream errors', async () => {
  264. const writeFake = new FakeSandbox()
  265. writeFake.deferStart()
  266. vi.spyOn(writeFake.handle, 'sendStdin').mockRejectedValueOnce('stdin rejected')
  267. const writeHandle = new E2BSubprocessHandle(runtime(writeFake), spec({
  268. stdio: { stdin: 'pipe', stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } },
  269. }), '/runtime/stdin-write-error')
  270. const writeError = once(writeHandle.stdin!, 'error')
  271. writeHandle.stdin!.write('input')
  272. writeFake.releaseStart()
  273. await expect(writeError).resolves.toMatchObject([{ message: 'stdin rejected' }])
  274. writeFake.finish()
  275. await writeHandle.done
  276. const closeFake = new FakeSandbox()
  277. vi.spyOn(closeFake.handle, 'closeStdin').mockRejectedValueOnce(new Error('close rejected'))
  278. const closeHandle = new E2BSubprocessHandle(runtime(closeFake), spec({
  279. stdio: { stdin: 'pipe', stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } },
  280. }), '/runtime/stdin-close-error')
  281. await flush()
  282. const closeError = once(closeHandle.stdin!, 'error')
  283. closeHandle.stdin!.end()
  284. await expect(closeError).resolves.toMatchObject([{ message: 'close rejected' }])
  285. closeFake.finish()
  286. await closeHandle.done
  287. })
  288. it('collects bounded tails, retains valid spills, and maps natural nonzero exits', async () => {
  289. const fake = new FakeSandbox()
  290. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  291. stdio: {
  292. stdin: { data: 'batch' },
  293. stdout: { maxBytes: 4, spill: { maxBytes: 16 } },
  294. stderr: { maxBytes: 3 },
  295. },
  296. }), '/runtime/two')
  297. await flush()
  298. await fake.stdout('abcdef')
  299. await fake.stderr('12345')
  300. fake.finish(7)
  301. await expect(handle.done).resolves.toEqual({ exitCode: 7, signal: null })
  302. expect(fake.handle.sent).toEqual(['batch'])
  303. expect(fake.handle.closes).toBe(1)
  304. expect(handle.collected.stdout!.readFrom(0)).toEqual({
  305. text: 'cdef',
  306. nextOffset: 6,
  307. lossy: true,
  308. spillPath: '/runtime/two/stdout.log',
  309. })
  310. expect(handle.collected.stderr!.readFrom(0)).toEqual({ text: '345', nextOffset: 5, lossy: true })
  311. expect(fake.removed).not.toContain('/runtime/two/stdout.log')
  312. })
  313. it('removes a spill once the complete stream exceeds its cap', async () => {
  314. const fake = new FakeSandbox()
  315. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  316. stdio: { stdin: 'ignore', stdout: { maxBytes: 2, spill: { maxBytes: 3 } }, stderr: 'inherit' },
  317. }), '/runtime/oversize')
  318. await flush()
  319. await fake.stdout('abcd')
  320. await fake.stderr('')
  321. fake.finish()
  322. await handle.done
  323. expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
  324. expect(fake.removed).toContain('/runtime/oversize/stdout.log')
  325. })
  326. it('contains remote spill-removal failures and routes empty inherited output', async () => {
  327. const fake = new FakeSandbox()
  328. fake.nextRemoveError = new Error('already removed')
  329. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  330. stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 4, spill: { maxBytes: 8 } } },
  331. }), '/runtime/remove-error')
  332. await flush()
  333. await fake.stdout('')
  334. await fake.stderr('')
  335. fake.finish()
  336. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  337. expect(fake.removed).toContain('/runtime/remove-error/stderr.log')
  338. })
  339. it('terminates a process group with TERM and reports the signal outcome', async () => {
  340. const fake = new FakeSandbox()
  341. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/term')
  342. await flush()
  343. handle.terminate()
  344. handle.terminate()
  345. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  346. await expect(handle.waitForExit()).resolves.toBe(true)
  347. expect(fake.commandsSeen).toContain('kill -TERM -- -4242')
  348. expect(fake.commandsSeen).not.toContain('kill -KILL -- -4242')
  349. })
  350. it('escalates a TERM-trapping process group to KILL and uses the SDK kill as fallback', async () => {
  351. const fake = new FakeSandbox()
  352. fake.trapsTerm = true
  353. fake.handle.killError = new Error('already gone')
  354. const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/kill')
  355. await flush()
  356. handle.terminate()
  357. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  358. await expect(handle.waitForExit()).resolves.toBe(true)
  359. expect(fake.commandsSeen).toContain('kill -KILL -- -4242')
  360. expect(fake.handle.kills).toBe(1)
  361. })
  362. it('honors termination requested before asynchronous startup finishes', async () => {
  363. const fake = new FakeSandbox()
  364. fake.deferStart()
  365. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/deferred-kill')
  366. handle.terminate()
  367. fake.releaseStart()
  368. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  369. })
  370. it('honors an already-aborted signal when constructing the asynchronous handle directly', async () => {
  371. const fake = new FakeSandbox()
  372. const handle = new E2BSubprocessHandle(runtime(fake), spec({ signal: AbortSignal.abort('stop') }), '/runtime/pre-aborted')
  373. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  374. })
  375. it('reacts to a signal that aborts after the remote command has started', async () => {
  376. const fake = new FakeSandbox()
  377. const controller = new AbortController()
  378. const handle = new E2BSubprocessHandle(runtime(fake), spec({ signal: controller.signal }), '/runtime/live-abort')
  379. await flush()
  380. controller.abort('stop')
  381. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  382. })
  383. it('bounds waitForExit while startup or a live group is pending', async () => {
  384. const fake = new FakeSandbox()
  385. fake.deferStart()
  386. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/wait')
  387. const beforeStart = new AbortController()
  388. const pending = handle.waitForExit(beforeStart.signal)
  389. beforeStart.abort()
  390. await expect(pending).resolves.toBe(false)
  391. await expect(handle.waitForExit(AbortSignal.abort())).resolves.toBe(false)
  392. fake.releaseStart()
  393. await flush()
  394. const live = new AbortController()
  395. const liveWait = handle.waitForExit(live.signal)
  396. live.abort()
  397. await expect(liveWait).resolves.toBe(false)
  398. fake.finish()
  399. await handle.done
  400. })
  401. it('bounds both sides of the liveness-poll abort race', async () => {
  402. const fake = new FakeSandbox()
  403. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/poll-abort')
  404. await flush()
  405. const beforeTick = new AbortController()
  406. fake.afterProbe = () => { beforeTick.abort(); fake.afterProbe = undefined }
  407. await expect(handle.waitForExit(beforeTick.signal)).resolves.toBe(false)
  408. const duringTick = new AbortController()
  409. fake.afterProbe = () => {
  410. fake.afterProbe = undefined
  411. setTimeout(() => { duringTick.abort() }, 0)
  412. }
  413. await expect(handle.waitForExit(duringTick.signal)).resolves.toBe(false)
  414. const duringProbe = new AbortController()
  415. fake.beforeProbe = () => { duringProbe.abort(); fake.beforeProbe = undefined }
  416. await expect(handle.waitForExit(duringProbe.signal)).resolves.toBe(false)
  417. fake.finish()
  418. await handle.done
  419. })
  420. it('observes a live group across one successful bounded poll', async () => {
  421. const fake = new FakeSandbox()
  422. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/poll-success')
  423. await flush()
  424. setTimeout(() => { fake.finish() }, 1)
  425. await expect(handle.waitForExit(new AbortController().signal)).resolves.toBe(true)
  426. await handle.done
  427. })
  428. it('treats startup failure as no live tree and contains readiness rejection', async () => {
  429. const fake = new FakeSandbox()
  430. fake.backgroundError = new Error('start failed')
  431. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/fail')
  432. await expect(handle.done).rejects.toThrow('start failed')
  433. expect(handle.pid).toBe(-1)
  434. await expect(handle.waitForExit()).resolves.toBe(true)
  435. handle.terminate()
  436. })
  437. it('bounds a readiness rejection with a still-live caller signal', async () => {
  438. const fake = new FakeSandbox()
  439. fake.deferStart()
  440. fake.backgroundError = new Error('start failed')
  441. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/fail-with-signal')
  442. const waiting = handle.waitForExit(new AbortController().signal)
  443. fake.releaseStart()
  444. await expect(handle.done).rejects.toThrow('start failed')
  445. await expect(waiting).resolves.toBe(true)
  446. })
  447. it('propagates an unavailable sandbox unless the caller aborts the wait', async () => {
  448. const fake = new FakeSandbox()
  449. let calls = 0
  450. const unavailable = runtime(fake, async () => {
  451. calls += 1
  452. if (calls === 1) return fake.sandbox
  453. throw new Error('connection unavailable')
  454. })
  455. const handle = new E2BSubprocessHandle(unavailable, spec(), '/runtime/unavailable')
  456. await flush()
  457. await expect(handle.waitForExit()).rejects.toThrow('connection unavailable')
  458. fake.finish()
  459. await handle.done
  460. })
  461. it('returns false when the caller aborts while reconnecting for liveness', async () => {
  462. const fake = new FakeSandbox()
  463. const reconnect = Promise.withResolvers<Sandbox>()
  464. let calls = 0
  465. const unavailable = runtime(fake, async () => {
  466. calls += 1
  467. return calls === 1 ? fake.sandbox : await reconnect.promise
  468. })
  469. const handle = new E2BSubprocessHandle(unavailable, spec(), '/runtime/reconnect-abort')
  470. await flush()
  471. const controller = new AbortController()
  472. const waiting = handle.waitForExit(controller.signal)
  473. await flush()
  474. controller.abort()
  475. reconnect.reject(new Error('connection unavailable'))
  476. await expect(waiting).resolves.toBe(false)
  477. fake.finish()
  478. await handle.done
  479. })
  480. it('returns false when a liveness request itself is aborted and surfaces other probe failures', async () => {
  481. const fake = new FakeSandbox()
  482. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/probe')
  483. await flush()
  484. const controller = new AbortController()
  485. controller.abort()
  486. await expect(handle.waitForExit(controller.signal)).resolves.toBe(false)
  487. fake.probeError = new Error('probe failed')
  488. await expect(handle.waitForExit()).rejects.toThrow('probe failed')
  489. fake.finish()
  490. await handle.done
  491. })
  492. it('makes batch stdin close failures best-effort', async () => {
  493. const fake = new FakeSandbox()
  494. vi.spyOn(fake.handle, 'sendStdin').mockRejectedValueOnce(new Error('closed'))
  495. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  496. stdio: { stdin: { data: 'ignored' }, stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } },
  497. }), '/runtime/stdin-closed')
  498. await flush()
  499. fake.finish()
  500. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  501. })
  502. it('rejects malformed SDK process ids and non-command settlement failures', async () => {
  503. const invalidPid = new FakeSandbox()
  504. invalidPid.handle.pid = 0
  505. const invalid = new E2BSubprocessHandle(runtime(invalidPid), spec(), '/runtime/invalid-pid')
  506. await expect(invalid.done).rejects.toThrow(/invalid command pid 0/)
  507. await expect(invalid.waitForExit()).resolves.toBe(true)
  508. const crashedFake = new FakeSandbox()
  509. const crashed = new E2BSubprocessHandle(runtime(crashedFake), spec(), '/runtime/crashed')
  510. await flush()
  511. crashedFake.alive = false
  512. crashedFake.handle.crash(new Error('command transport failed'))
  513. await expect(crashed.done).rejects.toThrow('command transport failed')
  514. })
  515. it('rejects invalid or absent process-group publication', async () => {
  516. const invalidGroup = new FakeSandbox()
  517. invalidGroup.processGroupId = 'not-a-pid\n'
  518. const invalid = new E2BSubprocessHandle(runtime(invalidGroup), spec(), '/runtime/invalid-group')
  519. await expect(invalid.done).rejects.toThrow(/invalid process-group id/)
  520. const absentGroup = new FakeSandbox()
  521. absentGroup.processGroupId = ''
  522. const absent = new E2BSubprocessHandle(runtime(absentGroup), spec(), '/runtime/absent-group')
  523. await flush()
  524. absentGroup.finish()
  525. await expect(absent.done).rejects.toThrow(/exited before publishing/)
  526. })
  527. it('waits for delayed process-group publication', async () => {
  528. const fake = new FakeSandbox()
  529. fake.processGroupReads.push('', '4242\n')
  530. const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/delayed-group')
  531. await vi.waitFor(() => { expect(handle.pid).toBe(4242) })
  532. fake.finish()
  533. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  534. })
  535. it('handles output backpressure and contains a stderr sink failure', async () => {
  536. const fake = new FakeSandbox()
  537. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  538. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
  539. }), '/runtime/backpressure')
  540. await flush()
  541. handle.stdout!.on('error', () => {})
  542. const stdoutWrite = vi.spyOn(handle.stdout!, 'write').mockReturnValueOnce(false)
  543. const stdoutPending = fake.stdout('blocked')
  544. queueMicrotask(() => { handle.stdout!.emit('drain') })
  545. await stdoutPending
  546. stdoutWrite.mockRestore()
  547. handle.stderr!.on('error', () => {})
  548. const stderrWrite = vi.spyOn(handle.stderr!, 'write').mockReturnValueOnce(false)
  549. const stderrPending = fake.stderr('broken')
  550. queueMicrotask(() => { handle.stderr!.emit('error', new Error('sink failed')) })
  551. await stderrPending
  552. stderrWrite.mockRestore()
  553. fake.finish()
  554. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  555. })
  556. it('contains a pipe callback failure instead of rejecting command settlement', async () => {
  557. const fake = new FakeSandbox()
  558. const handle = new E2BSubprocessHandle(runtime(fake), spec({
  559. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
  560. }), '/runtime/pipe-error')
  561. await flush()
  562. const emitted = once(handle.stdout!, 'error')
  563. handle.stdout!.destroy(new Error('consumer failed'))
  564. await emitted
  565. await fake.stdout('late output')
  566. fake.finish()
  567. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  568. })
  569. it('contains an already-gone group signal and observes non-command signal failures', async () => {
  570. const gone = new FakeSandbox()
  571. gone.trapsTerm = true
  572. gone.signalError = commandError(1)
  573. const goneHandle = new E2BSubprocessHandle(runtime(gone), spec({ graceMs: 1 }), '/runtime/gone-signal')
  574. await flush()
  575. goneHandle.terminate()
  576. await expect(goneHandle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  577. const failed = new FakeSandbox()
  578. failed.signalError = new Error('signal transport failed')
  579. const failedHandle = new E2BSubprocessHandle(runtime(failed), spec(), '/runtime/failed-signal')
  580. await flush()
  581. failedHandle.terminate()
  582. await flush()
  583. failed.finish()
  584. await expect(failedHandle.done).resolves.toEqual({ exitCode: 0, signal: null })
  585. })
  586. })
  587. describe('E2BSubprocessService', () => {
  588. async function service(
  589. fake = new FakeSandbox(),
  590. providedRuntime: E2BSandboxService = runtime(fake),
  591. ): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>> }> {
  592. const ctx = new Context()
  593. ctx.provide('e2b', providedRuntime)
  594. const fiber = await ctx.plugin(E2BSubprocessService)
  595. return { ctx, fiber }
  596. }
  597. it('registers handles and disposal terminates and joins live remote groups regardless of sandbox policy', async () => {
  598. const fake = new FakeSandbox()
  599. fake.trapsTerm = true
  600. const { ctx, fiber } = await service(fake)
  601. const handle = ctx.subprocess.spawn(spec({ graceMs: 1 }))
  602. await flush()
  603. await fiber.dispose()
  604. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  605. expect(fake.alive).toBe(false)
  606. })
  607. it('releases naturally settled handles before later service disposal', async () => {
  608. const fake = new FakeSandbox()
  609. const { ctx, fiber } = await service(fake)
  610. const handle = ctx.subprocess.spawn(spec())
  611. await flush()
  612. fake.finish()
  613. await handle.done
  614. await flush()
  615. const signalsBefore = fake.commandsSeen.filter(command => command.startsWith('kill -')).length
  616. await fiber.dispose()
  617. expect(fake.commandsSeen.filter(command => command.startsWith('kill -')).length).toBe(signalsBefore)
  618. })
  619. it('contains a release liveness failure and retries quiescence during disposal', async () => {
  620. const fake = new FakeSandbox()
  621. let calls = 0
  622. const reconnecting = runtime(fake, async () => {
  623. calls += 1
  624. if (calls === 2) throw new Error('transient liveness failure')
  625. return fake.sandbox
  626. })
  627. const { ctx, fiber } = await service(fake, reconnecting)
  628. const handle = ctx.subprocess.spawn(spec())
  629. await flush()
  630. fake.finish()
  631. await handle.done
  632. await flush()
  633. await fiber.dispose()
  634. expect(calls).toBeGreaterThanOrEqual(3)
  635. })
  636. it('contains spawn rejection while disposal is joining the pending handle', async () => {
  637. const fake = new FakeSandbox()
  638. fake.deferStart()
  639. fake.backgroundError = new Error('start failed during disposal')
  640. const { ctx, fiber } = await service(fake)
  641. const handle = ctx.subprocess.spawn(spec())
  642. const disposing = fiber.dispose()
  643. fake.releaseStart()
  644. await expect(disposing).resolves.toBeUndefined()
  645. await expect(handle.done).rejects.toThrow('start failed during disposal')
  646. })
  647. it('validates synchronous spawn preconditions', async () => {
  648. const { ctx } = await service()
  649. expect(() => ctx.subprocess.spawn(spec({ argv: [] }))).toThrow(/non-empty program/)
  650. expect(() => ctx.subprocess.spawn(spec({ graceMs: 0 }))).toThrow(/positive finite/)
  651. expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort('stop') }))).toThrow(/aborted before spawn/)
  652. expect(() => ctx.subprocess.spawn(spec({ signal: { aborted: true, reason: undefined } as AbortSignal }))).toThrow(/aborted$/)
  653. })
  654. it('registers the package-owned empty invariant installer', async () => {
  655. const ctx = new Context()
  656. await ctx.plugin(InvariantService, { enabled: true })
  657. const fiber = await ctx.plugin(E2BSubprocessInvariant).await()
  658. await fiber.dispose()
  659. })
  660. })