spawn.spec.ts 47 KB

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