spawn.ts 23 KB

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