spawn.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { dirname, join } from 'node:path'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import type { DshEnvironment } from '@deepseek-ai/dsh-process'
  6. import { killGroup, OutputCollector, spawnProcess } from '../src/spawn.ts'
  7. import type { ProcessHandle } from '@deepseek-ai/dsh-process'
  8. const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
  9. failNextClose: { value: false },
  10. failNextUnlink: { value: false },
  11. }))
  12. vi.mock('node:fs', async (importOriginal) => {
  13. const actual = await importOriginal<typeof import('node:fs')>()
  14. return {
  15. ...actual,
  16. closeSync(fd: number): void {
  17. if (failNextClose.value) {
  18. failNextClose.value = false
  19. throw Object.assign(new Error('simulated EIO on close'), { code: 'EIO' })
  20. }
  21. actual.closeSync(fd)
  22. },
  23. unlinkSync(path: Parameters<typeof actual.unlinkSync>[0]): void {
  24. if (failNextUnlink.value) {
  25. failNextUnlink.value = false
  26. throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' })
  27. }
  28. actual.unlinkSync(path)
  29. },
  30. }
  31. })
  32. const spillDir = mkdtempSync(join(tmpdir(), 'dsh-proc-spec-'))
  33. function spec(command: string, overrides: Partial<Parameters<typeof spawnProcess>[0]> = {}) {
  34. return {
  35. argv: ['bash', '-c', command],
  36. cwd: process.cwd(),
  37. stdoutMaxBytes: 64_000,
  38. stderrMaxBytes: 64_000,
  39. maxSpillBytes: 64 * 1024 * 1024,
  40. graceMs: 3_000,
  41. ...overrides,
  42. }
  43. }
  44. /** Poll until a pid no longer exists (kill(pid, 0) throws ESRCH). */
  45. async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
  46. const deadline = Date.now() + timeoutMs
  47. while (Date.now() < deadline) {
  48. try {
  49. process.kill(pid, 0)
  50. } catch {
  51. return
  52. }
  53. await new Promise(resolve => setTimeout(resolve, 20))
  54. }
  55. throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
  56. }
  57. async function waitForStdout(running: ProcessHandle, expected: string, timeoutMs = 5_000): Promise<void> {
  58. const deadline = Date.now() + timeoutMs
  59. while (Date.now() < deadline) {
  60. if (running.stdout.readFrom(0).text.includes(expected)) return
  61. await new Promise(resolve => setTimeout(resolve, 20))
  62. }
  63. throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`)
  64. }
  65. async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number> {
  66. const deadline = Date.now() + timeoutMs
  67. while (Date.now() < deadline) {
  68. try {
  69. const pid = Number(readFileSync(path, 'utf8').trim())
  70. if (Number.isSafeInteger(pid) && pid > 0) return pid
  71. } catch {
  72. // The child shell has not written the pid file yet.
  73. }
  74. await new Promise(resolve => setTimeout(resolve, 20))
  75. }
  76. throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`)
  77. }
  78. describe('spawnProcess', () => {
  79. it('captures stdout on success', async () => {
  80. const result = await spawnProcess(spec('echo hello')).done
  81. expect(result.exitCode).toBe(0)
  82. expect(result.signal).toBeNull()
  83. expect(result.stdout.text).toBe('hello\n')
  84. expect(result.stdout.truncated).toBe(false)
  85. expect(result.stderr.text).toBe('')
  86. })
  87. it('captures stderr separately', async () => {
  88. const result = await spawnProcess(spec('echo oops >&2')).done
  89. expect(result.exitCode).toBe(0)
  90. expect(result.stdout.text).toBe('')
  91. expect(result.stderr.text).toBe('oops\n')
  92. })
  93. it('captures both streams', async () => {
  94. const result = await spawnProcess(spec('echo out; echo err >&2')).done
  95. expect(result.stdout.text).toBe('out\n')
  96. expect(result.stderr.text).toBe('err\n')
  97. })
  98. it('reports non-zero exit codes', async () => {
  99. const result = await spawnProcess(spec('exit 42')).done
  100. expect(result.exitCode).toBe(42)
  101. expect(result.signal).toBeNull()
  102. })
  103. it('passes the ambient TERM through untouched (terminal policy is the caller\'s)', async () => {
  104. const result = await spawnProcess(spec('echo "${TERM:-unset}"', {
  105. env: { TERM: 'callers-choice' },
  106. })).done
  107. expect(result.stdout.text).toBe('callers-choice\n')
  108. })
  109. it('runs in the requested cwd', async () => {
  110. const result = await spawnProcess(spec('pwd', { cwd: '/tmp' })).done
  111. expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
  112. })
  113. it('kills the process group with SIGTERM when the signal fires', async () => {
  114. // spawnProcess owns no timer: it kills on abort. The bash executor drives the timeout
  115. // by firing this signal via a deadline (see executor.spec.ts); here we
  116. // assert the kill itself lands as SIGTERM.
  117. const controller = new AbortController()
  118. const start = Date.now()
  119. const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
  120. setTimeout(() => { controller.abort('deadline') }, 100)
  121. const result = await running.done
  122. expect(Date.now() - start).toBeLessThan(5_000)
  123. expect(result.signal).toBe('SIGTERM')
  124. expect(result.exitCode).toBeNull()
  125. })
  126. it('escalates to SIGKILL when SIGTERM is trapped', async () => {
  127. const running = spawnProcess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
  128. await waitForStdout(running, 'ready\n')
  129. running.kill()
  130. const result = await running.done
  131. expect(result.signal).toBe('SIGKILL')
  132. })
  133. it('kills the whole process group (grandchildren die too)', async () => {
  134. // The subshell writes the sleep's pid then waits on it; killing the
  135. // group must take the sleep down with bash.
  136. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
  137. const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
  138. const grandchild = await waitForPidFile(pidFile)
  139. expect(grandchild).toBeGreaterThan(0)
  140. running.kill()
  141. const result = await running.done
  142. expect(result.signal).toBe('SIGTERM')
  143. await waitGone(grandchild)
  144. })
  145. it('aborts via AbortSignal mid-run', async () => {
  146. const controller = new AbortController()
  147. const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
  148. setTimeout(() => { controller.abort('user cancelled') }, 50)
  149. const result = await running.done
  150. expect(result.signal).toBe('SIGTERM')
  151. })
  152. it('throws when the signal is already aborted before spawn', () => {
  153. const controller = new AbortController()
  154. controller.abort('too late')
  155. expect(() => spawnProcess(spec('echo hi', { signal: controller.signal })))
  156. .toThrow(/aborted before spawn: too late/)
  157. })
  158. it('rejects with a spawn error for a nonexistent cwd', async () => {
  159. await expect(spawnProcess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
  160. .rejects.toThrow(/ENOENT/)
  161. })
  162. it('kill() is idempotent (second call does not restart escalation)', async () => {
  163. const running = spawnProcess(spec('sleep 60'))
  164. running.kill()
  165. running.kill()
  166. const result = await running.done
  167. expect(result.signal).toBe('SIGTERM')
  168. })
  169. it('bounds inherited-pipe draining after the shell exits', async () => {
  170. const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
  171. const started = Date.now()
  172. const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
  173. const descendant = await waitForPidFile(pidFile)
  174. try {
  175. const result = await running.done
  176. expect(Date.now() - started).toBeLessThan(1_000)
  177. expect(result.exitCode).toBe(0)
  178. expect(result.stdout.text).toBe('shell-done\n')
  179. } finally {
  180. process.kill(descendant, 'SIGKILL')
  181. await waitGone(descendant)
  182. }
  183. })
  184. })
  185. describe('stdin and extra env (set by in-process plugins)', () => {
  186. it('writes stdin to the command and closes it', async () => {
  187. const result = await spawnProcess(spec('cat', { stdin: 'hello from stdin\n' })).done
  188. expect(result.exitCode).toBe(0)
  189. expect(result.stdout.text).toBe('hello from stdin\n')
  190. })
  191. it('a command that reads stdin sees EOF when none is supplied', async () => {
  192. // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no
  193. // output (it does NOT block).
  194. const result = await spawnProcess(spec('cat')).done
  195. expect(result.exitCode).toBe(0)
  196. expect(result.stdout.text).toBe('')
  197. })
  198. it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
  199. // With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device).
  200. // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO.
  201. const none = await spawnProcess(spec('test -c /dev/stdin && echo char || echo other')).done
  202. expect(none.stdout.text).toBe('char\n')
  203. const piped = await spawnProcess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
  204. expect(piped.stdout.text).toBe('socket\n')
  205. })
  206. it('merges ordinary extra env entries onto the scrubbed environment', async () => {
  207. const result = await spawnProcess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
  208. env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' },
  209. })).done
  210. expect(result.stdout.text).toBe('alpha/beta\n')
  211. })
  212. it('an explicit extra env entry overrides the credential scrub', async () => {
  213. // EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
  214. // entry is still honored — the scrub only drops AMBIENT process.env creds.
  215. const result = await spawnProcess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', {
  216. env: { EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
  217. })).done
  218. expect(result.stdout.text).toBe('explicit-wins\n')
  219. })
  220. it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
  221. // The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE.
  222. // The handler swallows that write error and `done` reports the child's real exit.
  223. const big = 'x'.repeat(1024 * 1024)
  224. const result = await spawnProcess(spec('exit 7', { stdin: big })).done
  225. expect(result.exitCode).toBe(7)
  226. })
  227. })
  228. describe('output truncation and spill', () => {
  229. it('applies stdout and stderr caps independently', async () => {
  230. const result = await spawnProcess(
  231. spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', {
  232. stdoutMaxBytes: 500,
  233. stderrMaxBytes: 100,
  234. }),
  235. { spillDir },
  236. ).done
  237. expect(result.stdout.truncated).toBe(false)
  238. expect(result.stdout.text).toBe('x'.repeat(500))
  239. expect(result.stderr.truncated).toBe(true)
  240. expect(result.stderr.text.length).toBeLessThanOrEqual(100)
  241. })
  242. it('keeps the tail and spills the full stream to disk', async () => {
  243. // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
  244. const result = await spawnProcess(
  245. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
  246. { spillDir },
  247. ).done
  248. expect(result.stdout.truncated).toBe(true)
  249. expect(result.stdout.text.length).toBeLessThanOrEqual(500)
  250. expect(result.stdout.text).toContain('line-0200')
  251. expect(result.stdout.text).not.toContain('line-0001')
  252. expect(result.stdout.spillPath).toBeDefined()
  253. const full = readFileSync(result.stdout.spillPath!, 'utf8')
  254. expect(full).toContain('line-0001')
  255. expect(full).toContain('line-0200')
  256. })
  257. it('does not truncate output exactly at the cap', async () => {
  258. const result = await spawnProcess(
  259. spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
  260. { spillDir },
  261. ).done
  262. expect(result.stdout.truncated).toBe(false)
  263. expect(result.stdout.text.length).toBe(500)
  264. expect(result.stdout.spillPath).toBeUndefined()
  265. })
  266. it('settles with the tail and no spill path when final spill close fails', async () => {
  267. failNextClose.value = true
  268. const result = await spawnProcess(
  269. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
  270. { spillDir },
  271. ).done
  272. expect(failNextClose.value).toBe(false)
  273. expect(result.exitCode).toBe(0)
  274. expect(result.stdout.truncated).toBe(true)
  275. expect(result.stdout.text).toContain('line-0200')
  276. expect(result.stdout.spillPath).toBeUndefined()
  277. })
  278. })
  279. describe('OutputCollector', () => {
  280. it('keeps the tail of a single oversized chunk', () => {
  281. const collector = new OutputCollector(10, 100, 'test', spillDir)
  282. collector.push(Buffer.from('0123456789abcdef'))
  283. const out = collector.finalize()
  284. expect(out.text).toBe('6789abcdef')
  285. expect(out.truncated).toBe(true)
  286. expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef')
  287. })
  288. it('readFrom returns increments and flags lossy reads', () => {
  289. const collector = new OutputCollector(10, 100, 'test', spillDir)
  290. collector.push(Buffer.from('aaaaa'))
  291. const first = collector.readFrom(0)
  292. expect(first.text).toBe('aaaaa')
  293. expect(first.lossy).toBe(false)
  294. expect(first.nextOffset).toBe(5)
  295. collector.push(Buffer.from('bbbbb'))
  296. const second = collector.readFrom(first.nextOffset)
  297. expect(second.text).toBe('bbbbb')
  298. expect(second.lossy).toBe(false)
  299. // Push enough to slide the window past the last offset.
  300. collector.push(Buffer.from('c'.repeat(20)))
  301. const third = collector.readFrom(second.nextOffset)
  302. expect(third.lossy).toBe(true)
  303. expect(third.text).toBe('c'.repeat(10))
  304. expect(third.spillPath).toBeDefined()
  305. })
  306. it('contains close failures and drops the spill path', () => {
  307. const collector = new OutputCollector(4, 100, 'closefail', spillDir)
  308. collector.push(Buffer.from('aaaa'))
  309. collector.push(Buffer.from('bbbb'))
  310. expect(collector.readFrom(0).spillPath).toBeDefined()
  311. failNextClose.value = true
  312. let out: ReturnType<typeof collector.finalize>
  313. expect(() => { out = collector.finalize() }).not.toThrow()
  314. expect(failNextClose.value).toBe(false)
  315. expect(out!.text).toBe('bbbb')
  316. expect(out!.truncated).toBe(true)
  317. expect(out!.spillPath).toBeUndefined()
  318. })
  319. it('discards a spill that exceeds its configured cap', () => {
  320. const collector = new OutputCollector(4, 8, 'bounded', spillDir)
  321. collector.push(Buffer.from('aaaa'))
  322. collector.push(Buffer.from('bbbb'))
  323. const spillPath = collector.readFrom(0).spillPath!
  324. expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb')
  325. collector.push(Buffer.from('c'))
  326. collector.push(Buffer.from('dddd'))
  327. const out = collector.finalize()
  328. expect(out.text).toBe('dddd')
  329. expect(out.truncated).toBe(true)
  330. expect(out.spillPath).toBeUndefined()
  331. expect(() => readFileSync(spillPath)).toThrow()
  332. })
  333. it('does not create a spill when the first overflowing chunk exceeds the cap', () => {
  334. const collector = new OutputCollector(4, 4, 'no-spill', spillDir)
  335. collector.push(Buffer.from('abcdefgh'))
  336. const out = collector.finalize()
  337. expect(out.text).toBe('efgh')
  338. expect(out.truncated).toBe(true)
  339. expect(out.spillPath).toBeUndefined()
  340. })
  341. it('contains cleanup failures while disabling an oversize spill', () => {
  342. const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir)
  343. collector.push(Buffer.from('aaaa'))
  344. collector.push(Buffer.from('bbbb'))
  345. const spillPath = collector.readFrom(0).spillPath!
  346. failNextClose.value = true
  347. failNextUnlink.value = true
  348. expect(() => { collector.push(Buffer.from('c')) }).not.toThrow()
  349. expect(failNextClose.value).toBe(false)
  350. expect(failNextUnlink.value).toBe(false)
  351. expect(collector.finalize().spillPath).toBeUndefined()
  352. unlinkSync(spillPath)
  353. })
  354. })
  355. describe('killGroup', () => {
  356. it('ignores non-positive pids', () => {
  357. expect(() => { killGroup(-1, 'SIGTERM') }).not.toThrow()
  358. expect(() => { killGroup(0, 'SIGTERM') }).not.toThrow()
  359. })
  360. it('swallows ESRCH for vanished groups', async () => {
  361. const running = spawnProcess(spec('true'))
  362. await running.done
  363. expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
  364. })
  365. })
  366. describe('argv validation', () => {
  367. it('rejects an empty argv before spawning', () => {
  368. expect(() => spawnProcess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/)
  369. })
  370. it('rejects an empty program name before spawning', () => {
  371. expect(() => spawnProcess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/)
  372. })
  373. it('spawns argv verbatim without shell interpretation', async () => {
  374. const result = await spawnProcess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] }).done
  375. expect(result.stdout.text).toBe('$HOME')
  376. })
  377. })
  378. describe('abort edge cases', () => {
  379. it('reports a fallback reason for reason-less pre-aborted signals', () => {
  380. // Real AbortControllers always set a DOMException reason; signal-like
  381. // objects from other libraries may not — the fallback covers them.
  382. const bare = {
  383. aborted: true,
  384. reason: undefined,
  385. addEventListener() {},
  386. removeEventListener() {},
  387. } as unknown as AbortSignal
  388. expect(() => spawnProcess(spec('echo hi', { signal: bare })))
  389. .toThrow(/aborted before spawn: aborted/)
  390. })
  391. it('reports the terminating signal of an externally self-killed command', async () => {
  392. // spawnProcess reports the raw signal; whether it counts as timeout/cancel is the
  393. // executor's classification (a self-kill is neither) — see executor.spec.ts.
  394. const result = await spawnProcess(spec('kill -TERM $$')).done
  395. expect(result.signal).toBe('SIGTERM')
  396. })
  397. })
  398. describe('environment and spill-file hardening', () => {
  399. it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => {
  400. process.env.DSH_TEST_API_KEY = 'super-secret'
  401. process.env.DSH_TEST_TOKEN = 'also-secret'
  402. process.env.DSH_TEST_PLAIN = 'visible'
  403. try {
  404. const result = await spawnProcess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
  405. expect(result.stdout.text.trim()).toBe('[absent|absent|absent]')
  406. } finally {
  407. delete process.env.DSH_TEST_API_KEY
  408. delete process.env.DSH_TEST_TOKEN
  409. delete process.env.DSH_TEST_PLAIN
  410. }
  411. })
  412. it('injects only the current trusted DSH environment after scrubbing ambient values', async () => {
  413. process.env.DSH_STALE = 'old-value'
  414. try {
  415. const result = await spawnProcess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
  416. dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' },
  417. })).done
  418. expect(result.stdout.text.trim()).toBe('[absent|1|current-session]')
  419. } finally {
  420. delete process.env.DSH_STALE
  421. }
  422. })
  423. it('rejects DSH variables on the ordinary env channel', () => {
  424. expect(() => spawnProcess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
  425. .toThrow(/DSH_WRONG_CHANNEL.*dshEnv/)
  426. })
  427. it('rejects ordinary variables on the managed env channel', () => {
  428. const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment
  429. expect(() => spawnProcess(spec('true', { dshEnv: invalid })))
  430. .toThrow(/managed child env.*PATH.*use env/)
  431. })
  432. it('creates spill files with owner-only permissions and random names', async () => {
  433. const result = await spawnProcess(
  434. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
  435. { spillDir },
  436. ).done
  437. const path = result.stdout.spillPath!
  438. expect(path).toMatch(/dsh-proc-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
  439. const mode = statSync(path).mode & 0o777
  440. expect(mode).toBe(0o600)
  441. })
  442. it('defaults spills into a private per-process directory', async () => {
  443. const result = await spawnProcess(
  444. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
  445. ).done
  446. const dir = dirname(result.stdout.spillPath!)
  447. expect(dir).toMatch(/dsh-proc-/)
  448. const mode = statSync(dir).mode & 0o777
  449. expect(mode).toBe(0o700)
  450. })
  451. it('killGroup never throws, even for EPERM-style failures', () => {
  452. const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
  453. throw Object.assign(new Error('EPERM'), { code: 'EPERM' })
  454. })
  455. try {
  456. expect(() => { killGroup(12345, 'SIGTERM') }).not.toThrow()
  457. } finally {
  458. spy.mockRestore()
  459. }
  460. })
  461. it('honors AbortSignal on background-style runs (no timeout)', async () => {
  462. const controller = new AbortController()
  463. const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
  464. setTimeout(() => { controller.abort() }, 50)
  465. const result = await running.done
  466. expect(result.signal).toBe('SIGTERM')
  467. })
  468. })