spawn.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. /**
  2. * Process plumbing for the local subprocess service: ordinary process launch
  3. * with per-stream stdio dispositions, tail-keep collection with spill
  4. * files, provider-owned range signalling, and common termination scheduling.
  5. * POSIX owners stage TERM before KILL; Windows owners terminate immediately.
  6. * This layer reacts to an abort signal; callers own deadlines, teardown
  7. * ladders, and cause classification.
  8. * @module dsh-subprocess-local/spawn
  9. */
  10. import { type ChildProcess, type SpawnOptions, spawn, spawnSync } from 'node:child_process'
  11. import type { Readable } from 'node:stream'
  12. import { setTimeout as sleepMs } from 'node:timers/promises'
  13. import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
  14. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  15. import type {
  16. SubprocessCollect,
  17. SubprocessHandle,
  18. SubprocessOutcome,
  19. SubprocessOutputMode,
  20. SubprocessSpawnSpec,
  21. } from '@deepseek-ai/dsh-subprocess'
  22. import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts'
  23. import { waitWithAbort } from './managed-owner.ts'
  24. import { controlEnvironment, controlPipe } from './control-spawn.ts'
  25. import { SUBPROCESS_CONTROL_FD } from '@deepseek-ai/dsh-subprocess/control'
  26. import { linuxProcessGroupHasLiveMembers } from './process-inspector.ts'
  27. import { OutputCollector, prepareManagedProcessBinding } from './output.ts'
  28. type SpawnProcess = (
  29. program: string,
  30. args: readonly string[],
  31. options: SpawnOptions,
  32. ) => ChildProcess
  33. /**
  34. * Build a child environment: explicit caller entries override the scrubbed
  35. * parent base using the target platform's environment-key semantics. A string
  36. * deliberately restores or overrides an entry; an explicit `undefined`
  37. * tombstone removes an ordinary ambient entry.
  38. * @param extra - explicit caller entries and tombstones, merged after the scrub.
  39. * @returns the environment to hand to `spawn` for the child process.
  40. */
  41. export function childEnv(extra?: Readonly<NodeJS.ProcessEnv>): NodeJS.ProcessEnv {
  42. const env = scrubbedParentEnv()
  43. if (process.platform !== 'win32') return { ...env, ...extra }
  44. let entries: [string, string | undefined][] = Object.entries(env)
  45. for (const [key, value] of Object.entries(extra ?? {})) {
  46. const normalized = key.toUpperCase()
  47. entries = entries.filter(([inherited]) => inherited.toUpperCase() !== normalized)
  48. entries.push([key, value])
  49. }
  50. return Object.fromEntries(entries)
  51. }
  52. /** Injectable process, spill, and platform operations. */
  53. export interface SpawnInternals {
  54. /** Process spawner (defaults to `node:child_process` `spawn`). */
  55. spawn?: SpawnProcess
  56. /** Directory for spill files (defaults to the OS temp dir). */
  57. spillDir?: string
  58. /** Windows tree-termination runner (defaults to `taskkill /PID <pid> /T /F`). */
  59. taskkill?: (pid: number) => void
  60. /** Host platform override for signalling decisions. */
  61. platform?: NodeJS.Platform
  62. /** Linux process-group member probe (defaults to `/proc` inspection). */
  63. linuxProcessGroupHasLiveMembers?: (processGroupId: number) => boolean | undefined
  64. }
  65. /**
  66. * Local-only synchronous final termination used by the owning service during
  67. * host exit and as the last fallback after failed normal disposal. It is
  68. * intentionally absent from the public subprocess seam.
  69. */
  70. export interface LocalSubprocessHandle extends SubprocessHandle {
  71. /** Force-terminate the current tree synchronously without starting timers or waits. */
  72. terminateForHostExit(): void
  73. }
  74. /**
  75. * Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an
  76. * awaited teardown must keep the event loop alive until the tree really
  77. * exits, or the parent can exit while claiming quiescence and orphan the
  78. * survivors it promised to reap.
  79. */
  80. function sleepTick(): Promise<void> {
  81. return sleepMs(15)
  82. }
  83. /**
  84. * Send `sig` to a detached POSIX process group. Never throws: delivery races
  85. * process exit and may run in a timer callback, so failures are contained and
  86. * a missing pid is a no-op.
  87. * @param pid - the group leader's pid, when the spawn published one.
  88. * @param sig - the signal to deliver to the whole group.
  89. */
  90. export function killGroup(pid: number | undefined, sig: NodeJS.Signals): void {
  91. if (pid === undefined) return
  92. try {
  93. process.kill(-pid, sig)
  94. } catch {
  95. // Swallow: see contract above.
  96. }
  97. }
  98. /**
  99. * Terminate one Windows process tree with `taskkill /T /F`. Contained like
  100. * POSIX group signalling — delivery races tree exit, so an absent tree, a
  101. * nonzero status, or a missing taskkill binary must not break idempotent
  102. * teardown.
  103. * @param pid - root process id, when the spawn published one.
  104. */
  105. export function taskkillProcessTree(pid: number | undefined): void {
  106. if (pid === undefined || pid <= 0) return
  107. // Outcome deliberately unchecked: an already-absent tree (status 128), exit
  108. // races, and a missing taskkill binary (spawnSync reports, never throws) are
  109. // as tolerable here as ESRCH is for a POSIX group signal.
  110. spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
  111. stdio: 'ignore',
  112. windowsHide: true,
  113. })
  114. }
  115. /**
  116. * Signal a detached process tree with platform-correct semantics: POSIX
  117. * signals the negative process-group id and falls back to the direct child
  118. * when the group is gone; Windows terminates the tree via taskkill (any
  119. * signal value force-terminates — Node maps signals to TerminateProcess).
  120. */
  121. function signalTree(
  122. platform: NodeJS.Platform,
  123. pid: number | undefined,
  124. sig: NodeJS.Signals,
  125. child: ChildProcess,
  126. taskkill: (pid: number) => void,
  127. ): void {
  128. /* v8 ignore next -- kill/terminate gate on treeAlive(), which is false without a pid; this guard protects direct callers only. */
  129. if (pid === undefined) return
  130. if (platform === 'win32') {
  131. taskkill(pid)
  132. return
  133. }
  134. try {
  135. process.kill(-pid, sig)
  136. } catch {
  137. /* v8 ignore start -- the fallback needs a live child whose group signal fails
  138. (EPERM-style), which POSIX CI cannot stage; the swallow keeps teardown idempotent. */
  139. try {
  140. child.kill(sig)
  141. } catch {
  142. // The direct child already exited; teardown remains idempotent.
  143. }
  144. /* v8 ignore stop */
  145. }
  146. }
  147. /**
  148. * Validate the synchronous portion of one ordinary spawn request.
  149. * @param spec - exact target request.
  150. * @throws when grace, cancellation, or argv is invalid before launch.
  151. */
  152. export function validateSubprocessSpec(spec: SubprocessSpawnSpec): void {
  153. if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) {
  154. throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
  155. }
  156. if (spec.signal?.aborted) {
  157. let reason = 'aborted'
  158. try {
  159. reason = String(spec.signal.reason ?? reason)
  160. } catch {
  161. // Arbitrary caller-owned reasons cannot escape the stable Error boundary.
  162. }
  163. throw new Error(`aborted before spawn: ${reason}`)
  164. }
  165. const [program] = spec.argv
  166. if (program === undefined || program.length === 0) {
  167. throw new Error('invalid argv: expected a non-empty program name at argv[0]')
  168. }
  169. }
  170. function directChildResult(child: ChildProcess): Promise<SubprocessOutcome> {
  171. return new Promise((resolve, reject) => {
  172. let completed = false
  173. child.once('error', (error) => {
  174. /* v8 ignore next -- ChildProcess may report a later operational error after its
  175. terminal exit event; the first terminal event owns the result. */
  176. if (completed) return
  177. completed = true
  178. reject(error)
  179. })
  180. child.once('exit', (exitCode, signal) => {
  181. /* v8 ignore next -- a spawn/kill error may be followed by exit; a Promise can publish only the first terminal event. */
  182. if (completed) return
  183. completed = true
  184. resolve({ exitCode, signal })
  185. })
  186. })
  187. }
  188. function fallbackOwner(
  189. platform: NodeJS.Platform,
  190. pid: number | undefined,
  191. child: ChildProcess,
  192. taskkill: (pid: number) => void,
  193. linuxGroupHasLiveMembers: (processGroupId: number) => boolean | undefined,
  194. direct: Promise<SubprocessOutcome>,
  195. ): BoundProcessOwner {
  196. let stopped = false
  197. let directSettled = false
  198. let observation: Promise<void> | undefined
  199. void direct.then(
  200. () => { directSettled = true },
  201. () => { directSettled = true },
  202. )
  203. const alive = (): boolean => {
  204. if (stopped || pid === undefined) return false
  205. if (platform === 'win32') return child.exitCode === null && child.signalCode === null
  206. try {
  207. process.kill(-pid, 0)
  208. if (directSettled && platform === 'linux' && linuxGroupHasLiveMembers(pid) === false) return false
  209. return true
  210. } catch (error) {
  211. const code = (error as NodeJS.ErrnoException).code
  212. if (code === 'ESRCH') return false
  213. /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses. */
  214. if (code === 'EPERM') return true
  215. return child.exitCode === null && child.signalCode === null
  216. /* v8 ignore stop */
  217. }
  218. }
  219. return {
  220. signal: (signal) => {
  221. if (!alive()) {
  222. stopped = true
  223. return
  224. }
  225. signalTree(platform, pid, signal, child, taskkill)
  226. },
  227. waitForExit: async () => {
  228. /* v8 ignore next -- bindManagedProcess memoizes this owner wait; the guard only
  229. protects direct internal re-entry after signal() observed absence. */
  230. if (stopped) return
  231. observation ??= (async () => {
  232. while (alive()) await sleepTick()
  233. stopped = true
  234. })()
  235. await observation
  236. },
  237. terminateForHostExit: () => {
  238. if (stopped) return
  239. signalTree(platform, pid, 'SIGKILL', child, taskkill)
  240. },
  241. }
  242. }
  243. /**
  244. * Bind platform launch facts to the existing stdio, outcome, abort, and termination lifecycle.
  245. * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
  246. * @param launch - platform streams, direct outcome, and managed-range owner.
  247. * @param internals - test-only spill-directory override.
  248. * @returns live subprocess handle.
  249. */
  250. export function bindManagedProcess(
  251. spec: SubprocessSpawnSpec,
  252. launch: ManagedProcessLaunch,
  253. internals: Pick<SpawnInternals, 'spillDir'> = {},
  254. ): LocalSubprocessHandle {
  255. const { spillDir } = prepareManagedProcessBinding(internals)
  256. const { stdin, stdout, stderr } = launch
  257. const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect =>
  258. mode !== 'pipe' && mode !== 'inherit'
  259. const outMode = spec.stdio.stdout
  260. const errMode = spec.stdio.stderr
  261. const stdinMode = spec.stdio.stdin
  262. const collectStream = (mode: SubprocessOutputMode, stream: Readable | null, label: string): OutputCollector | undefined => {
  263. if (!isCollect(mode) || stream === null) return undefined
  264. const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, label, spillDir)
  265. stream.on('data', (chunk: Buffer) => { collector.push(chunk) })
  266. return collector
  267. }
  268. const stdoutCollector = collectStream(outMode, stdout, 'stdout')
  269. const stderrCollector = collectStream(errMode, stderr, 'stderr')
  270. const observeOutputStream = (mode: SubprocessOutputMode, stream: Readable | null): Promise<void> | undefined => {
  271. if (mode === 'inherit' || stream === null || stream.readableEnded || stream.destroyed) return undefined
  272. return new Promise((resolve) => {
  273. const settle = (): void => {
  274. stream.off('end', settle)
  275. stream.off('close', settle)
  276. stream.off('error', settle)
  277. resolve()
  278. }
  279. stream.once('end', settle)
  280. stream.once('close', settle)
  281. stream.once('error', settle)
  282. })
  283. }
  284. const stdoutClosed = observeOutputStream(outMode, stdout)
  285. const stderrClosed = observeOutputStream(errMode, stderr)
  286. const outputStreamsClosed = Promise.all([stdoutClosed, stderrClosed])
  287. const stopCollectors = (): void => {
  288. if (stdoutCollector !== undefined) stdout?.destroy()
  289. if (stderrCollector !== undefined) stderr?.destroy()
  290. stdoutCollector?.seal()
  291. stderrCollector?.seal()
  292. }
  293. let graceTimer: ReturnType<typeof setTimeout> | undefined
  294. let terminationStarted = false
  295. let rangeExitObserved = false
  296. let rangeExitObservation: Promise<void> | undefined
  297. let settled = false
  298. const scheduleOwnerCleanup = (): boolean => {
  299. if (launch.owner.cleanup === undefined) return false
  300. queueMicrotask(() => { void done.finally(() => { launch.owner.cleanup?.() }).catch(() => {}) })
  301. return true
  302. }
  303. /**
  304. * Start or reuse the handle's managed-range exit observer. A failed read
  305. * before direct settlement can be retried. Once direct settlement permits
  306. * cleanup, retain a failed observation because removing its private evidence
  307. * must not turn a later wait into a false success. The first confirmed
  308. * absence is the permanent no-more-signals boundary and cancels pending
  309. * escalation before stale identity can be used.
  310. */
  311. const observeRangeExit = (): Promise<void> => {
  312. rangeExitObservation ??= (async () => {
  313. await launch.owner.waitForExit()
  314. rangeExitObserved = true
  315. if (graceTimer !== undefined) clearTimeout(graceTimer)
  316. graceTimer = undefined
  317. spec.signal?.removeEventListener('abort', onAbort)
  318. scheduleOwnerCleanup()
  319. })().catch((error: unknown) => {
  320. if (!settled || !scheduleOwnerCleanup()) rangeExitObservation = undefined
  321. throw error
  322. })
  323. return rangeExitObservation
  324. }
  325. const kill = (sig: 'SIGTERM' | 'SIGKILL', cancellationReason?: unknown): void => {
  326. if (rangeExitObserved) return
  327. launch.owner.signal(sig, cancellationReason)
  328. }
  329. const terminateWithReason = (cancellationReason: unknown): void => {
  330. if (rangeExitObserved || terminationStarted) return
  331. terminationStarted = true
  332. // Keep the shared observation rejection available to waitForExit() without
  333. // leaking an unhandled rejection when a caller only invokes terminate().
  334. void observeRangeExit().catch(() => {})
  335. kill('SIGTERM', cancellationReason)
  336. graceTimer = setTimeout(() => {
  337. graceTimer = undefined
  338. kill('SIGKILL')
  339. }, spec.graceMs)
  340. }
  341. const terminate = (): void => {
  342. terminateWithReason(new Error('subprocess terminated before target start'))
  343. }
  344. const terminateForHostExit = (): void => {
  345. launch.owner.terminateForHostExit()
  346. }
  347. // The caller owns timeout classification; this layer only reacts to abort.
  348. const onAbort = (): void => { terminateWithReason(spec.signal?.reason) }
  349. spec.signal?.addEventListener('abort', onAbort, { once: true })
  350. // Batch stdin is written and closed up front; process exit and captured
  351. // output remain authoritative, so write errors (EPIPE) are best-effort.
  352. if (typeof stdinMode === 'object' && stdin !== null) {
  353. stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
  354. stdin.end(stdinMode.data)
  355. }
  356. const done = new Promise<SubprocessOutcome>((resolve, reject) => {
  357. let pipeDrainTimer: ReturnType<typeof setTimeout> | undefined
  358. const settle = (outcome: SubprocessOutcome): void => {
  359. if (settled) return
  360. settled = true
  361. // Only harness-collected pipes are force-closed at the drain boundary;
  362. // a 'pipe'-mode stream belongs to the caller and closes with the child.
  363. stopCollectors()
  364. cleanup()
  365. resolve(outcome)
  366. }
  367. const fail = (error: unknown): void => {
  368. settled = true
  369. terminate()
  370. stopCollectors()
  371. cleanup()
  372. // Preserve the exact parent-local AbortSignal reason, including null or undefined.
  373. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Exact cancellation reason is the contract.
  374. reject(error)
  375. }
  376. launch.direct.then((outcome) => {
  377. if (stdoutClosed === undefined && stderrClosed === undefined) {
  378. settle(outcome)
  379. return
  380. }
  381. pipeDrainTimer = setTimeout(() => { settle(outcome) }, spec.graceMs)
  382. void outputStreamsClosed.then(() => { settle(outcome) })
  383. }, fail)
  384. function cleanup(): void {
  385. // graceTimer deliberately NOT cleared: forced termination must still
  386. // reach range survivors after the spawned command settles.
  387. if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer)
  388. }
  389. })
  390. const waitForExit = async (signal?: AbortSignal): Promise<boolean> => {
  391. if (rangeExitObserved) return true
  392. return waitWithAbort(observeRangeExit(), signal)
  393. }
  394. return {
  395. /* v8 ignore start -- pipe-mode streams exist on every conforming launch;
  396. the null-coalesces guard an internal adapter defect only. */
  397. stdin: stdinMode === 'pipe' ? stdin ?? undefined : undefined,
  398. stdout: outMode === 'pipe' ? stdout ?? undefined : undefined,
  399. stderr: errMode === 'pipe' ? stderr ?? undefined : undefined,
  400. control: launch.control,
  401. /* v8 ignore stop */
  402. collected: {
  403. ...stdoutCollector !== undefined ? { stdout: stdoutCollector } : {},
  404. ...stderrCollector !== undefined ? { stderr: stderrCollector } : {},
  405. },
  406. done,
  407. terminate,
  408. terminateForHostExit,
  409. waitForExit,
  410. }
  411. }
  412. /**
  413. * Spawn one detached PGID/taskkill fallback and bind the common lifecycle.
  414. * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
  415. * @param internals - test-only spill-directory, platform, and taskkill overrides.
  416. * @returns live subprocess handle.
  417. */
  418. export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): LocalSubprocessHandle {
  419. const binding = prepareManagedProcessBinding(internals)
  420. const platform = internals.platform ?? process.platform
  421. const [program, ...args] = spec.argv
  422. const stdio: import('node:child_process').StdioOptions = [
  423. spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe',
  424. spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe',
  425. spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe',
  426. ]
  427. if (spec.stdio.control === 'pipe') {
  428. while (stdio.length < SUBPROCESS_CONTROL_FD) stdio.push('ignore')
  429. stdio.push('overlapped')
  430. }
  431. const child = (internals.spawn ?? spawn)(program as string, args, {
  432. cwd: spec.cwd,
  433. env: controlEnvironment(childEnv(spec.env), spec.stdio.control),
  434. stdio,
  435. detached: platform !== 'win32',
  436. windowsHide: platform === 'win32',
  437. })
  438. const direct = directChildResult(child)
  439. const pid = child.pid
  440. const owner = fallbackOwner(
  441. platform,
  442. pid,
  443. child,
  444. internals.taskkill ?? taskkillProcessTree,
  445. internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers,
  446. direct,
  447. )
  448. return bindManagedProcess(spec, {
  449. stdin: child.stdin,
  450. stdout: child.stdout,
  451. stderr: child.stderr,
  452. control: controlPipe(child, spec.stdio.control),
  453. direct,
  454. owner,
  455. }, binding)
  456. }