spawn.spec.ts 47 KB

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