terminal.spec.ts 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997
  1. import { Buffer } from 'node:buffer'
  2. import { once } from 'node:events'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import {
  6. CommandExitError,
  7. FileNotFoundError,
  8. SandboxNotFoundError,
  9. type CommandHandle,
  10. type CommandResult,
  11. type Sandbox,
  12. } from '@deepseek-ai/dsh-e2b'
  13. import type E2BRuntime from '@deepseek-ai/dsh-e2b'
  14. import { SubprocessExecutableNotFoundError, type SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  15. import E2BSubprocessRuntime from '@deepseek-ai/dsh-subprocess-e2b'
  16. import { spawnE2BTerminal } from '../src/terminal.ts'
  17. function commandError(exitCode: number): CommandExitError {
  18. return new CommandExitError({ exitCode, stdout: '', stderr: '', error: `exit ${exitCode}` })
  19. }
  20. interface CommandOptions {
  21. signal?: AbortSignal
  22. cwd?: string
  23. envs?: Record<string, string>
  24. }
  25. class FakeTerminalCommandHandle {
  26. pid = 123
  27. disconnects = 0
  28. sdkKills = 0
  29. disconnectError: unknown
  30. sdkKillError: unknown
  31. waitError: unknown
  32. settleOnSdkKill = true
  33. private readonly result = Promise.withResolvers<CommandResult>()
  34. private settled = false
  35. wait(): Promise<CommandResult> {
  36. if (this.waitError !== undefined) throw this.waitError
  37. return this.result.promise
  38. }
  39. async disconnect(): Promise<void> {
  40. this.disconnects += 1
  41. if (this.disconnectError !== undefined) throw this.disconnectError
  42. }
  43. async kill(): Promise<boolean> {
  44. this.sdkKills += 1
  45. if (this.sdkKillError !== undefined) {
  46. const error = this.sdkKillError
  47. if (this.settleOnSdkKill) this.fail(137)
  48. throw error
  49. }
  50. if (this.settleOnSdkKill) this.fail(137)
  51. return true
  52. }
  53. succeed(exitCode = 0): void {
  54. if (this.settled) return
  55. this.settled = true
  56. this.result.resolve({ exitCode, stdout: '', stderr: '' })
  57. }
  58. fail(exitCode: number): void {
  59. if (this.settled) return
  60. this.settled = true
  61. this.result.reject(commandError(exitCode))
  62. }
  63. crash(error: unknown): void {
  64. if (this.settled) return
  65. this.settled = true
  66. this.result.reject(error)
  67. }
  68. asHandle(): CommandHandle {
  69. return this as unknown as CommandHandle
  70. }
  71. }
  72. class FakeTerminalSandbox {
  73. readonly handle = new FakeTerminalCommandHandle()
  74. readonly commands: string[] = []
  75. readonly commandOptions: CommandOptions[] = []
  76. readonly inputs: Array<{ pid: number; data: Buffer }> = []
  77. readonly sizes: Array<{ pid: number; cols: number; rows: number }> = []
  78. readonly removed: string[] = []
  79. readonly directories: string[] = []
  80. readonly writes = new Map<string, string>()
  81. createOptions: Parameters<Sandbox['pty']['create']>[0] | undefined
  82. ambient = 'KEEP=visible\0UNICODE=你好\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
  83. sessionId = '123\n'
  84. foreground = '456\n'
  85. groups = [123]
  86. zombieGroups: number[] = []
  87. createError: unknown
  88. writeError: unknown
  89. sendError: unknown
  90. commandFailure: unknown
  91. makeDirRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
  92. sendInputRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
  93. foregroundRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
  94. signalRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
  95. sessionGroupsFailure: unknown
  96. foregroundFailure: unknown
  97. termFailure: unknown
  98. removeError: unknown
  99. clearOnTerm = true
  100. clearOnKill = true
  101. resolvedExecutable = '/usr/bin/node\n'
  102. requestedOutput = 'requested-shell$ '
  103. emitOutputMarker = true
  104. afterSessionLookup: (() => void) | undefined
  105. private createGate: Promise<undefined> | undefined
  106. private releaseCreateGate: (() => void) | undefined
  107. deferCreate(): void {
  108. const gate = Promise.withResolvers<undefined>()
  109. this.createGate = gate.promise
  110. this.releaseCreateGate = () => { gate.resolve(undefined) }
  111. }
  112. releaseCreate(): void {
  113. this.releaseCreateGate?.()
  114. }
  115. readonly sandbox = {
  116. files: {
  117. makeDir: async (path: string, options?: CommandOptions): Promise<boolean> => {
  118. this.directories.push(path)
  119. await this.makeDirRequest?.(options?.signal)
  120. options?.signal?.throwIfAborted()
  121. return true
  122. },
  123. write: async (files: Array<{ path: string; data: string }>): Promise<object[]> => {
  124. for (const file of files) this.writes.set(file.path, file.data)
  125. if (this.writeError !== undefined) throw this.writeError
  126. return files.map(() => ({}))
  127. },
  128. remove: async (path: string): Promise<void> => {
  129. this.removed.push(path)
  130. if (this.removeError !== undefined) throw this.removeError
  131. },
  132. },
  133. commands: {
  134. run: async (command: string, options?: CommandOptions): Promise<CommandResult> => {
  135. this.commands.push(command)
  136. if (options !== undefined) this.commandOptions.push(options)
  137. options?.signal?.throwIfAborted()
  138. if (this.commandFailure !== undefined) {
  139. const error = this.commandFailure
  140. this.commandFailure = undefined
  141. throw error
  142. }
  143. if (command.includes('env -0 | base64')) {
  144. return {
  145. exitCode: 0,
  146. stdout: ['/home/user', this.ambient].map(value => Buffer.from(value).toString('base64')).join('\n'),
  147. stderr: '',
  148. }
  149. }
  150. if (command.includes('command -v -- ')) {
  151. return { exitCode: 0, stdout: this.resolvedExecutable, stderr: '' }
  152. }
  153. if (command.startsWith('ps -o sid=')) {
  154. this.afterSessionLookup?.()
  155. return { exitCode: 0, stdout: this.sessionId, stderr: '' }
  156. }
  157. if (command.startsWith('ps -o tpgid=')) {
  158. await this.foregroundRequest?.(options?.signal)
  159. options?.signal?.throwIfAborted()
  160. if (this.foregroundFailure !== undefined) throw this.foregroundFailure
  161. return { exitCode: 0, stdout: this.foreground, stderr: '' }
  162. }
  163. if (command.startsWith('set -o pipefail; ps -eo sid=')) {
  164. if (this.sessionGroupsFailure !== undefined) throw this.sessionGroupsFailure
  165. const groups = command.includes('stat=') && command.includes('$3 !~ /^[ZXx]/')
  166. ? this.groups
  167. : [...this.groups, ...this.zombieGroups]
  168. return { exitCode: 0, stdout: groups.map(group => `${group}\n`).join(''), stderr: '' }
  169. }
  170. if (command.startsWith('kill -TERM -- ')) {
  171. if (this.termFailure !== undefined) throw this.termFailure
  172. if (this.clearOnTerm) {
  173. this.groups = []
  174. this.handle.fail(143)
  175. }
  176. }
  177. if (command.startsWith('kill -INT -- ')) {
  178. await this.signalRequest?.(options?.signal)
  179. options?.signal?.throwIfAborted()
  180. }
  181. if (command.startsWith('kill -KILL -- ') && this.clearOnKill) this.groups = []
  182. return { exitCode: 0, stdout: '', stderr: '' }
  183. },
  184. },
  185. pty: {
  186. resize: async (pid: number, size: { cols: number; rows: number }, options?: { signal?: AbortSignal }): Promise<void> => {
  187. options?.signal?.throwIfAborted()
  188. this.sizes.push({ pid, ...size })
  189. },
  190. create: async (options: Parameters<Sandbox['pty']['create']>[0]): Promise<CommandHandle> => {
  191. this.createOptions = options
  192. if (this.createError !== undefined) throw this.createError
  193. await this.createGate
  194. options.signal?.throwIfAborted()
  195. await options.onData(Buffer.from('buffered banner\n'))
  196. return this.handle.asHandle()
  197. },
  198. sendInput: async (pid: number, data: Uint8Array, options?: { signal?: AbortSignal }): Promise<void> => {
  199. options?.signal?.throwIfAborted()
  200. await this.sendInputRequest?.(options?.signal)
  201. options?.signal?.throwIfAborted()
  202. this.inputs.push({ pid, data: Buffer.from(data) })
  203. if (this.sendError !== undefined) throw this.sendError
  204. if (this.emitOutputMarker && Buffer.from(data).includes(Buffer.from('runner.bash'))) {
  205. const marker = [...this.writes].find(([path]) => path.endsWith('/output-marker'))?.[1]
  206. const onData = this.createOptions?.onData
  207. if (marker !== undefined && onData !== undefined) {
  208. await onData(Buffer.from(Buffer.from(data).toString().replace(/\r$/, '\r\n')))
  209. const split = Math.floor(marker.length / 2)
  210. await onData(Buffer.from(marker.slice(0, split)))
  211. await onData(Buffer.from(marker.slice(split)))
  212. await onData(Buffer.from(this.requestedOutput))
  213. }
  214. }
  215. },
  216. },
  217. } as unknown as Sandbox
  218. }
  219. function runtime(fake: FakeTerminalSandbox): E2BRuntime {
  220. return {
  221. cwd: '/workspace',
  222. runtimeRoot: '/workspace/.dsh-e2b',
  223. getSandbox: async () => fake.sandbox,
  224. } as unknown as E2BRuntime
  225. }
  226. function spec(overrides: Partial<SubprocessTerminalSpawnSpec> = {}): SubprocessTerminalSpawnSpec {
  227. return {
  228. argv: ['/bin/bash', '--noprofile', '--norc'],
  229. cwd: '/workspace',
  230. rows: 24,
  231. cols: 80,
  232. terminalType: 'dumb',
  233. graceMs: 5,
  234. env: { TERM: 'dumb', DSH_SESSION_ID: 'owner', TOKEN_EXPLICIT: 'kept' },
  235. ...overrides,
  236. }
  237. }
  238. function holdRequestUntilAbort(started: PromiseWithResolvers<AbortSignal>) {
  239. return async (signal: AbortSignal | undefined): Promise<void> => {
  240. if (signal === undefined) throw new Error('expected an operation signal')
  241. signal.throwIfAborted()
  242. started.resolve(signal)
  243. await new Promise<void>((_resolve, reject) => {
  244. signal.addEventListener('abort', () => {
  245. reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
  246. }, { once: true })
  247. })
  248. }
  249. }
  250. /** Spawn the terminal under test with the config default the service would pass. */
  251. function testSpawn(
  252. runtime: Parameters<typeof spawnE2BTerminal>[0],
  253. spec: Parameters<typeof spawnE2BTerminal>[1],
  254. stateDir: string,
  255. pollMs = 20,
  256. ): ReturnType<typeof spawnE2BTerminal> {
  257. return spawnE2BTerminal(runtime, spec, stateDir, pollMs)
  258. }
  259. describe('E2B terminal allocation', () => {
  260. it('preserves the requested terminal type and resizes the remote PTY only while running', async () => {
  261. const fake = new FakeTerminalSandbox()
  262. const terminal = await testSpawn(runtime(fake), spec({ terminalType: 'xterm-256color' }), '/runtime/interactive')
  263. try {
  264. expect(fake.writes.get('/runtime/interactive/environment')).toContain('TERM=xterm-256color\0')
  265. await terminal.resize(132, 40)
  266. expect(fake.sizes).toEqual([{ pid: terminal.pid, cols: 132, rows: 40 }])
  267. fake.handle.succeed()
  268. await terminal.done
  269. await expect(terminal.resize(80, 24)).rejects.toThrow('terminal process has exited')
  270. } finally {
  271. await terminal.terminate()
  272. }
  273. })
  274. it('hides bootstrap-shell bytes and preserves requested-shell bytes across the output boundary', async () => {
  275. const fake = new FakeTerminalSandbox()
  276. const terminal = await testSpawn(runtime(fake), spec(), '/runtime/terminal-one')
  277. let output = ''
  278. terminal.output.on('data', (chunk) => { output += String(chunk) })
  279. await new Promise(resolve => setTimeout(resolve, 0))
  280. expect(output).toBe('requested-shell$ ')
  281. expect(output).not.toContain('buffered banner')
  282. expect(output).not.toContain('runner.bash')
  283. expect(fake.createOptions).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace', timeoutMs: 0 })
  284. const controlEnvs = fake.createOptions?.envs
  285. expect(controlEnvs?.HOME).toMatch(/^\/\.dsh-e2b-control-/)
  286. expect(controlEnvs).toEqual({
  287. TERM: 'dumb',
  288. NPM_TOKEN: '',
  289. DSH_STALE: '',
  290. HOME: controlEnvs?.HOME,
  291. })
  292. expect(fake.inputs[0]?.data.toString()).toContain("exec /bin/bash '/runtime/terminal-one/runner.bash'")
  293. expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('KEEP=visible\0')
  294. expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('UNICODE=你好\0')
  295. expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('TOKEN_EXPLICIT=kept\0')
  296. expect(fake.writes.get('/runtime/terminal-one/environment')).not.toContain('secret')
  297. expect(fake.writes.get('/runtime/terminal-one/environment')).not.toContain('DSH_STALE')
  298. expect(fake.writes.get('/runtime/terminal-one/argv')).toBe('/bin/bash\0--noprofile\0--norc\0')
  299. const marker = fake.writes.get('/runtime/terminal-one/output-marker') ?? ''
  300. expect(marker).toMatch(/^dsh-e2b-bootstrap:/)
  301. expect(fake.inputs[0]?.data.toString()).not.toContain(marker)
  302. const runner = fake.writes.get('/runtime/terminal-one/runner.bash') ?? ''
  303. expect(runner).toContain('if (( ${#dsh_argv[@]} == 0 )); then')
  304. expect(runner).toContain('printf \'%s\' "$dsh_output_marker"')
  305. expect(runner).toContain('exec env -i -- "${dsh_env[@]}" "${dsh_argv[@]}"')
  306. expect(runner).not.toContain('\u007f')
  307. terminal.output.destroy()
  308. await fake.createOptions?.onData(Buffer.from('late bootstrap callback'))
  309. expect(output).toBe('requested-shell$ ')
  310. await terminal.write('echo ok\r')
  311. expect(fake.inputs.at(-1)?.data.toString()).toBe('echo ok\r')
  312. await expect(terminal.inspectForeground()).resolves.toEqual({ processGroupId: 456, inputWaiting: false })
  313. await expect(terminal.signalForeground('SIGINT')).resolves.toBe(456)
  314. expect(fake.commands).toContain('kill -INT -- -456')
  315. const terminated = terminal.terminate()
  316. await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  317. await terminated
  318. expect(fake.handle.disconnects).toBe(1)
  319. expect(fake.removed).toContain('/runtime/terminal-one')
  320. })
  321. it('inherits only safe ambient values and limits the allocation signal to setup', async () => {
  322. const fake = new FakeTerminalSandbox()
  323. const controller = new AbortController()
  324. const terminal = await testSpawn(
  325. runtime(fake),
  326. spec({ env: undefined, signal: controller.signal }),
  327. '/runtime/abort-live',
  328. )
  329. const environment = fake.writes.get('/runtime/abort-live/environment') ?? ''
  330. expect(environment).toContain('KEEP=visible\0')
  331. expect(environment).not.toContain('secret')
  332. expect(environment).not.toContain('DSH_STALE')
  333. controller.abort(new Error('stop'))
  334. await terminal.write('still live\r')
  335. expect(fake.inputs.at(-1)?.data.toString()).toBe('still live\r')
  336. await terminal.terminate()
  337. await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  338. })
  339. it('publishes the PTY handle before honoring allocation cancellation', async () => {
  340. const fake = new FakeTerminalSandbox()
  341. fake.deferCreate()
  342. const controller = new AbortController()
  343. const spawning = testSpawn(
  344. runtime(fake),
  345. spec({ signal: controller.signal }),
  346. '/runtime/allocation-cancel',
  347. )
  348. await vi.waitFor(() => { expect(fake.createOptions).toBeDefined() })
  349. controller.abort(new Error('allocation cancelled'))
  350. fake.releaseCreate()
  351. await expect(spawning).rejects.toThrow('allocation cancelled')
  352. expect(fake.createOptions?.signal).toBeUndefined()
  353. expect(fake.groups).toEqual([])
  354. expect(fake.handle.disconnects).toBe(1)
  355. })
  356. it('rejects malformed environment and argv values before PTY allocation', async () => {
  357. const invalidName = new FakeTerminalSandbox()
  358. await expect(testSpawn(runtime(invalidName), spec({ env: { 'BAD=NAME': 'x' } }), '/runtime/name'))
  359. .rejects.toThrow('environment entries')
  360. expect(invalidName.createOptions).toBeUndefined()
  361. const invalidValue = new FakeTerminalSandbox()
  362. await expect(testSpawn(runtime(invalidValue), spec({ env: { BAD: 'x\0y' } }), '/runtime/value'))
  363. .rejects.toThrow('environment entries')
  364. const invalidArg = new FakeTerminalSandbox()
  365. await expect(testSpawn(runtime(invalidArg), spec({ argv: ['/bin/bash', 'x\0y'] }), '/runtime/argv'))
  366. .rejects.toThrow('argv must not contain NUL')
  367. })
  368. it('cleans malformed handles, bootstrap failures, and readiness failures', async () => {
  369. const failedState = new FakeTerminalSandbox()
  370. failedState.writeError = new Error('state write failed')
  371. await expect(testSpawn(runtime(failedState), spec(), '/runtime/state-write'))
  372. .rejects.toThrow('state write failed')
  373. expect(failedState.writes.get('/runtime/state-write/environment')).toContain('KEEP=visible\0')
  374. expect(failedState.removed).toContain('/runtime/state-write')
  375. expect(failedState.createOptions).toBeUndefined()
  376. const stateAlreadyGone = new FakeTerminalSandbox()
  377. stateAlreadyGone.writeError = new Error('state write failed after external cleanup')
  378. stateAlreadyGone.removeError = new FileNotFoundError('state already gone')
  379. await expect(testSpawn(runtime(stateAlreadyGone), spec(), '/runtime/state-gone'))
  380. .rejects.toThrow('state write failed after external cleanup')
  381. const invalidPid = new FakeTerminalSandbox()
  382. invalidPid.handle.pid = 0
  383. await expect(testSpawn(runtime(invalidPid), spec(), '/runtime/invalid-pid'))
  384. .rejects.toThrow('invalid terminal pid 0')
  385. expect(invalidPid.handle.sdkKills).toBe(1)
  386. expect(invalidPid.removed).toContain('/runtime/invalid-pid')
  387. const failedInput = new FakeTerminalSandbox()
  388. failedInput.sendError = new Error('bootstrap failed')
  389. await expect(testSpawn(runtime(failedInput), spec(), '/runtime/input'))
  390. .rejects.toThrow('bootstrap failed')
  391. expect(failedInput.commands).toContain('kill -TERM -- -123')
  392. expect(failedInput.groups).toEqual([])
  393. const invalidSession = new FakeTerminalSandbox()
  394. invalidSession.sessionId = 'not-a-session\n'
  395. invalidSession.clearOnTerm = false
  396. await expect(testSpawn(runtime(invalidSession), spec(), '/runtime/session'))
  397. .rejects.toThrow('cannot resolve process session')
  398. expect(invalidSession.commands).toContain('kill -TERM -- -123')
  399. expect(invalidSession.commands).toContain('kill -KILL -- -123')
  400. expect(invalidSession.groups).toEqual([])
  401. expect(invalidSession.handle.sdkKills).toBe(1)
  402. const lateData = invalidSession.createOptions?.onData
  403. if (lateData === undefined) throw new Error('missing captured terminal callback')
  404. expect(lateData(Buffer.from('late bytes'))).toBeUndefined()
  405. const termFailed = new FakeTerminalSandbox()
  406. termFailed.sendError = new Error('bootstrap failed')
  407. termFailed.termFailure = new Error('TERM transport failed')
  408. await expect(testSpawn(runtime(termFailed), spec(), '/runtime/term-failed'))
  409. .rejects.toThrow('bootstrap failed')
  410. expect(termFailed.commands).toContain('kill -KILL -- -123')
  411. expect(termFailed.handle.sdkKills).toBe(1)
  412. const uninspectable = new FakeTerminalSandbox()
  413. uninspectable.sendError = new Error('bootstrap failed')
  414. uninspectable.sessionGroupsFailure = 'session enumeration failed'
  415. uninspectable.handle.sdkKillError = new Error('PTY kill failed')
  416. let uninspectableFailure: unknown
  417. try {
  418. await testSpawn(runtime(uninspectable), spec(), '/runtime/uninspectable')
  419. } catch (error: unknown) {
  420. uninspectableFailure = error
  421. }
  422. expect(uninspectableFailure).toBeInstanceOf(AggregateError)
  423. expect(uninspectable.handle.sdkKills).toBe(1)
  424. const survivingGroups = new FakeTerminalSandbox()
  425. survivingGroups.sendError = new Error('bootstrap failed')
  426. survivingGroups.clearOnTerm = false
  427. survivingGroups.clearOnKill = false
  428. await expect(testSpawn(runtime(survivingGroups), spec({ graceMs: 1 }), '/runtime/surviving-groups'))
  429. .rejects.toThrow('bootstrap failed')
  430. const survivingPid = new FakeTerminalSandbox()
  431. survivingPid.sendError = new Error('bootstrap failed')
  432. survivingPid.groups = []
  433. survivingPid.handle.settleOnSdkKill = false
  434. await expect(testSpawn(runtime(survivingPid), spec({ graceMs: 1 }), '/runtime/surviving-pid'))
  435. .rejects.toThrow('bootstrap failed')
  436. const waitFailed = new FakeTerminalSandbox()
  437. waitFailed.handle.waitError = new Error('wait failed')
  438. waitFailed.handle.settleOnSdkKill = false
  439. waitFailed.handle.sdkKillError = new Error('kill failed')
  440. await expect(testSpawn(runtime(waitFailed), spec(), '/runtime/wait-failed'))
  441. .rejects.toThrow('wait failed')
  442. expect(waitFailed.handle.sdkKills).toBe(1)
  443. const cleanupFailed = new FakeTerminalSandbox()
  444. cleanupFailed.handle.pid = 0
  445. cleanupFailed.handle.sdkKillError = new Error('kill transport failed')
  446. cleanupFailed.removeError = new Error('remove transport failed')
  447. await expect(testSpawn(runtime(cleanupFailed), spec(), '/runtime/cleanup-failed'))
  448. .rejects.toThrow('invalid terminal pid 0')
  449. const expiredDuringRollback = new FakeTerminalSandbox()
  450. expiredDuringRollback.sendError = new Error('bootstrap failed before timeout')
  451. expiredDuringRollback.groups = []
  452. expiredDuringRollback.handle.settleOnSdkKill = false
  453. expiredDuringRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
  454. expiredDuringRollback.removeError = new SandboxNotFoundError('sandbox expired')
  455. await expect(testSpawn(runtime(expiredDuringRollback), spec(), '/runtime/expired-rollback'))
  456. .rejects.toThrow('bootstrap failed before timeout')
  457. expect(expiredDuringRollback.handle.sdkKills).toBe(1)
  458. const expiredBeforeSdkRollback = new FakeTerminalSandbox()
  459. expiredBeforeSdkRollback.handle.waitError = new Error('wait failed after timeout')
  460. expiredBeforeSdkRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
  461. expiredBeforeSdkRollback.handle.settleOnSdkKill = false
  462. await expect(testSpawn(runtime(expiredBeforeSdkRollback), spec(), '/runtime/expired-sdk-rollback'))
  463. .rejects.toThrow('wait failed after timeout')
  464. const missingDuringDisconnect = new FakeTerminalSandbox()
  465. missingDuringDisconnect.sendError = new Error('bootstrap failed before disconnect')
  466. missingDuringDisconnect.handle.disconnectError = new SandboxNotFoundError('sandbox expired')
  467. await expect(testSpawn(runtime(missingDuringDisconnect), spec(), '/runtime/missing-disconnect'))
  468. .rejects.toThrow('bootstrap failed before disconnect')
  469. const failedDisconnect = new FakeTerminalSandbox()
  470. failedDisconnect.sendError = new Error('bootstrap failed with disconnect failure')
  471. failedDisconnect.handle.disconnectError = new Error('disconnect transport failed')
  472. await expect(testSpawn(runtime(failedDisconnect), spec(), '/runtime/failed-disconnect'))
  473. .rejects.toThrow('bootstrap failed with disconnect failure')
  474. })
  475. it('propagates setup cancellation and provider failures', async () => {
  476. const aborted = new FakeTerminalSandbox()
  477. await expect(testSpawn(runtime(aborted), spec({ signal: AbortSignal.abort(new Error('stop')) }), '/runtime/abort'))
  478. .rejects.toThrow('stop')
  479. const createFailed = new FakeTerminalSandbox()
  480. createFailed.createError = new Error('create failed')
  481. await expect(testSpawn(runtime(createFailed), spec(), '/runtime/create'))
  482. .rejects.toThrow('create failed')
  483. })
  484. it('bounds a missing bootstrap-output boundary by process exit or cancellation', async () => {
  485. const exited = new FakeTerminalSandbox()
  486. exited.emitOutputMarker = false
  487. const exiting = testSpawn(runtime(exited), spec(), '/runtime/missing-output-boundary')
  488. await vi.waitFor(() => { expect(exited.inputs).toHaveLength(1) })
  489. exited.handle.succeed(0)
  490. await expect(exiting).rejects.toThrow('terminal exited before publishing its output boundary')
  491. const cancelled = new FakeTerminalSandbox()
  492. cancelled.emitOutputMarker = false
  493. const controller = new AbortController()
  494. const cancelling = testSpawn(
  495. runtime(cancelled),
  496. spec({ signal: controller.signal }),
  497. '/runtime/cancel-output-boundary',
  498. )
  499. await vi.waitFor(() => { expect(cancelled.inputs).toHaveLength(1) })
  500. await new Promise(resolve => setTimeout(resolve, 0))
  501. controller.abort(new Error('cancel output boundary'))
  502. await expect(cancelling).rejects.toThrow('cancel output boundary')
  503. })
  504. })
  505. describe('E2B terminal lifecycle', () => {
  506. it('aborts and joins in-flight terminal operations before cleanup', async () => {
  507. const fake = new FakeTerminalSandbox()
  508. const terminal = await testSpawn(runtime(fake), spec(), '/runtime/in-flight-operations')
  509. const writeStarted = Promise.withResolvers<AbortSignal>()
  510. const inspectStarted = Promise.withResolvers<AbortSignal>()
  511. const signalStarted = Promise.withResolvers<AbortSignal>()
  512. fake.sendInputRequest = holdRequestUntilAbort(writeStarted)
  513. let foregroundRequests = 0
  514. fake.foregroundRequest = async (signal) => {
  515. foregroundRequests += 1
  516. if (foregroundRequests === 1) await holdRequestUntilAbort(inspectStarted)(signal)
  517. }
  518. let signalCompleted = false
  519. fake.signalRequest = async (operationSignal) => {
  520. await holdRequestUntilAbort(signalStarted)(operationSignal)
  521. signalCompleted = true
  522. }
  523. const write = terminal.write('late input')
  524. const inspect = terminal.inspectForeground()
  525. await Promise.all([writeStarted.promise, inspectStarted.promise])
  526. const signal = terminal.signalForeground('SIGINT')
  527. await signalStarted.promise
  528. const terminating = terminal.terminate()
  529. await expect(write).rejects.toThrow('terminal is terminating')
  530. await expect(inspect).rejects.toThrow('terminal is terminating')
  531. await expect(signal).rejects.toThrow('terminal is terminating')
  532. await terminating
  533. expect(signalCompleted).toBe(false)
  534. expect(fake.inputs).toHaveLength(1)
  535. const commandCount = fake.commands.length
  536. await expect(terminal.write('after termination')).rejects.toThrow('terminal is terminating')
  537. await expect(terminal.inspectForeground()).rejects.toThrow('terminal is terminating')
  538. await expect(terminal.signalForeground('SIGINT')).rejects.toThrow('terminal is terminating')
  539. expect(fake.commands).toHaveLength(commandCount)
  540. })
  541. it('maps ordinary exits, closes output, and reports an absent foreground after exit', async () => {
  542. const fake = new FakeTerminalSandbox()
  543. fake.groups = []
  544. const terminal = await testSpawn(runtime(fake), spec(), '/runtime/natural')
  545. terminal.output.resume()
  546. const ended = once(terminal.output, 'end')
  547. fake.handle.succeed(7)
  548. await expect(terminal.done).resolves.toEqual({ exitCode: 7, signal: null })
  549. await ended
  550. await expect(terminal.write('late')).rejects.toThrow('exited')
  551. fake.foregroundFailure = commandError(1)
  552. await expect(terminal.inspectForeground()).resolves.toBeUndefined()
  553. await expect(terminal.signalForeground('SIGINT')).rejects.toThrow('cannot resolve foreground process group')
  554. await terminal.terminate()
  555. })
  556. it.each([
  557. [7, { exitCode: 7, signal: null }],
  558. [143, { exitCode: 143, signal: null }],
  559. [255, { exitCode: 255, signal: null }],
  560. ] as const)('classifies an unrequested command exit %i', async (exitCode, expected) => {
  561. const fake = new FakeTerminalSandbox()
  562. fake.groups = []
  563. const terminal = await testSpawn(runtime(fake), spec(), `/runtime/exit-${exitCode}`)
  564. fake.handle.fail(exitCode)
  565. await expect(terminal.done).resolves.toEqual(expected)
  566. await terminal.terminate()
  567. })
  568. it('treats a terminal session containing only zombies as quiescent', async () => {
  569. const fake = new FakeTerminalSandbox()
  570. fake.groups = []
  571. fake.zombieGroups = [123]
  572. const terminal = await testSpawn(runtime(fake), spec(), '/runtime/zombie-session')
  573. fake.handle.succeed(0)
  574. await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null })
  575. await terminal.terminate()
  576. expect(fake.commands).toContain(
  577. "set -o pipefail; ps -eo sid=,pgid=,stat= | awk '$1 == 123 && $3 !~ /^[ZXx]/ { print $2 }'",
  578. )
  579. })
  580. it('treats a timeout-killed sandbox as quiescent during terminal cleanup', async () => {
  581. const fake = new FakeTerminalSandbox()
  582. const terminal = await testSpawn(runtime(fake), spec(), '/runtime/expired-sandbox')
  583. fake.sessionGroupsFailure = new SandboxNotFoundError('sandbox expired')
  584. fake.handle.succeed(0)
  585. await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null })
  586. await terminal.terminate()
  587. })
  588. it('treats sandbox disappearance during PTY kill as quiescent', async () => {
  589. const fake = new FakeTerminalSandbox()
  590. fake.groups = []
  591. fake.handle.settleOnSdkKill = false
  592. fake.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
  593. const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/expired-pty-kill')
  594. await terminal.terminate()
  595. expect(fake.handle.sdkKills).toBe(1)
  596. })
  597. it('propagates a non-missing PTY kill failure', async () => {
  598. const fake = new FakeTerminalSandbox()
  599. fake.groups = []
  600. fake.handle.settleOnSdkKill = false
  601. fake.handle.sdkKillError = new Error('PTY kill transport failed')
  602. const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/failed-pty-kill')
  603. await expect(terminal.terminate()).rejects.toThrow('PTY kill transport failed')
  604. fake.handle.sdkKillError = undefined
  605. fake.handle.succeed(0)
  606. await terminal.done
  607. await terminal.terminate()
  608. })
  609. it.each([
  610. ['accepts sandbox loss', new SandboxNotFoundError('sandbox expired'), true],
  611. ['propagates another failure', new Error('disconnect failed'), false],
  612. ] as const)('%s while disconnecting a settled terminal', async (_label, failure, accepted) => {
  613. const fake = new FakeTerminalSandbox()
  614. const terminal = await testSpawn(runtime(fake), spec(), `/runtime/disconnect-${accepted}`)
  615. fake.handle.disconnectError = failure
  616. fake.groups = []
  617. fake.handle.succeed(0)
  618. if (accepted) await expect(terminal.terminate()).resolves.toBeUndefined()
  619. else await expect(terminal.terminate()).rejects.toThrow('disconnect failed')
  620. })
  621. it('rejects killing the terminal shell and propagates live foreground failures', async () => {
  622. const fake = new FakeTerminalSandbox()
  623. fake.foreground = '123\n'
  624. const terminal = await testSpawn(runtime(fake), spec(), '/runtime/signal')
  625. await expect(terminal.signalForeground('SIGKILL')).rejects.toThrow('refusing to SIGKILL')
  626. fake.foreground = 'invalid\n'
  627. await expect(terminal.inspectForeground()).rejects.toThrow('cannot resolve foreground')
  628. fake.foregroundFailure = commandError(1)
  629. await expect(terminal.inspectForeground()).resolves.toBeUndefined()
  630. fake.foregroundFailure = commandError(2)
  631. await expect(terminal.inspectForeground()).rejects.toBeInstanceOf(CommandExitError)
  632. fake.clearOnTerm = true
  633. await terminal.terminate()
  634. })
  635. it('sends KILL before checking an expired force-cleanup deadline', async () => {
  636. const fake = new FakeTerminalSandbox()
  637. fake.groups = [123, 456]
  638. fake.clearOnTerm = false
  639. const terminal = await testSpawn(runtime(fake), spec({ graceMs: 0 }), '/runtime/escalate')
  640. const terminating = terminal.terminate()
  641. await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  642. await terminating
  643. expect(fake.commands).toContain('kill -TERM -- -123 -456')
  644. expect(fake.commands).toContain('kill -KILL -- -123 -456')
  645. })
  646. it('surfaces cleanup failures and allows a later retry', async () => {
  647. const fake = new FakeTerminalSandbox()
  648. fake.groups = [1]
  649. const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/retry')
  650. await expect(terminal.terminate()).rejects.toThrow('unsafe process group 1')
  651. fake.groups = []
  652. fake.handle.succeed(0)
  653. await terminal.done
  654. await terminal.terminate()
  655. })
  656. it('propagates a process-group signalling transport failure before retry', async () => {
  657. const fake = new FakeTerminalSandbox()
  658. fake.termFailure = new Error('signal transport failed')
  659. const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/signal-failure')
  660. await expect(terminal.terminate()).rejects.toThrow('signal transport failed')
  661. fake.groups = []
  662. fake.handle.succeed(0)
  663. await terminal.done
  664. await terminal.terminate()
  665. const alreadyExited = new FakeTerminalSandbox()
  666. alreadyExited.termFailure = commandError(1)
  667. const tolerant = await testSpawn(runtime(alreadyExited), spec({ graceMs: 1 }), '/runtime/group-exited')
  668. const tolerantTermination = tolerant.terminate()
  669. await expect(tolerant.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  670. await tolerantTermination
  671. })
  672. it('keeps command rejection authoritative while cleanup is already waiting', async () => {
  673. const fake = new FakeTerminalSandbox()
  674. fake.groups = []
  675. fake.removeError = new Error('private state already gone')
  676. const terminal = await testSpawn(runtime(fake), spec(), '/runtime/reject-during-cleanup')
  677. terminal.output.on('error', () => {})
  678. const cleanup = terminal.terminate()
  679. await Promise.resolve()
  680. fake.handle.crash(new Error('command transport failed'))
  681. await expect(terminal.done).rejects.toThrow('command transport failed')
  682. await cleanup
  683. })
  684. it('keeps a late command rejection authoritative after PTY kill', async () => {
  685. const fake = new FakeTerminalSandbox()
  686. fake.groups = []
  687. fake.handle.settleOnSdkKill = false
  688. const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/reject-after-kill')
  689. terminal.output.on('error', () => {})
  690. const cleanup = terminal.terminate()
  691. while (fake.handle.sdkKills === 0) await new Promise(resolve => setTimeout(resolve, 0))
  692. await Promise.resolve()
  693. fake.handle.crash(new Error('late command transport failed'))
  694. await expect(terminal.done).rejects.toThrow('late command transport failed')
  695. await cleanup
  696. })
  697. it('reports surviving groups, a surviving top-level pid, and transport failure', async () => {
  698. const survivor = new FakeTerminalSandbox()
  699. survivor.clearOnTerm = false
  700. survivor.clearOnKill = false
  701. const terminal = await testSpawn(runtime(survivor), spec({ graceMs: 1 }), '/runtime/survivor')
  702. await expect(terminal.terminate()).rejects.toThrow('surviving process groups: 123')
  703. const livePid = new FakeTerminalSandbox()
  704. livePid.groups = []
  705. livePid.handle.settleOnSdkKill = false
  706. const live = await testSpawn(runtime(livePid), spec({ graceMs: 1 }), '/runtime/live-pid')
  707. await expect(live.terminate()).rejects.toThrow('surviving pid: 123')
  708. livePid.handle.succeed(0)
  709. await live.done
  710. const crashed = new FakeTerminalSandbox()
  711. crashed.groups = []
  712. const failed = await testSpawn(runtime(crashed), spec(), '/runtime/crashed')
  713. const outputError = once(failed.output, 'error')
  714. crashed.handle.crash('transport gone')
  715. await expect(failed.done).rejects.toEqual('transport gone')
  716. await expect(outputError).resolves.toMatchObject([{ message: 'transport gone' }])
  717. await failed.terminate()
  718. })
  719. })
  720. describe('E2B subprocess terminal service', () => {
  721. async function service(fake = new FakeTerminalSandbox()): Promise<{
  722. ctx: Context
  723. fiber: Awaited<ReturnType<Context['plugin']>>
  724. fake: FakeTerminalSandbox
  725. }> {
  726. const ctx = new Context()
  727. ctx.provide('e2b', runtime(fake))
  728. const fiber = await ctx.plugin(E2BSubprocessRuntime)
  729. return { ctx, fiber, fake }
  730. }
  731. it('resolves remote executables', async () => {
  732. const { ctx, fake } = await service()
  733. await expect(ctx.subprocess.resolveExecutable('/bin/bash')).resolves.toBe('/bin/bash')
  734. await expect(ctx.subprocess.resolveExecutable('node', { PATH: '/custom/bin' }, new AbortController().signal))
  735. .resolves.toBe('/usr/bin/node')
  736. fake.resolvedExecutable = 'tools/bin/node\n'
  737. await expect(ctx.subprocess.resolveExecutable('node', { PATH: 'tools/bin' }))
  738. .resolves.toBe('/workspace/tools/bin/node')
  739. const commandOptions = fake.commandOptions.at(-1)
  740. expect(commandOptions).toMatchObject({ cwd: '/workspace' })
  741. expect(commandOptions?.envs?.HOME).toMatch(/^\/\.dsh-e2b-control-/)
  742. expect(commandOptions?.envs).toEqual({ HOME: commandOptions?.envs?.HOME })
  743. expect((ctx.e2b)).toBeDefined()
  744. })
  745. it('rejects invalid executable lookup inputs and results', async () => {
  746. const { ctx, fake } = await service()
  747. await expect(ctx.subprocess.resolveExecutable('')).rejects.toThrow('non-empty')
  748. await expect(ctx.subprocess.resolveExecutable('./bin/server')).rejects.toThrow('is a relative path')
  749. await expect(ctx.subprocess.resolveExecutable('node_modules/.bin/server')).rejects.toThrow('is a relative path')
  750. await expect(ctx.subprocess.resolveExecutable('node', undefined, AbortSignal.abort(new Error('stop'))))
  751. .rejects.toThrow('stop')
  752. fake.resolvedExecutable = 'node\n'
  753. await expect(ctx.subprocess.resolveExecutable('node')).rejects.toThrow('did not resolve')
  754. fake.resolvedExecutable = '/one\n/two\n'
  755. await expect(ctx.subprocess.resolveExecutable('node')).rejects.toThrow('did not resolve')
  756. })
  757. it.each(['/missing/shell', 'missing-shell'])('classifies missing remote executable %s and preserves other failures', async (command) => {
  758. const { ctx, fake, fiber } = await service()
  759. try {
  760. for (const exitCode of [1, 127]) {
  761. const error = commandError(exitCode)
  762. fake.commandFailure = error
  763. await expect(ctx.subprocess.resolveExecutable(command)).rejects.toMatchObject({
  764. constructor: SubprocessExecutableNotFoundError,
  765. cause: error,
  766. })
  767. }
  768. for (const error of [commandError(126), new Error('sandbox disconnected')]) {
  769. fake.commandFailure = error
  770. await expect(ctx.subprocess.resolveExecutable(command)).rejects.toBe(error)
  771. }
  772. } finally {
  773. await fiber.dispose()
  774. }
  775. })
  776. it('discovers terminal environment facts from the remote sandbox and propagates transport failures', async () => {
  777. const { ctx, fake, fiber } = await service()
  778. try {
  779. fake.ambient = 'SHELL=/opt/fish\0'
  780. await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix', defaultShell: '/opt/fish' })
  781. fake.ambient = 'PATH=/usr/bin\0'
  782. await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' })
  783. fake.commandFailure = new Error('sandbox disconnected')
  784. await expect(ctx.subprocess.terminalEnvironment()).rejects.toThrow('sandbox disconnected')
  785. } finally {
  786. await fiber.dispose()
  787. }
  788. })
  789. it('rejects a non-positive poll cadence at load', async () => {
  790. const ctx = new Context()
  791. ctx.provide('e2b', runtime(new FakeTerminalSandbox()))
  792. await expect(ctx.plugin(E2BSubprocessRuntime, { pollMs: 0 }))
  793. .rejects.toThrow('pollMs must be a positive safe integer')
  794. const explicit = await ctx.plugin(E2BSubprocessRuntime, { pollMs: 5 })
  795. await explicit.dispose()
  796. })
  797. it('owns live terminals through service disposal', async () => {
  798. const { ctx, fiber, fake } = await service()
  799. const terminal = await ctx.subprocess.spawnTerminal(spec({ signal: new AbortController().signal }))
  800. await fiber.dispose()
  801. await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  802. expect(fake.handle.disconnects).toBe(1)
  803. })
  804. it('joins and rejects terminal setup that completes during service disposal', async () => {
  805. const fake = new FakeTerminalSandbox()
  806. const { ctx, fiber } = await service(fake)
  807. let disposing: Promise<void> | undefined
  808. fake.afterSessionLookup = () => {
  809. fake.afterSessionLookup = undefined
  810. queueMicrotask(() => {
  811. queueMicrotask(() => { disposing = fiber.dispose() })
  812. })
  813. }
  814. const subprocess = ctx.subprocess
  815. const spawning = ctx.subprocess.spawnTerminal(spec())
  816. const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup')
  817. await vi.waitFor(() => { expect(disposing).toBeDefined() })
  818. await expect(subprocess.spawnTerminal(spec())).rejects.toThrow('service is disposing')
  819. await rejected
  820. await disposing
  821. expect(fake.groups).toEqual([])
  822. expect(fake.handle.disconnects).toBe(1)
  823. expect(fake.removed.some(path => path.includes('/terminals/'))).toBe(true)
  824. })
  825. it('aborts and rolls back terminal setup that cannot publish its output boundary during disposal', async () => {
  826. const fake = new FakeTerminalSandbox()
  827. fake.emitOutputMarker = false
  828. const { ctx, fiber } = await service(fake)
  829. const spawning = ctx.subprocess.spawnTerminal(spec())
  830. const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup')
  831. await vi.waitFor(() => { expect(fake.inputs).toHaveLength(1) })
  832. await fiber.dispose()
  833. await rejected
  834. expect(fake.groups).toEqual([])
  835. expect(fake.handle.disconnects).toBe(1)
  836. })
  837. it('owns and cancels terminal state-directory creation during disposal', async () => {
  838. const fake = new FakeTerminalSandbox()
  839. fake.makeDirRequest = async (signal) => {
  840. await new Promise<never>((_resolve, reject) => {
  841. const onAbort = (): void => {
  842. const reason: unknown = signal?.reason
  843. reject(reason instanceof Error ? reason : new Error(String(reason)))
  844. }
  845. signal?.addEventListener('abort', onAbort, { once: true })
  846. if (signal?.aborted === true) onAbort()
  847. })
  848. }
  849. const { ctx, fiber } = await service(fake)
  850. const spawning = ctx.subprocess.spawnTerminal(spec())
  851. const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup')
  852. await vi.waitFor(() => { expect(fake.directories.some(path => path.includes('/terminals/'))).toBe(true) })
  853. await fiber.dispose()
  854. await rejected
  855. expect(fake.removed.some(path => path.includes('/terminals/'))).toBe(true)
  856. expect(fake.createOptions).toBeUndefined()
  857. })
  858. it('releases naturally settled terminals and validates terminal requests', async () => {
  859. const { ctx, fiber, fake } = await service()
  860. for (const request of [
  861. spec({ argv: [] }),
  862. spec({ signal: AbortSignal.abort(new Error('cancelled')) }),
  863. ]) {
  864. await expect(ctx.subprocess.spawnTerminal(request)).rejects.toThrow()
  865. }
  866. fake.groups = []
  867. const terminal = await ctx.subprocess.spawnTerminal(spec())
  868. fake.handle.succeed(0)
  869. await terminal.done
  870. await terminal.terminate()
  871. const signals = fake.commands.filter(command => command.startsWith('kill -')).length
  872. await fiber.dispose()
  873. expect(fake.commands.filter(command => command.startsWith('kill -'))).toHaveLength(signals)
  874. })
  875. it('contains a failed automatic terminal release until service disposal retries it', async () => {
  876. const { fiber, fake } = await service()
  877. fake.clearOnTerm = false
  878. fake.clearOnKill = false
  879. const terminal = await (fiber.ctx).subprocess.spawnTerminal(spec({ graceMs: 1 }))
  880. fake.handle.succeed(0)
  881. await terminal.done
  882. await new Promise(resolve => setTimeout(resolve, 10))
  883. expect(fake.commands).toContain('kill -KILL -- -123')
  884. fake.groups = []
  885. await fiber.dispose()
  886. await expect(terminal.terminate()).resolves.toBeUndefined()
  887. })
  888. it('contains an immediate automatic terminal release rejection before disposal retries it', async () => {
  889. const { fiber, fake } = await service()
  890. fake.groups = []
  891. const terminal = await (fiber.ctx).subprocess.spawnTerminal(spec())
  892. const terminate = vi.spyOn(terminal, 'terminate')
  893. .mockRejectedValueOnce(new Error('automatic release failed'))
  894. fake.handle.succeed(0)
  895. await terminal.done
  896. await vi.waitFor(() => { expect(terminate).toHaveBeenCalledTimes(1) })
  897. await new Promise(resolve => setTimeout(resolve, 0))
  898. await fiber.dispose()
  899. expect(terminate).toHaveBeenCalledTimes(2)
  900. expect(fake.handle.disconnects).toBe(1)
  901. })
  902. })