spawn.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. /**
  2. * Process plumbing for the local subprocess service: detached process-tree
  3. * spawn with per-stream stdio dispositions, tail-keep collection with spill
  4. * files, tree-scoped signalling (POSIX groups; Windows taskkill), and the
  5. * SIGTERM→SIGKILL escalation. This layer reacts to an abort signal; callers
  6. * own deadlines, teardown ladders, and cause classification.
  7. * @module dsh-subprocess-local/spawn
  8. */
  9. import { type ChildProcess, type SpawnOptions, spawn, spawnSync } from 'node:child_process'
  10. import type { Readable } from 'node:stream'
  11. import { randomBytes } from 'node:crypto'
  12. import { closeSync, mkdtempSync, openSync, rmdirSync, unlinkSync, writeSync } from 'node:fs'
  13. import { tmpdir } from 'node:os'
  14. import { join } from 'node:path'
  15. import { setTimeout as sleepMs } from 'node:timers/promises'
  16. import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
  17. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  18. import type {
  19. CollectedOutput,
  20. SubprocessCollect,
  21. SubprocessHandle,
  22. SubprocessOutcome,
  23. SubprocessOutputMode,
  24. SubprocessSpawnSpec,
  25. } from '@deepseek-ai/dsh-subprocess'
  26. import { linuxProcessGroupHasLiveMembers } from './process-inspector.ts'
  27. type SpawnProcess = (
  28. program: string,
  29. args: readonly string[],
  30. options: SpawnOptions,
  31. ) => ChildProcess
  32. /**
  33. * Build a child environment: explicit caller entries override the scrubbed
  34. * parent base using the target platform's environment-key semantics. A string
  35. * deliberately restores or overrides an entry; an explicit `undefined`
  36. * tombstone removes an ordinary ambient entry.
  37. * @param extra - explicit caller entries and tombstones, merged after the scrub.
  38. * @returns the environment to hand to `spawn` for the child process.
  39. */
  40. export function childEnv(extra?: Readonly<NodeJS.ProcessEnv>): NodeJS.ProcessEnv {
  41. const env = scrubbedParentEnv()
  42. if (process.platform !== 'win32') return { ...env, ...extra }
  43. let entries: [string, string | undefined][] = Object.entries(env)
  44. for (const [key, value] of Object.entries(extra ?? {})) {
  45. const normalized = key.toUpperCase()
  46. entries = entries.filter(([inherited]) => inherited.toUpperCase() !== normalized)
  47. entries.push([key, value])
  48. }
  49. return Object.fromEntries(entries)
  50. }
  51. /** Injectable process, spill, and platform operations. */
  52. export interface SpawnInternals {
  53. /** Process spawner (defaults to `node:child_process` `spawn`). */
  54. spawn?: SpawnProcess
  55. /** Directory for spill files (defaults to the OS temp dir). */
  56. spillDir?: string
  57. /** Windows tree-termination runner (defaults to `taskkill /PID <pid> /T /F`). */
  58. taskkill?: (pid: number) => void
  59. /** Host platform override for signalling decisions. */
  60. platform?: NodeJS.Platform
  61. /** Linux process-group member probe (defaults to `/proc` inspection). */
  62. linuxProcessGroupHasLiveMembers?: (processGroupId: number) => boolean | undefined
  63. }
  64. /**
  65. * Local-only synchronous final termination used by the owning service during
  66. * host exit and as the last fallback after failed normal disposal. It is
  67. * intentionally absent from the public subprocess seam.
  68. */
  69. export interface LocalSubprocessHandle extends SubprocessHandle {
  70. /** Force-terminate the current tree synchronously without starting timers or waits. */
  71. terminateForHostExit(): void
  72. }
  73. /**
  74. * Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an
  75. * awaited teardown must keep the event loop alive until the tree really
  76. * exits, or the parent can exit while claiming quiescence and orphan the
  77. * survivors it promised to reap.
  78. */
  79. function sleepTick(): Promise<void> {
  80. return sleepMs(15)
  81. }
  82. let spillCounter = 0
  83. let defaultSpillDir: string | undefined
  84. /**
  85. * The default spill location: a private (0700) per-process directory under
  86. * the OS tmpdir, created lazily. Predictable world-readable paths would let
  87. * other local users read command output or pre-create symlinks. At a
  88. * JavaScript-observable process exit the directory is removed only when it
  89. * holds no completed spill file (spill files are retained as full-output
  90. * recovery artifacts until an external cleanup).
  91. */
  92. function privateSpillDir(): string {
  93. defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-'))
  94. return defaultSpillDir
  95. }
  96. // The per-process spill directory is removed at process exit when it holds no
  97. // completed spill file: a directory that never spilled is empty and is safe to
  98. // remove, while a directory holding completed spill files keeps them (their
  99. // content is retained until an external cleanup). A SIGKILLed process cannot
  100. // run this at all; its residue is left to OS temp hygiene.
  101. /* v8 ignore next 4 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */
  102. process.once('exit', () => {
  103. if (defaultSpillDir === undefined) return
  104. try { rmdirSync(defaultSpillDir) } catch { /* best-effort: ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code. */ }
  105. })
  106. /**
  107. * Collects one stream with a bounded in-memory tail. With a spill cap, on
  108. * first overflow a spill file is created and every chunk (including those
  109. * already collected) is appended there while the full stream remains within
  110. * the cap; without one, only the in-memory tail is ever retained (the
  111. * diagnostic-tail shape — a language server's stderr).
  112. *
  113. * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
  114. * end of command output; the spill file covers the head.
  115. */
  116. export class OutputCollector {
  117. private chunks: Buffer[] = []
  118. private bytes = 0
  119. private dropped = false
  120. private spillFd: number | undefined
  121. private spillFile: string | undefined
  122. private spillDisabled: boolean
  123. /** Total bytes ever pushed (not just retained). */
  124. private total = 0
  125. constructor(
  126. private readonly maxBytes: number,
  127. private readonly maxSpillBytes: number | undefined,
  128. private readonly label: string,
  129. private readonly spillDir: string,
  130. ) {
  131. this.spillDisabled = maxSpillBytes === undefined
  132. }
  133. /**
  134. * Ingest one stream chunk, counting it toward the whole-stream total. On
  135. * first overflow of the in-memory cap a spill file is opened (when spilling
  136. * is enabled) and every chunk (already-collected ones included) is appended
  137. * there from then on; the in-memory tail then drops whole chunks from its
  138. * head (or the head of a single over-cap chunk) until it fits the cap again.
  139. * @param chunk - the raw bytes from one stream 'data' event.
  140. */
  141. push(chunk: Buffer): void {
  142. this.total += chunk.length
  143. const overflows = this.bytes + chunk.length > this.maxBytes
  144. if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk)
  145. this.chunks.push(chunk)
  146. this.bytes += chunk.length
  147. while (this.bytes > this.maxBytes) {
  148. const head = this.chunks[0] as Buffer
  149. const excess = this.bytes - this.maxBytes
  150. if (head.length <= excess) {
  151. // Drop the whole head chunk (length ≥ 1 is guaranteed while over cap).
  152. this.chunks.shift()
  153. this.bytes -= head.length
  154. } else {
  155. // Trim the head so the retained window is byte-exact at the cap — a
  156. // diagnostic tail (an LSP server's stderr) must hold the LAST
  157. // maxBytes regardless of how the stream was chunked.
  158. this.chunks[0] = head.subarray(excess)
  159. this.bytes -= excess
  160. }
  161. this.dropped = true
  162. }
  163. }
  164. /** Open the spill file lazily and append `chunk` (and any prior chunks once). */
  165. private spillAll(chunk: Buffer): void {
  166. if (this.maxSpillBytes !== undefined && this.total > this.maxSpillBytes) {
  167. this.discardSpill()
  168. return
  169. }
  170. if (this.spillFd === undefined) {
  171. // Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
  172. // existing path, symlink or not) + owner-only mode: defeats spill-path
  173. // prediction and symlink planting in shared tmp dirs.
  174. this.spillFile = join(
  175. this.spillDir,
  176. `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
  177. )
  178. this.spillFd = openSync(this.spillFile, 'wx', 0o600)
  179. for (const prior of this.chunks) writeSync(this.spillFd, prior)
  180. }
  181. writeSync(this.spillFd, chunk)
  182. }
  183. /** Stop spilling and remove the file once it can no longer hold the complete stream. */
  184. private discardSpill(): void {
  185. const fd = this.spillFd
  186. const file = this.spillFile
  187. this.spillFd = undefined
  188. this.spillFile = undefined
  189. this.spillDisabled = true
  190. if (fd !== undefined) {
  191. try {
  192. closeSync(fd)
  193. } catch {
  194. // Retain the descriptor so finalize can retry the failed close.
  195. this.spillFd = fd
  196. }
  197. }
  198. if (file !== undefined) {
  199. try {
  200. unlinkSync(file)
  201. } catch {
  202. // A failed unlink leaves at most maxSpillBytes behind, never an unbounded file.
  203. }
  204. }
  205. }
  206. /**
  207. * Incremental read in whole-stream byte coordinates: returns everything
  208. * pushed since `fromByte`. When `fromByte` has already slid out of the
  209. * in-memory tail window, the read is `lossy` — it returns the whole
  210. * retained tail and the gap is only recoverable from the spill file.
  211. * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
  212. * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
  213. */
  214. readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } {
  215. const windowStart = this.total - this.bytes
  216. const buffer = Buffer.concat(this.chunks)
  217. const lossy = fromByte < windowStart
  218. const slice = lossy ? buffer : buffer.subarray(fromByte - windowStart)
  219. return {
  220. text: slice.toString('utf8'),
  221. nextOffset: this.total,
  222. lossy,
  223. ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
  224. }
  225. }
  226. /**
  227. * Close the spill file once the stream has ended. A failed close (delayed
  228. * writeback fault) stops advertising the spill path — the file may be
  229. * missing its tail — while every in-memory read keeps working. Idempotent;
  230. * the spawn path seals both collectors at settlement so reads after exit
  231. * never point at a still-open file.
  232. */
  233. seal(): void {
  234. if (this.spillFd === undefined) return
  235. try {
  236. closeSync(this.spillFd)
  237. } catch {
  238. // A delayed writeback failure makes the spill unreliable; keep the
  239. // in-memory result but stop advertising that file.
  240. this.spillFile = undefined
  241. }
  242. this.spillFd = undefined
  243. }
  244. /**
  245. * Seal the spill file and return the final output.
  246. * @returns the final collected output: tail text, truncation flag, and the spill path when intact.
  247. */
  248. finalize(): CollectedOutput {
  249. this.seal()
  250. return {
  251. text: Buffer.concat(this.chunks).toString('utf8'),
  252. truncated: this.dropped,
  253. ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
  254. }
  255. }
  256. }
  257. /**
  258. * Send `sig` to a detached POSIX process group. Never throws: delivery races
  259. * process exit and may run in a timer callback, so failures are contained and
  260. * a non-positive pid is a no-op.
  261. * @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op.
  262. * @param sig - the signal to deliver to the whole group.
  263. */
  264. export function killGroup(pid: number, sig: NodeJS.Signals): void {
  265. if (pid <= 0) return
  266. try {
  267. process.kill(-pid, sig)
  268. } catch {
  269. // Swallow: see contract above.
  270. }
  271. }
  272. /**
  273. * Terminate one Windows process tree with `taskkill /T /F`. Contained like
  274. * POSIX group signalling — delivery races tree exit, so an absent tree, a
  275. * nonzero status, or a missing taskkill binary must not break idempotent
  276. * teardown.
  277. * @param pid - root process id; non-positive is a no-op.
  278. */
  279. export function taskkillProcessTree(pid: number): void {
  280. if (pid <= 0) return
  281. // Outcome deliberately unchecked: an already-absent tree (status 128), exit
  282. // races, and a missing taskkill binary (spawnSync reports, never throws) are
  283. // as tolerable here as ESRCH is for a POSIX group signal.
  284. spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
  285. stdio: 'ignore',
  286. windowsHide: true,
  287. })
  288. }
  289. /**
  290. * Signal a detached process tree with platform-correct semantics: POSIX
  291. * signals the negative process-group id and falls back to the direct child
  292. * when the group is gone; Windows terminates the tree via taskkill (any
  293. * signal value force-terminates — Node maps signals to TerminateProcess).
  294. */
  295. function signalTree(
  296. platform: NodeJS.Platform,
  297. pid: number,
  298. sig: NodeJS.Signals,
  299. child: ChildProcess,
  300. taskkill: (pid: number) => void,
  301. ): void {
  302. if (platform === 'win32') {
  303. taskkill(pid)
  304. return
  305. }
  306. /* v8 ignore next -- kill/terminate gate on treeAlive(), which is false for pid -1; this guard protects direct callers only. */
  307. if (pid <= 0) return
  308. try {
  309. process.kill(-pid, sig)
  310. } catch {
  311. /* v8 ignore start -- the fallback needs a live child whose group signal fails
  312. (EPERM-style), which POSIX CI cannot stage; the swallow keeps teardown idempotent. */
  313. try {
  314. child.kill(sig)
  315. } catch {
  316. // The direct child already exited; teardown remains idempotent.
  317. }
  318. /* v8 ignore stop */
  319. }
  320. }
  321. /**
  322. * Spawn one isolated detached process tree with the spec's per-stream stdio
  323. * dispositions. Runtime exits resolve `done` as {@link SubprocessOutcome};
  324. * only spawn failures reject.
  325. * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
  326. * @param internals - test-only spill-directory, platform, and taskkill overrides.
  327. * @returns live subprocess handle.
  328. * @throws when `graceMs` cannot be represented by one Node timer.
  329. */
  330. export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): LocalSubprocessHandle {
  331. if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) {
  332. throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
  333. }
  334. const spillDir = internals.spillDir ?? privateSpillDir()
  335. const platform = internals.platform ?? process.platform
  336. const spawnProcess = internals.spawn ?? spawn
  337. const taskkill = internals.taskkill ?? taskkillProcessTree
  338. const linuxGroupHasLiveMembers = internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers
  339. if (spec.signal?.aborted) {
  340. throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
  341. }
  342. const [program, ...args] = spec.argv
  343. if (program === undefined || program.length === 0) {
  344. throw new Error('invalid argv: expected a non-empty program name at argv[0]')
  345. }
  346. const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect =>
  347. mode !== 'pipe' && mode !== 'inherit'
  348. const outMode = spec.stdio.stdout
  349. const errMode = spec.stdio.stderr
  350. const stdinMode = spec.stdio.stdin
  351. const env = childEnv(spec.env)
  352. const child = spawnProcess(program, args, {
  353. cwd: spec.cwd,
  354. env,
  355. stdio: [
  356. stdinMode === 'ignore' ? 'ignore' : 'pipe',
  357. outMode === 'inherit' ? 'inherit' : 'pipe',
  358. errMode === 'inherit' ? 'inherit' : 'pipe',
  359. ],
  360. // `detached` gives teardown a tree root on POSIX (its own process group);
  361. // Windows terminates by root pid through taskkill /T instead.
  362. detached: platform !== 'win32',
  363. windowsHide: platform === 'win32',
  364. })
  365. const collectStream = (mode: SubprocessOutputMode, stream: Readable | null, label: string): OutputCollector | undefined => {
  366. if (!isCollect(mode) || stream === null) return undefined
  367. const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, label, spillDir)
  368. stream.on('data', (chunk: Buffer) => { collector.push(chunk) })
  369. return collector
  370. }
  371. const stdoutCollector = collectStream(outMode, child.stdout, 'stdout')
  372. const stderrCollector = collectStream(errMode, child.stderr, 'stderr')
  373. let graceTimer: ReturnType<typeof setTimeout> | undefined
  374. let treeExitObserved = false
  375. let treeExitObservation: Promise<void> | undefined
  376. let settled = false
  377. // Failed spawns use pid -1 so signalling remains a no-op.
  378. const pid = child.pid ?? -1
  379. /** Whether the detached tree's root (or POSIX group) is still alive. */
  380. const treeAlive = (): boolean => {
  381. /* v8 ignore next -- only a timer callback already queued when the observer settles can enter here;
  382. the guard is the final defense against probing an id after its tree was confirmed absent. */
  383. if (treeExitObserved) return false
  384. if (pid <= 0) return false
  385. if (platform === 'win32') {
  386. // Windows has no group-liveness probe; the direct child's exit is the
  387. // observable boundary (taskkill /T already took the tree with it).
  388. return child.exitCode === null && child.signalCode === null
  389. }
  390. try {
  391. process.kill(-pid, 0)
  392. // A group containing only unreaped zombies still answers kill(0), but
  393. // it can execute no work and cannot be signalled into quiescence. Only
  394. // inspect after direct-child settlement so live-process polls remain a
  395. // syscall rather than repeated process-table scans.
  396. if (settled && platform === 'linux' && linuxGroupHasLiveMembers(pid) === false) return false
  397. return true
  398. } catch (error) {
  399. const code = (error as NodeJS.ErrnoException).code
  400. /* v8 ignore next 2 -- POSIX reports an absent group as ESRCH; child-reaping timing
  401. makes observing the other arm platform-dependent. */
  402. if (code === 'ESRCH') return false
  403. /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs
  404. tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */
  405. if (code === 'EPERM') return true
  406. return child.exitCode === null && child.signalCode === null
  407. /* v8 ignore stop */
  408. }
  409. }
  410. /**
  411. * Start or reuse the handle's single whole-tree exit observer. The first
  412. * confirmed absence is a permanent no-more-signals boundary: it cancels a
  413. * pending escalation before this process-group id can be reused.
  414. */
  415. const observeTreeExit = (): Promise<void> => {
  416. treeExitObservation ??= (async () => {
  417. while (treeAlive()) await sleepTick()
  418. treeExitObserved = true
  419. if (graceTimer !== undefined) clearTimeout(graceTimer)
  420. graceTimer = undefined
  421. })()
  422. return treeExitObservation
  423. }
  424. // The escalation's tier primitive (not on the handle — terminate() is the
  425. // only consumer-facing termination verb). Guards on TREE liveness, not
  426. // outcome settlement: a TERM-trapping helper can outlive the settled direct
  427. // child and must stay signalable, while a fully-dead tree (possible pid
  428. // reuse) must not be re-signalled by a later tier.
  429. const kill = (sig: NodeJS.Signals): void => {
  430. /* v8 ignore next -- the shared exit observer cancels the ordinary dead-tree timer;
  431. this remains the timer/death race guard and cannot be staged deterministically. */
  432. if (!treeAlive()) return
  433. signalTree(platform, pid, sig, child, taskkill)
  434. }
  435. const terminate = (): void => {
  436. if (treeExitObserved || graceTimer !== undefined) return
  437. // Observe from the first termination tier onward, even when inherited
  438. // pipes delay `done` and no consumer has begun its own teardown wait.
  439. void observeTreeExit()
  440. // oxlint-disable-next-line typescript/no-unnecessary-condition -- observer can record absence before its first await.
  441. if (treeExitObserved) return
  442. kill('SIGTERM')
  443. // The escalation must survive direct-child settlement — the leader dying
  444. // does not mean the tree died — so settle does not clear this timer, and
  445. // kill() re-probes tree liveness before force-killing. It stays ref'd:
  446. // the pending SIGKILL is a commitment, and a parent exiting before it
  447. // fires would orphan a trapped survivor. Self-bounds at graceMs.
  448. graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs)
  449. }
  450. const terminateForHostExit = (): void => {
  451. kill('SIGKILL')
  452. }
  453. // The caller owns timeout classification; this layer only reacts to abort.
  454. const onAbort = (): void => { terminate() }
  455. spec.signal?.addEventListener('abort', onAbort, { once: true })
  456. // Batch stdin is written and closed up front; process exit and captured
  457. // output remain authoritative, so write errors (EPIPE) are best-effort.
  458. if (typeof stdinMode === 'object' && child.stdin !== null) {
  459. child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
  460. child.stdin.end(stdinMode.data)
  461. }
  462. const done = new Promise<SubprocessOutcome>((resolve, reject) => {
  463. let pipeDrainTimer: ReturnType<typeof setTimeout> | undefined
  464. const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
  465. if (settled) return
  466. settled = true
  467. // Only harness-collected pipes are force-closed at the drain boundary;
  468. // a 'pipe'-mode stream belongs to the caller and closes with the child.
  469. if (stdoutCollector !== undefined) child.stdout?.destroy()
  470. if (stderrCollector !== undefined) child.stderr?.destroy()
  471. stdoutCollector?.seal()
  472. stderrCollector?.seal()
  473. cleanup()
  474. resolve({ exitCode, signal })
  475. }
  476. child.on('error', (error) => {
  477. // No meaningful close outcome follows a spawn failure.
  478. settled = true
  479. cleanup()
  480. reject(error)
  481. })
  482. child.on('exit', (exitCode, signal) => {
  483. // A surviving descendant that inherited a pipe must not hold the
  484. // outcome open indefinitely: after exit, the same bounded grace that
  485. // governs kills also bounds the close wait.
  486. pipeDrainTimer = setTimeout(() => {
  487. settle(exitCode, signal)
  488. }, spec.graceMs)
  489. })
  490. child.on('close', settle)
  491. function cleanup(): void {
  492. // graceTimer deliberately NOT cleared: the SIGKILL escalation must be
  493. // able to reach tree survivors after the direct child settles.
  494. if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer)
  495. spec.signal?.removeEventListener('abort', onAbort)
  496. }
  497. })
  498. const waitForExit = async (signal?: AbortSignal): Promise<boolean> => {
  499. const observed = observeTreeExit()
  500. if (treeExitObserved) return true
  501. if (signal?.aborted) return false
  502. if (signal === undefined) {
  503. await observed
  504. return true
  505. }
  506. const aborted = Promise.withResolvers<boolean>()
  507. const onAbort = (): void => { aborted.resolve(false) }
  508. signal.addEventListener('abort', onAbort, { once: true })
  509. /* v8 ignore next -- closes the event-loop race between the preceding aborted check and listener registration. */
  510. if (signal.aborted) onAbort()
  511. try {
  512. return await Promise.race([observed.then(() => true), aborted.promise])
  513. } finally {
  514. signal.removeEventListener('abort', onAbort)
  515. }
  516. }
  517. return {
  518. pid,
  519. /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */
  520. stdin: stdinMode === 'pipe' ? child.stdin ?? undefined : undefined,
  521. stdout: outMode === 'pipe' ? child.stdout ?? undefined : undefined,
  522. stderr: errMode === 'pipe' ? child.stderr ?? undefined : undefined,
  523. /* v8 ignore stop */
  524. collected: {
  525. ...stdoutCollector !== undefined ? { stdout: stdoutCollector } : {},
  526. ...stderrCollector !== undefined ? { stderr: stderrCollector } : {},
  527. },
  528. done,
  529. terminate,
  530. terminateForHostExit,
  531. waitForExit,
  532. }
  533. }