spawn.spec.ts 55 KB

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