spawn.spec.ts 37 KB

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