spawn.spec.ts 45 KB

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