spawn.spec.ts 55 KB

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