spawn.spec.ts 32 KB

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