spawn.spec.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905
  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-subprocess'
  6. import { killGroup, OutputCollector, spawnSubprocess, taskkillProcessTree } from '../src/spawn.ts'
  7. import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
  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-subprocess-spec-'))
  33. type SpecOverrides = Partial<Parameters<typeof spawnSubprocess>[0]> & {
  34. stdoutMaxBytes?: number
  35. stderrMaxBytes?: number
  36. maxSpillBytes?: number
  37. stdin?: string
  38. }
  39. function spec(command: string, overrides: SpecOverrides = {}) {
  40. const { stdoutMaxBytes = 64_000, stderrMaxBytes = 64_000, maxSpillBytes = 64 * 1024 * 1024, stdin, ...rest } = overrides
  41. return {
  42. argv: ['bash', '-c', command],
  43. cwd: process.cwd(),
  44. stdio: {
  45. stdin: stdin !== undefined ? { data: stdin } : 'ignore' as const,
  46. stdout: { maxBytes: stdoutMaxBytes, spill: { maxBytes: maxSpillBytes } },
  47. stderr: { maxBytes: stderrMaxBytes, spill: { maxBytes: maxSpillBytes } },
  48. },
  49. graceMs: 3_000,
  50. ...rest,
  51. }
  52. }
  53. /** Poll until a pid no longer exists (kill(pid, 0) throws ESRCH). */
  54. async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
  55. const deadline = Date.now() + timeoutMs
  56. while (Date.now() < deadline) {
  57. try {
  58. process.kill(pid, 0)
  59. } catch {
  60. return
  61. }
  62. await new Promise(resolve => setTimeout(resolve, 20))
  63. }
  64. throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
  65. }
  66. async function waitForStdout(running: SubprocessHandle, expected: string, timeoutMs = 5_000): Promise<void> {
  67. const deadline = Date.now() + timeoutMs
  68. while (Date.now() < deadline) {
  69. if (running.collected.stdout!.readFrom(0).text.includes(expected)) return
  70. await new Promise(resolve => setTimeout(resolve, 20))
  71. }
  72. throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`)
  73. }
  74. /** Await settlement and project both collected streams like a batch outcome. */
  75. async function finish(running: SubprocessHandle) {
  76. const outcome = await running.done
  77. const final = (reader: SubprocessOutputReader | undefined) => {
  78. const read = reader!.readFrom(0)
  79. return { text: read.text, truncated: read.lossy, ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {} }
  80. }
  81. return { ...outcome, stdout: final(running.collected.stdout), stderr: final(running.collected.stderr) }
  82. }
  83. async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number> {
  84. const deadline = Date.now() + timeoutMs
  85. while (Date.now() < deadline) {
  86. try {
  87. const pid = Number(readFileSync(path, 'utf8').trim())
  88. if (Number.isSafeInteger(pid) && pid > 0) return pid
  89. } catch {
  90. // The child shell has not written the pid file yet.
  91. }
  92. await new Promise(resolve => setTimeout(resolve, 20))
  93. }
  94. throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`)
  95. }
  96. describe('spawnSubprocess', () => {
  97. it('captures stdout on success', async () => {
  98. const result = await finish(spawnSubprocess(spec('echo hello')))
  99. expect(result.exitCode).toBe(0)
  100. expect(result.signal).toBeNull()
  101. expect(result.stdout.text).toBe('hello\n')
  102. expect(result.stdout.truncated).toBe(false)
  103. expect(result.stderr.text).toBe('')
  104. })
  105. it('captures stderr separately', async () => {
  106. const result = await finish(spawnSubprocess(spec('echo oops >&2')))
  107. expect(result.exitCode).toBe(0)
  108. expect(result.stdout.text).toBe('')
  109. expect(result.stderr.text).toBe('oops\n')
  110. })
  111. it('captures both streams', async () => {
  112. const result = await finish(spawnSubprocess(spec('echo out; echo err >&2')))
  113. expect(result.stdout.text).toBe('out\n')
  114. expect(result.stderr.text).toBe('err\n')
  115. })
  116. it('reports non-zero exit codes', async () => {
  117. const result = await finish(spawnSubprocess(spec('exit 42')))
  118. expect(result.exitCode).toBe(42)
  119. expect(result.signal).toBeNull()
  120. })
  121. it('passes the ambient TERM through untouched (terminal policy is the caller\'s)', async () => {
  122. const result = await finish(spawnSubprocess(spec('echo "${TERM:-unset}"', {
  123. env: { TERM: 'callers-choice' },
  124. })))
  125. expect(result.stdout.text).toBe('callers-choice\n')
  126. })
  127. it('runs in the requested cwd', async () => {
  128. const result = await finish(spawnSubprocess(spec('pwd', { cwd: '/tmp' })))
  129. expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
  130. })
  131. it('kills the process group with SIGTERM when the signal fires', async () => {
  132. // spawnProcess owns no timer: it kills on abort. The bash executor drives the timeout
  133. // by firing this signal via a deadline (see executor.spec.ts); here we
  134. // assert the kill itself lands as SIGTERM.
  135. const controller = new AbortController()
  136. const start = Date.now()
  137. const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal }))
  138. setTimeout(() => { controller.abort('deadline') }, 100)
  139. const result = await running.done
  140. expect(Date.now() - start).toBeLessThan(5_000)
  141. expect(result.signal).toBe('SIGTERM')
  142. expect(result.exitCode).toBeNull()
  143. })
  144. it('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => {
  145. const running = spawnSubprocess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
  146. await waitForStdout(running, 'ready\n')
  147. running.terminate()
  148. const result = await running.done
  149. expect(result.signal).toBe('SIGKILL')
  150. })
  151. it('kill() sends one signal Node-style, without escalation', async () => {
  152. const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60', { graceMs: 100 }))
  153. await waitForStdout(running, 'armed\n')
  154. running.kill() // trapped SIGTERM, no SIGKILL follow-up
  155. await new Promise(resolve => setTimeout(resolve, 400))
  156. expect(running.collected.stdout).toBeDefined()
  157. running.kill('SIGKILL') // explicit signal choice, still no timers
  158. const result = await running.done
  159. expect(result.signal).toBe('SIGKILL')
  160. })
  161. it('kills the whole process group (grandchildren die too)', async () => {
  162. // The subshell writes the sleep's pid then waits on it; killing the
  163. // group must take the sleep down with bash.
  164. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
  165. const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
  166. const grandchild = await waitForPidFile(pidFile)
  167. expect(grandchild).toBeGreaterThan(0)
  168. running.kill()
  169. const result = await running.done
  170. expect(result.signal).toBe('SIGTERM')
  171. await waitGone(grandchild)
  172. })
  173. it('aborts via AbortSignal mid-run', async () => {
  174. const controller = new AbortController()
  175. const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal }))
  176. setTimeout(() => { controller.abort('user cancelled') }, 50)
  177. const result = await running.done
  178. expect(result.signal).toBe('SIGTERM')
  179. })
  180. it('throws when the signal is already aborted before spawn', () => {
  181. const controller = new AbortController()
  182. controller.abort('too late')
  183. expect(() => spawnSubprocess(spec('echo hi', { signal: controller.signal })))
  184. .toThrow(/aborted before spawn: too late/)
  185. })
  186. it('rejects with a spawn error for a nonexistent cwd', async () => {
  187. await expect(spawnSubprocess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
  188. .rejects.toThrow(/ENOENT/)
  189. })
  190. it('terminate() is idempotent (second call does not restart escalation)', async () => {
  191. const running = spawnSubprocess(spec('sleep 60'))
  192. running.terminate()
  193. running.terminate()
  194. const result = await running.done
  195. expect(result.signal).toBe('SIGTERM')
  196. })
  197. it('bounds inherited-pipe draining after the shell exits', async () => {
  198. const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
  199. const started = Date.now()
  200. const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
  201. const descendant = await waitForPidFile(pidFile)
  202. try {
  203. const result = await finish(running)
  204. expect(Date.now() - started).toBeLessThan(1_000)
  205. expect(result.exitCode).toBe(0)
  206. expect(result.stdout.text).toBe('shell-done\n')
  207. } finally {
  208. process.kill(descendant, 'SIGKILL')
  209. await waitGone(descendant)
  210. }
  211. })
  212. })
  213. describe('stdin and extra env (set by in-process plugins)', () => {
  214. it('writes stdin to the command and closes it', async () => {
  215. const result = await finish(spawnSubprocess(spec('cat', { stdin: 'hello from stdin\n' })))
  216. expect(result.exitCode).toBe(0)
  217. expect(result.stdout.text).toBe('hello from stdin\n')
  218. })
  219. it('a command that reads stdin sees EOF when none is supplied', async () => {
  220. // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no
  221. // output (it does NOT block).
  222. const result = await finish(spawnSubprocess(spec('cat')))
  223. expect(result.exitCode).toBe(0)
  224. expect(result.stdout.text).toBe('')
  225. })
  226. it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
  227. // With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device).
  228. // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO.
  229. const none = await finish(spawnSubprocess(spec('test -c /dev/stdin && echo char || echo other')))
  230. expect(none.stdout.text).toBe('char\n')
  231. const piped = await finish(spawnSubprocess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })))
  232. expect(piped.stdout.text).toBe('socket\n')
  233. })
  234. it('merges ordinary extra env entries onto the scrubbed environment', async () => {
  235. const result = await finish(spawnSubprocess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
  236. env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' },
  237. })))
  238. expect(result.stdout.text).toBe('alpha/beta\n')
  239. })
  240. it('an explicit extra env entry overrides the credential scrub', async () => {
  241. // EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
  242. // entry is still honored — the scrub only drops AMBIENT process.env creds.
  243. const result = await finish(spawnSubprocess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', {
  244. env: { EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
  245. })))
  246. expect(result.stdout.text).toBe('explicit-wins\n')
  247. })
  248. it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
  249. // The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE.
  250. // The handler swallows that write error and `done` reports the child's real exit.
  251. const big = 'x'.repeat(1024 * 1024)
  252. const result = await finish(spawnSubprocess(spec('exit 7', { stdin: big })))
  253. expect(result.exitCode).toBe(7)
  254. })
  255. })
  256. describe('output truncation and spill', () => {
  257. it('applies stdout and stderr caps independently', async () => {
  258. const result = await finish(spawnSubprocess(
  259. spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', {
  260. stdoutMaxBytes: 500,
  261. stderrMaxBytes: 100,
  262. }),
  263. { spillDir },
  264. ))
  265. expect(result.stdout.truncated).toBe(false)
  266. expect(result.stdout.text).toBe('x'.repeat(500))
  267. expect(result.stderr.truncated).toBe(true)
  268. expect(result.stderr.text.length).toBeLessThanOrEqual(100)
  269. })
  270. it('keeps the tail and spills the full stream to disk', async () => {
  271. // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
  272. const result = await finish(spawnSubprocess(
  273. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
  274. { spillDir },
  275. ))
  276. expect(result.stdout.truncated).toBe(true)
  277. expect(result.stdout.text.length).toBeLessThanOrEqual(500)
  278. expect(result.stdout.text).toContain('line-0200')
  279. expect(result.stdout.text).not.toContain('line-0001')
  280. expect(result.stdout.spillPath).toBeDefined()
  281. const full = readFileSync(result.stdout.spillPath!, 'utf8')
  282. expect(full).toContain('line-0001')
  283. expect(full).toContain('line-0200')
  284. })
  285. it('does not truncate output exactly at the cap', async () => {
  286. const result = await finish(spawnSubprocess(
  287. spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
  288. { spillDir },
  289. ))
  290. expect(result.stdout.truncated).toBe(false)
  291. expect(result.stdout.text.length).toBe(500)
  292. expect(result.stdout.spillPath).toBeUndefined()
  293. })
  294. it('settles with the tail and no spill path when final spill close fails', async () => {
  295. failNextClose.value = true
  296. const result = await finish(spawnSubprocess(
  297. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
  298. { spillDir },
  299. ))
  300. expect(failNextClose.value).toBe(false)
  301. expect(result.exitCode).toBe(0)
  302. expect(result.stdout.truncated).toBe(true)
  303. expect(result.stdout.text).toContain('line-0200')
  304. expect(result.stdout.spillPath).toBeUndefined()
  305. })
  306. })
  307. describe('OutputCollector', () => {
  308. it('keeps the tail of a single oversized chunk', () => {
  309. const collector = new OutputCollector(10, 100, 'test', spillDir)
  310. collector.push(Buffer.from('0123456789abcdef'))
  311. const out = collector.finalize()
  312. expect(out.text).toBe('6789abcdef')
  313. expect(out.truncated).toBe(true)
  314. expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef')
  315. })
  316. it('retains a byte-exact tail across uneven chunk boundaries', () => {
  317. // The old whole-chunk drop could under-retain; a diagnostic tail must be
  318. // exactly the LAST maxBytes regardless of chunking.
  319. const collector = new OutputCollector(10, undefined, 'exact-tail', spillDir)
  320. collector.push(Buffer.from('aaaa'))
  321. collector.push(Buffer.from('bbbbbb'))
  322. collector.push(Buffer.from('cc'))
  323. const out = collector.finalize()
  324. expect(out.text).toBe('aabbbbbbcc')
  325. expect(Buffer.byteLength(out.text)).toBe(10)
  326. expect(out.truncated).toBe(true)
  327. })
  328. it('readFrom returns increments and flags lossy reads', () => {
  329. const collector = new OutputCollector(10, 100, 'test', spillDir)
  330. collector.push(Buffer.from('aaaaa'))
  331. const first = collector.readFrom(0)
  332. expect(first.text).toBe('aaaaa')
  333. expect(first.lossy).toBe(false)
  334. expect(first.nextOffset).toBe(5)
  335. collector.push(Buffer.from('bbbbb'))
  336. const second = collector.readFrom(first.nextOffset)
  337. expect(second.text).toBe('bbbbb')
  338. expect(second.lossy).toBe(false)
  339. // Push enough to slide the window past the last offset.
  340. collector.push(Buffer.from('c'.repeat(20)))
  341. const third = collector.readFrom(second.nextOffset)
  342. expect(third.lossy).toBe(true)
  343. expect(third.text).toBe('c'.repeat(10))
  344. expect(third.spillPath).toBeDefined()
  345. })
  346. it('contains close failures and drops the spill path', () => {
  347. const collector = new OutputCollector(4, 100, 'closefail', spillDir)
  348. collector.push(Buffer.from('aaaa'))
  349. collector.push(Buffer.from('bbbb'))
  350. expect(collector.readFrom(0).spillPath).toBeDefined()
  351. failNextClose.value = true
  352. let out: ReturnType<typeof collector.finalize>
  353. expect(() => { out = collector.finalize() }).not.toThrow()
  354. expect(failNextClose.value).toBe(false)
  355. expect(out!.text).toBe('bbbb')
  356. expect(out!.truncated).toBe(true)
  357. expect(out!.spillPath).toBeUndefined()
  358. })
  359. it('discards a spill that exceeds its configured cap', () => {
  360. const collector = new OutputCollector(4, 8, 'bounded', spillDir)
  361. collector.push(Buffer.from('aaaa'))
  362. collector.push(Buffer.from('bbbb'))
  363. const spillPath = collector.readFrom(0).spillPath!
  364. expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb')
  365. collector.push(Buffer.from('c'))
  366. collector.push(Buffer.from('dddd'))
  367. const out = collector.finalize()
  368. expect(out.text).toBe('dddd')
  369. expect(out.truncated).toBe(true)
  370. expect(out.spillPath).toBeUndefined()
  371. expect(() => readFileSync(spillPath)).toThrow()
  372. })
  373. it('does not create a spill when the first overflowing chunk exceeds the cap', () => {
  374. const collector = new OutputCollector(4, 4, 'no-spill', spillDir)
  375. collector.push(Buffer.from('abcdefgh'))
  376. const out = collector.finalize()
  377. expect(out.text).toBe('efgh')
  378. expect(out.truncated).toBe(true)
  379. expect(out.spillPath).toBeUndefined()
  380. })
  381. it('contains cleanup failures while disabling an oversize spill', () => {
  382. const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir)
  383. collector.push(Buffer.from('aaaa'))
  384. collector.push(Buffer.from('bbbb'))
  385. const spillPath = collector.readFrom(0).spillPath!
  386. failNextClose.value = true
  387. failNextUnlink.value = true
  388. expect(() => { collector.push(Buffer.from('c')) }).not.toThrow()
  389. expect(failNextClose.value).toBe(false)
  390. expect(failNextUnlink.value).toBe(false)
  391. expect(collector.finalize().spillPath).toBeUndefined()
  392. unlinkSync(spillPath)
  393. })
  394. })
  395. describe('killGroup', () => {
  396. it('ignores non-positive pids', () => {
  397. expect(() => { killGroup(-1, 'SIGTERM') }).not.toThrow()
  398. expect(() => { killGroup(0, 'SIGTERM') }).not.toThrow()
  399. })
  400. it('swallows ESRCH for vanished groups', async () => {
  401. const running = spawnSubprocess(spec('true'))
  402. await running.done
  403. expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
  404. })
  405. it('handle.kill() after the tree died delivers no termination signal', async () => {
  406. // Cleanup code commonly kills handles in a finally; once the tree is gone
  407. // the pid may be reused, so a late kill must deliver nothing (the
  408. // liveness PROBE — signal 0 — is the only process.kill allowed).
  409. const running = spawnSubprocess(spec('true'))
  410. await running.done
  411. await running.waitForExit()
  412. const spy = vi.spyOn(process, 'kill')
  413. try {
  414. running.kill()
  415. const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0)
  416. expect(delivered).toEqual([])
  417. } finally {
  418. spy.mockRestore()
  419. }
  420. })
  421. })
  422. describe('stdio dispositions', () => {
  423. it("'pipe' exposes raw streams for caller-owned protocol decoding", async () => {
  424. const running = spawnSubprocess({
  425. ...spec('cat'),
  426. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 1000 } },
  427. })
  428. expect(running.stdin).toBeDefined()
  429. expect(running.stdout).toBeDefined()
  430. expect(running.stderr).toBeUndefined()
  431. expect(running.collected.stdout).toBeUndefined()
  432. expect(running.collected.stderr).toBeDefined()
  433. const echoed = new Promise<string>((resolve) => {
  434. let text = ''
  435. running.stdout!.on('data', (chunk: Buffer) => { text += chunk.toString('utf8') })
  436. running.stdout!.on('end', () => { resolve(text) })
  437. })
  438. running.stdin!.end('through the pipe\n')
  439. const outcome = await running.done
  440. expect(outcome.exitCode).toBe(0)
  441. expect(await echoed).toBe('through the pipe\n')
  442. })
  443. it('a collect mode without spill keeps only the in-memory tail (no file)', async () => {
  444. const running = spawnSubprocess({
  445. ...spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done'),
  446. stdio: { stdin: 'ignore', stdout: { maxBytes: 100 }, stderr: { maxBytes: 100 } },
  447. }, { spillDir })
  448. await running.done
  449. const read = running.collected.stdout!.readFrom(0)
  450. expect(read.lossy).toBe(true)
  451. expect(read.text).toContain('line-0200')
  452. expect(read.spillPath).toBeUndefined()
  453. })
  454. })
  455. describe('dispose ladder', () => {
  456. it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => {
  457. const running = spawnSubprocess({
  458. ...spec('read -r line; exit 0'),
  459. stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
  460. })
  461. await running.dispose({ eofGraceMs: 5_000, graceMs: 200 })
  462. const outcome = await running.done
  463. expect(outcome.exitCode).toBe(0)
  464. expect(outcome.signal).toBeNull()
  465. })
  466. it('tier 2: an EOF-deaf child dies by SIGTERM', async () => {
  467. const running = spawnSubprocess({
  468. ...spec('sleep 60'),
  469. stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
  470. })
  471. await running.dispose({ eofGraceMs: 100, graceMs: 5_000 })
  472. const outcome = await running.done
  473. expect(outcome.signal).toBe('SIGTERM')
  474. })
  475. it('tier 3: a TERM-trapping child dies by SIGKILL, and dispose() is idempotent', async () => {
  476. const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60'))
  477. await waitForStdout(running, 'armed\n')
  478. const first = running.dispose({ eofGraceMs: 50, graceMs: 200 })
  479. const second = running.dispose({ eofGraceMs: 50, graceMs: 200 })
  480. expect(second).toBe(first)
  481. await first
  482. const outcome = await running.done
  483. expect(outcome.signal).toBe('SIGKILL')
  484. })
  485. })
  486. describe('windows tree semantics (injected platform)', () => {
  487. it('kill and terminate route through taskkill by root pid', async () => {
  488. const killed: number[] = []
  489. const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), {
  490. spillDir,
  491. platform: 'win32',
  492. taskkill: (pid) => {
  493. killed.push(pid)
  494. // Simulate the forced tree termination taskkill performs.
  495. try {
  496. process.kill(pid, 'SIGKILL')
  497. } catch {
  498. // Already gone — matches taskkill's tolerated not-found status.
  499. }
  500. },
  501. })
  502. running.terminate()
  503. const outcome = await running.done
  504. expect(killed).toContain(running.pid)
  505. expect(outcome.signal).toBe('SIGKILL')
  506. })
  507. it('waitForExit falls back to direct-child liveness where groups do not exist', async () => {
  508. const running = spawnSubprocess(spec('true'), { spillDir, platform: 'win32', taskkill: () => {} })
  509. await running.done
  510. await expect(running.waitForExit()).resolves.toBe(true)
  511. })
  512. })
  513. describe('waitForExit', () => {
  514. it('waits for the whole detached tree, not just the shell', async () => {
  515. const pidFile = join(spillDir, `tree-wait-${Date.now()}.pid`)
  516. const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
  517. const grandchild = await waitForPidFile(pidFile)
  518. running.terminate()
  519. await running.done
  520. await expect(running.waitForExit()).resolves.toBe(true)
  521. await expect(waitGone(grandchild, 100)).resolves.toBeUndefined()
  522. })
  523. it('an aborted wait reports false while the tree lives', async () => {
  524. const running = spawnSubprocess(spec('sleep 60'))
  525. const controller = new AbortController()
  526. controller.abort()
  527. await expect(running.waitForExit(controller.signal)).resolves.toBe(false)
  528. running.terminate()
  529. await running.done
  530. })
  531. })
  532. describe('tree-survivor escalation (terminate/dispose reach helpers the leader left behind)', () => {
  533. it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => {
  534. // The leader spawns a TERM-trapping helper with all stdio detached from
  535. // the collected pipes, then exits: the helper holds the GROUP alive while
  536. // the direct child settles. The escalation must still reach it.
  537. const pidFile = join(spillDir, `survivor-${Date.now()}.pid`)
  538. const running = spawnSubprocess(spec(
  539. `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; wait_placeholder=; exit 0`,
  540. { graceMs: 300 },
  541. ))
  542. const helper = await waitForPidFile(pidFile)
  543. await running.done // direct child settled; helper survives in the group
  544. expect(() => process.kill(helper, 0)).not.toThrow()
  545. running.terminate() // SIGTERM (trapped) → grace → SIGKILL the group
  546. await expect(running.waitForExit()).resolves.toBe(true)
  547. await waitGone(helper)
  548. })
  549. it('dispose() holds each tier on whole-tree exit, not direct-child settlement', async () => {
  550. const pidFile = join(spillDir, `survivor-dispose-${Date.now()}.pid`)
  551. const running = spawnSubprocess(spec(
  552. `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`,
  553. { graceMs: 200 },
  554. ))
  555. const helper = await waitForPidFile(pidFile)
  556. await running.done
  557. expect(() => process.kill(helper, 0)).not.toThrow()
  558. await running.dispose({ eofGraceMs: 100, graceMs: 300 })
  559. // The ladder only returns once the WHOLE tree is gone.
  560. expect(() => process.kill(helper, 0)).toThrow()
  561. })
  562. it('service teardown awaits tree survivors, not just handle settlement', async () => {
  563. const { Context } = await import('cordis')
  564. const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local')
  565. const ctx = new Context()
  566. const fiber = await ctx.plugin(LocalSubprocessService)
  567. ;(ctx.subprocess as InstanceType<typeof LocalSubprocessService>).internals = { spillDir }
  568. const pidFile = join(spillDir, `survivor-svc-${Date.now()}.pid`)
  569. const running = ctx.subprocess.spawn(spec(
  570. `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`,
  571. { graceMs: 200 },
  572. ))
  573. const helper = await waitForPidFile(pidFile)
  574. await running.done
  575. await fiber.dispose()
  576. // Teardown itself waited for the survivor to die.
  577. expect(() => process.kill(helper, 0)).toThrow()
  578. })
  579. })
  580. describe('coverage seams', () => {
  581. it('taskkillProcessTree ignores non-positive pids and contains a missing binary', () => {
  582. expect(() => { taskkillProcessTree(-1) }).not.toThrow()
  583. expect(() => { taskkillProcessTree(0) }).not.toThrow()
  584. // On POSIX there is no taskkill; spawnSync reports the failure in its
  585. // result and the function stays silent — the same containment Windows
  586. // relies on for an already-absent tree.
  587. expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow()
  588. })
  589. it('dispose on a spawn-failed handle observes the rejection and returns', async () => {
  590. const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' }))
  591. const disposal = running.dispose({ eofGraceMs: 1_000, graceMs: 1_000 })
  592. await expect(running.done).rejects.toThrow()
  593. await expect(disposal).resolves.toBeUndefined()
  594. })
  595. it("an 'inherit' stdout with collected stderr wires only the requested collector", async () => {
  596. const running = spawnSubprocess({
  597. ...spec('echo to-parent; echo err >&2'),
  598. stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 1000 } },
  599. })
  600. const outcome = await running.done
  601. expect(outcome.exitCode).toBe(0)
  602. expect(running.stdout).toBeUndefined()
  603. expect(running.collected.stdout).toBeUndefined()
  604. expect(running.collected.stderr!.readFrom(0).text).toBe('err\n')
  605. })
  606. it('terminate() after the tree died delivers no termination signal', async () => {
  607. const running = spawnSubprocess(spec('true'))
  608. await running.done
  609. await running.waitForExit()
  610. const spy = vi.spyOn(process, 'kill')
  611. try {
  612. running.terminate()
  613. const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0)
  614. expect(delivered).toEqual([])
  615. } finally {
  616. spy.mockRestore()
  617. }
  618. })
  619. it('waitForExit on a failed spawn reports exited immediately', async () => {
  620. const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-spawn-test' }))
  621. await expect(running.done).rejects.toThrow()
  622. await expect(running.waitForExit()).resolves.toBe(true)
  623. })
  624. it('dispose() on an already-exited tree returns without delivering a signal', async () => {
  625. const running = spawnSubprocess(spec('true'))
  626. await running.done
  627. await running.waitForExit()
  628. const spy = vi.spyOn(process, 'kill')
  629. try {
  630. await running.dispose({ eofGraceMs: 50, graceMs: 50 })
  631. const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0)
  632. expect(delivered).toEqual([])
  633. } finally {
  634. spy.mockRestore()
  635. }
  636. })
  637. it('a batch-stdin handle exposes no stdin and dispose skips the EOF tier', async () => {
  638. const running = spawnSubprocess(spec('cat', { stdin: 'batch\n' }))
  639. expect(running.stdin).toBeUndefined()
  640. await running.done
  641. await running.dispose({ eofGraceMs: 50, graceMs: 50 })
  642. expect(running.collected.stdout!.readFrom(0).text).toBe('batch\n')
  643. })
  644. })
  645. describe('coverage seams 2', () => {
  646. it('win32 treeAlive reports alive for a live child and gone after taskkill', async () => {
  647. let killedPid = 0
  648. const running = spawnSubprocess(spec('sleep 60'), {
  649. spillDir,
  650. platform: 'win32',
  651. taskkill: (pid) => {
  652. killedPid = pid
  653. try {
  654. process.kill(pid, 'SIGKILL')
  655. } catch {
  656. // Already gone.
  657. }
  658. },
  659. })
  660. const aborted = new AbortController()
  661. aborted.abort()
  662. await expect(running.waitForExit(aborted.signal)).resolves.toBe(false) // alive branch
  663. running.terminate()
  664. await running.done
  665. expect(killedPid).toBe(running.pid)
  666. await expect(running.waitForExit()).resolves.toBe(true)
  667. })
  668. it('the win32 dispose ladder skips the POSIX SIGTERM tier and force-terminates', async () => {
  669. const kills: number[] = []
  670. const running = spawnSubprocess({
  671. ...spec('sleep 60'),
  672. stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
  673. }, {
  674. spillDir,
  675. platform: 'win32',
  676. taskkill: (pid) => {
  677. kills.push(pid)
  678. try {
  679. process.kill(pid, 'SIGKILL')
  680. } catch {
  681. // Already gone.
  682. }
  683. },
  684. })
  685. await running.dispose({ eofGraceMs: 50, graceMs: 5_000 })
  686. // Exactly one forced tree termination: no POSIX SIGTERM tier ran.
  687. expect(kills).toEqual([running.pid])
  688. })
  689. it('dispose throws when even SIGKILL produces no exit within the grace', async () => {
  690. // An inert taskkill simulates a tree that never reports exit.
  691. const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} })
  692. await expect(running.dispose({ eofGraceMs: 20, graceMs: 40 }))
  693. .rejects.toThrow(/did not exit within 40ms after forced termination/)
  694. // Real cleanup: the injected platform spawned without detachment, so the
  695. // child is a plain (group-less) POSIX process — kill it directly.
  696. process.kill(running.pid, 'SIGKILL')
  697. await running.done
  698. })
  699. it("stderr: 'pipe' exposes the raw stream", async () => {
  700. const running = spawnSubprocess({
  701. ...spec('echo err >&2'),
  702. stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: 'pipe' },
  703. })
  704. expect(running.stderr).toBeDefined()
  705. const text = new Promise<string>((resolve) => {
  706. let out = ''
  707. running.stderr!.on('data', (chunk: Buffer) => { out += chunk.toString('utf8') })
  708. running.stderr!.on('end', () => { resolve(out) })
  709. })
  710. await running.done
  711. expect(await text).toBe('err\n')
  712. })
  713. })
  714. describe('argv validation', () => {
  715. it('rejects an empty argv before spawning', () => {
  716. expect(() => spawnSubprocess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/)
  717. })
  718. it('rejects an empty program name before spawning', () => {
  719. expect(() => spawnSubprocess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/)
  720. })
  721. it('spawns argv verbatim without shell interpretation', async () => {
  722. const result = await finish(spawnSubprocess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] }))
  723. expect(result.stdout.text).toBe('$HOME')
  724. })
  725. })
  726. describe('abort edge cases', () => {
  727. it('reports a fallback reason for reason-less pre-aborted signals', () => {
  728. // Real AbortControllers always set a DOMException reason; signal-like
  729. // objects from other libraries may not — the fallback covers them.
  730. const bare = {
  731. aborted: true,
  732. reason: undefined,
  733. addEventListener() {},
  734. removeEventListener() {},
  735. } as unknown as AbortSignal
  736. expect(() => spawnSubprocess(spec('echo hi', { signal: bare })))
  737. .toThrow(/aborted before spawn: aborted/)
  738. })
  739. it('reports the terminating signal of an externally self-killed command', async () => {
  740. // spawnProcess reports the raw signal; whether it counts as timeout/cancel is the
  741. // executor's classification (a self-kill is neither) — see executor.spec.ts.
  742. const result = await finish(spawnSubprocess(spec('kill -TERM $$')))
  743. expect(result.signal).toBe('SIGTERM')
  744. })
  745. })
  746. describe('environment and spill-file hardening', () => {
  747. it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => {
  748. process.env.DSH_TEST_API_KEY = 'super-secret'
  749. process.env.DSH_TEST_TOKEN = 'also-secret'
  750. process.env.DSH_TEST_PLAIN = 'visible'
  751. try {
  752. const result = await finish(spawnSubprocess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')))
  753. expect(result.stdout.text.trim()).toBe('[absent|absent|absent]')
  754. } finally {
  755. delete process.env.DSH_TEST_API_KEY
  756. delete process.env.DSH_TEST_TOKEN
  757. delete process.env.DSH_TEST_PLAIN
  758. }
  759. })
  760. it('injects only the current trusted DSH environment after scrubbing ambient values', async () => {
  761. process.env.DSH_STALE = 'old-value'
  762. try {
  763. const result = await finish(spawnSubprocess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
  764. dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' },
  765. })))
  766. expect(result.stdout.text.trim()).toBe('[absent|1|current-session]')
  767. } finally {
  768. delete process.env.DSH_STALE
  769. }
  770. })
  771. it('rejects DSH variables on the ordinary env channel', () => {
  772. expect(() => spawnSubprocess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
  773. .toThrow(/DSH_WRONG_CHANNEL.*dshEnv/)
  774. })
  775. it('rejects ordinary variables on the managed env channel', () => {
  776. const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment
  777. expect(() => spawnSubprocess(spec('true', { dshEnv: invalid })))
  778. .toThrow(/managed child env.*PATH.*use env/)
  779. })
  780. it('creates spill files with owner-only permissions and random names', async () => {
  781. const result = await finish(spawnSubprocess(
  782. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
  783. { spillDir },
  784. ))
  785. const path = result.stdout.spillPath!
  786. expect(path).toMatch(/dsh-subprocess-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
  787. const mode = statSync(path).mode & 0o777
  788. expect(mode).toBe(0o600)
  789. })
  790. it('defaults spills into a private per-process directory', async () => {
  791. const result = await finish(spawnSubprocess(
  792. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
  793. ))
  794. const dir = dirname(result.stdout.spillPath!)
  795. expect(dir).toMatch(/dsh-subprocess-/)
  796. const mode = statSync(dir).mode & 0o777
  797. expect(mode).toBe(0o700)
  798. })
  799. it('killGroup never throws, even for EPERM-style failures', () => {
  800. const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
  801. throw Object.assign(new Error('EPERM'), { code: 'EPERM' })
  802. })
  803. try {
  804. expect(() => { killGroup(12345, 'SIGTERM') }).not.toThrow()
  805. } finally {
  806. spy.mockRestore()
  807. }
  808. })
  809. it('honors AbortSignal on background-style runs (no timeout)', async () => {
  810. const controller = new AbortController()
  811. const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal }))
  812. setTimeout(() => { controller.abort() }, 50)
  813. const result = await running.done
  814. expect(result.signal).toBe('SIGTERM')
  815. })
  816. })