spawn.spec.ts 47 KB

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