@dbx-tools/core 0.9.121 → 0.9.123

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/exec.ts CHANGED
@@ -54,15 +54,24 @@ import * as readline from "node:readline";
54
54
  import { Readable } from "node:stream";
55
55
  import { text } from "node:stream/consumers";
56
56
  import { finished } from "node:stream/promises";
57
+ import { errorUtils, functionUtils, log } from "@dbx-tools/shared-core";
57
58
 
58
59
  /** Shell-compatible exit code returned when an executable cannot be found. */
59
60
  export const COMMAND_NOT_FOUND_EXIT_CODE = 127;
60
61
 
61
- const DEFAULT_GRACEFUL_TIMEOUT_MS = 10_000;
62
- const DEFAULT_FORCE_TIMEOUT_MS = 4_000;
62
+ const DEFAULT_TERMINATE_TIMEOUT_MS = 8_000;
63
+ const DEFAULT_KILL_TIMEOUT_MS = 4_000;
63
64
  const DEFAULT_KILL_POLL_INTERVAL_MS = 100;
65
+ const DEFAULT_TERMINATE_SIGNAL: NodeJS.Signals = "SIGTERM";
66
+ const DEFAULT_KILL_SIGNAL: NodeJS.Signals = "SIGKILL";
67
+ const FORWARDED_PARENT_SIGNALS = (
68
+ process.platform === "win32"
69
+ ? ["SIGINT", "SIGTERM", "SIGBREAK"]
70
+ : ["SIGHUP", "SIGINT", "SIGTERM", "SIGQUIT"]
71
+ ) satisfies readonly NodeJS.Signals[];
64
72
  const WINDOWS_PROCESS_SNAPSHOT_SCRIPT =
65
73
  "Get-CimInstance Win32_Process | ForEach-Object { '{0} {1} {2}' -f $_.ProcessId, $_.ParentProcessId, $_.CreationDate.ToFileTimeUtc() }";
74
+ const logger = log.logger("core:exec");
66
75
 
67
76
  /** Stdio mode for a subprocess fd. */
68
77
  export type ExecStdio = "inherit" | "pipe" | "ignore";
@@ -122,7 +131,7 @@ export type ExecResult = {
122
131
  * This is additive - the object still satisfies `Promise<ExecResult>`, so every
123
132
  * existing `await spawn(...)` / `spawn(...).then(...)` caller is unchanged. What
124
133
  * is new is that the same value is the live child: callers that want to supervise
125
- * it can read `.pid`, call `.kill(signal)`, and listen for `exit` without a
134
+ * it can read `.pid`, call `.shutdown()`, and listen for `exit` without a
126
135
  * second spawn.
127
136
  *
128
137
  * @example Await it (unchanged)
@@ -130,48 +139,51 @@ export type ExecResult = {
130
139
  *
131
140
  * @example Supervise it
132
141
  * const proc = spawn("bun", ["src/server.ts"]);
133
- * const shutdown = () => kill(proc);
142
+ * const shutdown = () => proc.shutdown();
134
143
  * const { exitCode } = await proc;
135
144
  */
136
- export type ChildProcessResult = ChildProcess & Promise<ExecResult>;
137
-
138
- /** Options for {@link spawn}. Extends `SpawnOptions` except `stdio`, which is driven by `stdin` / `stdout` / `stderr`. */
139
- export type ExecOptions = Omit<SpawnOptions, "stdio"> & {
140
- /** `"inherit"` by default; `"pipe"` / `"ignore"` select modes, any other string is input. */
141
- stdin?: ExecStdio | string;
142
- stdout?: StdioOption;
143
- stderr?: StdioOption;
144
- /** Throw when the process exits with a non-zero code. */
145
- check?: boolean;
146
- /**
147
- * Omitted — adaptive trim ({@link spawnSync} drops one spawn trailing newline,
148
- * {@link spawn} does not); `true` — strip all leading/trailing whitespace;
149
- * `false` — leave captured output unchanged.
150
- */
151
- trim?: boolean;
152
- };
145
+ export type ChildProcessResult = ChildProcess &
146
+ Promise<ExecResult> & {
147
+ /** Stop the child process tree once using the configured termination policy. */
148
+ shutdown(options?: ChildProcessTerminationOptions): Promise<void>;
149
+ };
153
150
 
154
- /** Graceful and forced shutdown policy for {@link kill}. */
155
- export interface KillOptions {
156
- /** Signal sent to the snapshotted process tree. `false` skips this phase. Defaults to `SIGTERM`. */
157
- gracefulSignal?: NodeJS.Signals | false;
158
- /** Processes receiving the graceful signal. Defaults to the complete process tree. */
159
- gracefulSignalTarget?: "root" | "tree";
160
- /** Time to wait for graceful shutdown. Defaults to 10 seconds. */
161
- gracefulTimeoutMs?: number;
162
- /** Signal sent to survivors. `false` disables forced termination. Defaults to `SIGKILL`. */
163
- forceSignal?: NodeJS.Signals | false;
164
- /** Time to poll and force newly discovered descendants. Defaults to 4 seconds. */
165
- forceTimeoutMs?: number;
166
- /** Interval used to refresh and poll the process tree. Defaults to 100 milliseconds. */
167
- pollIntervalMs?: number;
151
+ /** Process-tree termination policy used by {@link ChildProcessResult.shutdown}. */
152
+ export interface ChildProcessTerminationOptions {
153
+ /** Time to wait for operating-system termination. Defaults to 8 seconds. */
154
+ terminateTimeout?: number;
155
+ /** Time to poll and force-kill remaining descendants. Defaults to 4 seconds. */
156
+ killTimeout?: number;
168
157
  }
169
158
 
159
+ /** Options for {@link spawn}. Extends `SpawnOptions` except `stdio`, which is driven by `stdin` / `stdout` / `stderr`. */
160
+ export type ExecOptions = Omit<SpawnOptions, "stdio" | "killSignal"> &
161
+ ChildProcessTerminationOptions & {
162
+ /** `"inherit"` by default; `"pipe"` / `"ignore"` select modes, any other string is input. */
163
+ stdin?: ExecStdio | string;
164
+ stdout?: StdioOption;
165
+ stderr?: StdioOption;
166
+ /**
167
+ * Forward parent termination signals to this child. Defaults to `true`.
168
+ * Set to `false` to skip the shared signal trap; on POSIX, the child is also
169
+ * detached by default so terminal process-group signals are not inherited.
170
+ */
171
+ forwardTerminationSignals?: boolean;
172
+ /** Throw when the process exits with a non-zero code. */
173
+ check?: boolean;
174
+ /**
175
+ * Omitted — adaptive trim ({@link spawnSync} drops one spawn trailing newline,
176
+ * {@link spawn} does not); `true` — strip all leading/trailing whitespace;
177
+ * `false` — leave captured output unchanged.
178
+ */
179
+ trim?: boolean;
180
+ };
181
+
170
182
  /** Stdio mode for {@link spawnSync} (no per-line callbacks). */
171
183
  export type SyncExecStdio = ExecStdio | "capture";
172
184
 
173
185
  /** Options for {@link spawnSync}. Same shape as {@link ExecOptions} but without line-handler stdio. */
174
- export type SyncExecOptions = Omit<SpawnOptions, "stdio"> & {
186
+ export type SyncExecOptions = Omit<SpawnOptions, "stdio" | "killSignal"> & {
175
187
  /** `"inherit"` by default; `"pipe"` / `"ignore"` select modes, any other string is input. */
176
188
  stdin?: ExecStdio | string;
177
189
  stdout?: SyncExecStdio;
@@ -223,6 +235,179 @@ interface RefreshedProcessTree {
223
235
  discovered: ProcessSnapshotEntry[];
224
236
  }
225
237
 
238
+ interface ParentSignalForwarderState {
239
+ readonly children: Map<number, ChildProcessResult>;
240
+ readonly handlers: Map<NodeJS.Signals, () => void>;
241
+ readonly forwardingSignals: Set<NodeJS.Signals>;
242
+ readonly listeners: Set<(signal: NodeJS.Signals) => void>;
243
+ }
244
+
245
+ interface SignalExitEmitter {
246
+ count?: unknown;
247
+ }
248
+
249
+ interface ProcessWithSignalExitEmitter extends NodeJS.Process {
250
+ __signal_exit_emitter__?: SignalExitEmitter;
251
+ }
252
+
253
+ type ParentSignalShutdown = (signal: NodeJS.Signals) => Promise<void>;
254
+
255
+ const parentSignalForwarderState = functionUtils.memoize((): ParentSignalForwarderState => ({
256
+ children: new Map(),
257
+ handlers: new Map(),
258
+ forwardingSignals: new Set(),
259
+ listeners: new Set(),
260
+ }));
261
+ const parentSignalShutdowns = new WeakMap<ChildProcessResult, ParentSignalShutdown>();
262
+
263
+ function registerParentSignalForwarding(child: ChildProcessResult): (() => void) | undefined {
264
+ const pid = child.pid;
265
+ if (pid === undefined) return undefined;
266
+ const state = parentSignalForwarderState();
267
+ installParentSignalHandlers(state);
268
+ state.children.set(pid, child);
269
+ return () => {
270
+ if (state.children.get(pid) === child) state.children.delete(pid);
271
+ };
272
+ }
273
+
274
+ function installParentSignalHandlers(state: ParentSignalForwarderState): void {
275
+ if (state.handlers.size > 0) return;
276
+ for (const signal of FORWARDED_PARENT_SIGNALS) {
277
+ const handler = () => {
278
+ try {
279
+ forwardParentSignal(state, signal);
280
+ } catch (error) {
281
+ reportParentSignalError(signal, undefined, error);
282
+ }
283
+ };
284
+ state.handlers.set(signal, handler);
285
+ process.prependListener(signal, handler);
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Subscribe to the process-global parent-termination trap used by {@link spawn}.
291
+ * Does not add a per-subscriber `process` listener; extra listeners would change
292
+ * whether the trap re-raises the signal after children exit.
293
+ *
294
+ * @param listener - Called synchronously when a forwarded parent signal arrives
295
+ * @returns Unsubscribe function
296
+ */
297
+ export function onParentTerminationSignal(
298
+ listener: (signal: NodeJS.Signals) => void,
299
+ ): () => void {
300
+ const state = parentSignalForwarderState();
301
+ installParentSignalHandlers(state);
302
+ state.listeners.add(listener);
303
+ return () => {
304
+ state.listeners.delete(listener);
305
+ };
306
+ }
307
+
308
+ function forwardParentSignal(state: ParentSignalForwarderState, signal: NodeJS.Signals): void {
309
+ if (state.forwardingSignals.has(signal)) return;
310
+ for (const listener of state.listeners) {
311
+ try {
312
+ listener(signal);
313
+ } catch (error) {
314
+ reportParentSignalError(signal, undefined, error);
315
+ }
316
+ }
317
+ const shutdowns: Promise<void>[] = [];
318
+ for (const [pid, child] of state.children) {
319
+ if (child.exitCode !== null || child.signalCode !== null) {
320
+ state.children.delete(pid);
321
+ continue;
322
+ }
323
+ shutdowns.push(shutdownChildForParentSignal(child, pid, signal));
324
+ }
325
+
326
+ const handler = state.handlers.get(signal);
327
+ const completed = Promise.all(shutdowns).catch((error) => {
328
+ reportParentSignalError(signal, undefined, error);
329
+ });
330
+ if (!handler || process.listenerCount(signal) !== passiveSignalListenerCount() + 1) {
331
+ void completed;
332
+ return;
333
+ }
334
+ state.forwardingSignals.add(signal);
335
+ void completed
336
+ .then(() => {
337
+ process.off(signal, handler);
338
+ state.handlers.delete(signal);
339
+ state.forwardingSignals.delete(signal);
340
+ try {
341
+ process.kill(process.pid, signal);
342
+ } catch (error) {
343
+ reportParentSignalError(signal, process.pid, error);
344
+ }
345
+ })
346
+ .catch((error) => {
347
+ reportParentSignalError(signal, undefined, error);
348
+ });
349
+ }
350
+
351
+ function shutdownChildForParentSignal(
352
+ child: ChildProcessResult,
353
+ pid: number,
354
+ signal: NodeJS.Signals,
355
+ ): Promise<void> {
356
+ try {
357
+ const shutdown = parentSignalShutdowns.get(child);
358
+ return (shutdown ? shutdown(signal) : child.shutdown()).catch((error) => {
359
+ reportParentSignalError(signal, pid, error);
360
+ });
361
+ } catch (error) {
362
+ reportParentSignalError(signal, pid, error);
363
+ return Promise.resolve();
364
+ }
365
+ }
366
+
367
+ function reportParentSignalError(
368
+ signal: NodeJS.Signals,
369
+ pid: number | undefined,
370
+ error: unknown,
371
+ ): void {
372
+ if (isExpectedProcessShutdownError(error)) return;
373
+ try {
374
+ logger.warn("failed to forward parent signal", {
375
+ signal,
376
+ ...(pid === undefined ? {} : { pid }),
377
+ error,
378
+ });
379
+ } catch {
380
+ // Signal forwarding must never prevent other process listeners from running.
381
+ }
382
+ }
383
+
384
+ function isExpectedProcessShutdownError(error: unknown): boolean {
385
+ if (isMissingProcessError(error)) return true;
386
+ return error instanceof AggregateError && error.errors.every(isExpectedProcessShutdownError);
387
+ }
388
+
389
+ function passiveSignalListenerCount(): number {
390
+ const legacyEmitter = (process as ProcessWithSignalExitEmitter).__signal_exit_emitter__;
391
+ const modernEmitter = (globalThis as typeof globalThis & Record<symbol, unknown>)[
392
+ Symbol.for("signal-exit emitter")
393
+ ] as SignalExitEmitter | undefined;
394
+ return signalExitEmitterCount(legacyEmitter) + signalExitEmitterCount(modernEmitter);
395
+ }
396
+
397
+ function signalExitEmitterCount(emitter: SignalExitEmitter | undefined): number {
398
+ return typeof emitter?.count === "number" ? emitter.count : 0;
399
+ }
400
+
401
+ function resolveDetached(
402
+ detached: boolean | undefined,
403
+ forwardTerminationSignals: boolean,
404
+ ): boolean | undefined {
405
+ if (detached !== undefined || forwardTerminationSignals || process.platform === "win32") {
406
+ return detached;
407
+ }
408
+ return true;
409
+ }
410
+
226
411
  function parseSpawnArgs<T extends SpawnOptions>(input: SpawnArgs<T>): ParsedSpawnArgs<T> {
227
412
  let [value, ...values] = input;
228
413
 
@@ -321,12 +506,13 @@ function timeoutValue(value: number, name: string): number {
321
506
  return value;
322
507
  }
323
508
 
324
- /** Validate a finite positive polling interval. */
325
- function pollIntervalValue(value: number): number {
326
- if (!Number.isFinite(value) || value <= 0) {
327
- throw new RangeError("pollIntervalMs must be a finite positive number");
328
- }
329
- return value;
509
+ /** Reject caller-selected signals so platform shutdown policy stays internal. */
510
+ function rejectSignalOptions(options: object): void {
511
+ const unsupported = options as { terminateSignal?: unknown; killSignal?: unknown };
512
+ if (unsupported.terminateSignal === undefined && unsupported.killSignal === undefined) return;
513
+ throw new TypeError(
514
+ "Process signals are selected internally; configure terminateTimeout and killTimeout instead",
515
+ );
330
516
  }
331
517
 
332
518
  /** Parse the common `pid parentPid identity` process-snapshot format. */
@@ -389,7 +575,8 @@ async function linuxProcessSnapshot(): Promise<ProcessSnapshotEntry[]> {
389
575
  *
390
576
  * Linux reads exact start ticks from `/proc`. Windows uses the built-in
391
577
  * PowerShell CIM provider. macOS and other POSIX platforms combine `ps`
392
- * process start time and executable name.
578
+ * process start time and executable name. The helper process is detached so a
579
+ * terminal SIGINT to this process group cannot abort the snapshot.
393
580
  */
394
581
  async function processSnapshot(): Promise<ProcessSnapshotEntry[]> {
395
582
  if (process.platform === "linux") return linuxProcessSnapshot();
@@ -402,6 +589,7 @@ async function processSnapshot(): Promise<ProcessSnapshotEntry[]> {
402
589
  const proc = nodeSpawn(command, args, {
403
590
  stdio: ["ignore", "pipe", "pipe"],
404
591
  windowsHide: true,
592
+ detached: true,
405
593
  });
406
594
  const stdout = text(proc.stdout!);
407
595
  const stderr = text(proc.stderr!);
@@ -416,6 +604,16 @@ async function processSnapshot(): Promise<ProcessSnapshotEntry[]> {
416
604
  return parseProcessSnapshot(linesFromCapturedOutput(stdoutText));
417
605
  }
418
606
 
607
+ /** Read a process snapshot, or `undefined` when the process table cannot be listed. */
608
+ async function tryProcessSnapshot(): Promise<ProcessSnapshotEntry[] | undefined> {
609
+ try {
610
+ return await processSnapshot();
611
+ } catch (error) {
612
+ logger.debug("process snapshot failed", { error: errorUtils.toError(error) });
613
+ return undefined;
614
+ }
615
+ }
616
+
419
617
  /** Group a process snapshot by parent PID. */
420
618
  function processChildren(
421
619
  snapshot: readonly ProcessSnapshotEntry[],
@@ -551,7 +749,12 @@ async function waitForProcessTreeExit(
551
749
  const remainingMs = deadline - Date.now();
552
750
  if (remainingMs > 0) await delay(Math.min(pollIntervalMs, remainingMs));
553
751
 
554
- const refreshed = refreshProcessTree(retained, await processSnapshot());
752
+ const snapshot = await tryProcessSnapshot();
753
+ if (snapshot === undefined) {
754
+ if (Date.now() >= deadline) break;
755
+ continue;
756
+ }
757
+ const refreshed = refreshProcessTree(retained, snapshot);
555
758
  if (signalDiscovered && refreshed.discovered.length > 0) {
556
759
  signalProcesses(refreshed.discovered, signal, signaled);
557
760
  }
@@ -827,90 +1030,75 @@ function linesFromCapturedOutput(output: string): string[] {
827
1030
  * The initial tree is retained before signaling so descendants remain
828
1031
  * targetable after their parent exits or they are reparented. Polling refreshes
829
1032
  * the process table, merges newly created descendants, and sends each one the
830
- * active shutdown signal once. Either phase can be disabled for graceful-only
831
- * or force-only termination. macOS and other POSIX platforms use `ps`; Windows
832
- * uses the built-in PowerShell CIM process provider.
1033
+ * active shutdown signal once. macOS and other POSIX platforms use `ps`;
1034
+ * Windows uses the built-in PowerShell CIM process provider.
833
1035
  *
834
1036
  * Windows does not implement POSIX signal semantics. Node terminates processes
835
1037
  * unconditionally for supported signals there, including `SIGTERM`.
836
1038
  *
837
1039
  * @param child - Root child process whose snapshotted tree should stop
838
- * @param options - Graceful signal, force signal, timeout, and polling policy
839
- * @throws When the process table cannot be captured, a signal cannot be sent,
840
- * or identity-matched processes remain after the final enabled phase
1040
+ * @param options - Graceful and forced shutdown timeout policy
1041
+ * @param terminateSignal - Internal signal selected from the parent event or platform default
841
1042
  */
842
- export async function kill(child: ChildProcess, options: KillOptions = {}): Promise<void> {
1043
+ async function terminateProcessTree(
1044
+ child: ChildProcess,
1045
+ options: ChildProcessTerminationOptions = {},
1046
+ terminateSignal: NodeJS.Signals = DEFAULT_TERMINATE_SIGNAL,
1047
+ ): Promise<void> {
843
1048
  if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) {
844
1049
  return;
845
1050
  }
846
1051
 
847
- const gracefulTimeoutMs = timeoutValue(
848
- options.gracefulTimeoutMs ?? DEFAULT_GRACEFUL_TIMEOUT_MS,
849
- "gracefulTimeoutMs",
1052
+ const terminateTimeout = timeoutValue(
1053
+ options.terminateTimeout ?? DEFAULT_TERMINATE_TIMEOUT_MS,
1054
+ "terminateTimeout",
850
1055
  );
851
- const forceTimeoutMs = timeoutValue(
852
- options.forceTimeoutMs ?? DEFAULT_FORCE_TIMEOUT_MS,
853
- "forceTimeoutMs",
854
- );
855
- const pollIntervalMs = pollIntervalValue(options.pollIntervalMs ?? DEFAULT_KILL_POLL_INTERVAL_MS);
856
- const snapshot = await processSnapshot();
1056
+ const killTimeout = timeoutValue(options.killTimeout ?? DEFAULT_KILL_TIMEOUT_MS, "killTimeout");
1057
+ const pollIntervalMs = DEFAULT_KILL_POLL_INTERVAL_MS;
1058
+ const snapshot = await tryProcessSnapshot();
857
1059
  if (child.exitCode !== null || child.signalCode !== null) return;
858
- const tree = processTree(snapshot, child.pid);
859
- const retained = new Map(tree.map((entry) => [entry.pid, entry]));
860
- const gracefulSignal = options.gracefulSignal === undefined ? "SIGTERM" : options.gracefulSignal;
861
- const gracefulSignalTarget = options.gracefulSignalTarget ?? "tree";
862
- const forceSignal = options.forceSignal === undefined ? "SIGKILL" : options.forceSignal;
863
- if (gracefulSignalTarget !== "root" && gracefulSignalTarget !== "tree") {
864
- throw new RangeError("gracefulSignalTarget must be root or tree");
865
- }
866
- if (gracefulSignal === false && forceSignal === false) {
867
- throw new Error("At least one process-tree termination signal must be enabled");
868
- }
869
-
870
- let forceTargets = tree;
871
- if (gracefulSignal !== false) {
872
- const gracefulSignaled = new Set<string>();
873
- const gracefulTargets = gracefulSignalTarget === "root" ? [tree.at(-1)!] : tree;
874
- signalProcesses(gracefulTargets, gracefulSignal, gracefulSignaled);
875
- forceTargets = await waitForProcessTreeExit(
876
- retained,
877
- tree,
878
- gracefulSignal,
879
- gracefulSignaled,
880
- gracefulTimeoutMs,
881
- pollIntervalMs,
882
- gracefulSignalTarget === "tree",
883
- );
884
- }
885
- if (forceTargets.length === 0) return;
886
- if (forceSignal === false) {
887
- if (gracefulSignal === false) {
888
- throw new Error("At least one process-tree termination signal must be enabled");
1060
+ if (snapshot === undefined) {
1061
+ try {
1062
+ process.kill(child.pid, terminateSignal);
1063
+ } catch (error) {
1064
+ if (!isMissingProcessError(error)) throw error;
889
1065
  }
890
- throw processTerminationError(gracefulSignal, forceTargets);
1066
+ return;
891
1067
  }
1068
+ const tree = processTree(snapshot, child.pid);
1069
+ const retained = new Map(tree.map((entry) => [entry.pid, entry]));
892
1070
 
893
- const forceSignaled = new Set<string>();
894
- signalProcesses(forceTargets, forceSignal, forceSignaled);
895
- const forcedSurvivors = await waitForProcessTreeExit(
1071
+ const terminated = new Set<string>();
1072
+ signalProcesses(tree, terminateSignal, terminated);
1073
+ const killTargets = await waitForProcessTreeExit(
896
1074
  retained,
897
- forceTargets,
898
- forceSignal,
899
- forceSignaled,
900
- forceTimeoutMs,
1075
+ tree,
1076
+ terminateSignal,
1077
+ terminated,
1078
+ terminateTimeout,
901
1079
  pollIntervalMs,
902
1080
  true,
903
1081
  );
904
- if (forcedSurvivors.length > 0) throw processTerminationError(forceSignal, forcedSurvivors);
905
- }
1082
+ if (killTargets.length === 0) return;
906
1083
 
907
- function processTerminationError(
908
- signal: NodeJS.Signals,
909
- processes: readonly ProcessSnapshotEntry[],
910
- ): Error {
911
- return new Error(
912
- `Processes did not terminate after ${signal}: ${processes.map((entry) => entry.pid).join(", ")}`,
1084
+ const killed = new Set<string>();
1085
+ signalProcesses(killTargets, DEFAULT_KILL_SIGNAL, killed);
1086
+ const survivors = await waitForProcessTreeExit(
1087
+ retained,
1088
+ killTargets,
1089
+ DEFAULT_KILL_SIGNAL,
1090
+ killed,
1091
+ killTimeout,
1092
+ pollIntervalMs,
1093
+ true,
913
1094
  );
1095
+ if (survivors.length === 0) return;
1096
+ logger.warn("process tree still running after kill timeout", {
1097
+ rootPid: child.pid,
1098
+ survivorPids: survivors.map((entry) => entry.pid),
1099
+ killSignal: DEFAULT_KILL_SIGNAL,
1100
+ killTimeout,
1101
+ });
914
1102
  }
915
1103
 
916
1104
  /**
@@ -921,7 +1109,7 @@ function processTerminationError(
921
1109
  * exit (and any line reads / stdin write), then resolves to the {@link ExecResult}
922
1110
  * or rejects (spawn error other than a missing executable, or `check` on non-zero
923
1111
  * exit). Existing `await spawn(...)` callers are unaffected. The handle half lets a
924
- * caller supervise the process (`.pid`, `.kill(signal)`, `exit` event) without a
1112
+ * caller supervise the process (`.pid`, `.shutdown()`, `exit` event) without a
925
1113
  * second spawn.
926
1114
  *
927
1115
  * @param command - Executable to run (resolved on `PATH` when `shell` is set on options)
@@ -933,7 +1121,22 @@ function processTerminationError(
933
1121
  */
934
1122
  export function spawn(...args: SpawnArgs<ExecOptions>): ChildProcessResult {
935
1123
  const { command, commandArgs, options = {} } = parseSpawnArgs(args);
936
- const { stdin, stdout, stderr, check, trim, ...spawnOpts } = options;
1124
+ rejectSignalOptions(options);
1125
+ const {
1126
+ stdin,
1127
+ stdout,
1128
+ stderr,
1129
+ forwardTerminationSignals = true,
1130
+ terminateTimeout,
1131
+ killTimeout,
1132
+ check,
1133
+ trim,
1134
+ ...spawnOpts
1135
+ } = options;
1136
+ const terminationOptions: ChildProcessTerminationOptions = {
1137
+ terminateTimeout,
1138
+ killTimeout,
1139
+ };
937
1140
  const stdoutLines: string[] = [];
938
1141
  const stderrLines: string[] = [];
939
1142
  const stdoutHandler = resolveStdio(stdout, stdoutLines);
@@ -942,8 +1145,13 @@ export function spawn(...args: SpawnArgs<ExecOptions>): ChildProcessResult {
942
1145
 
943
1146
  const proc = nodeSpawn(command, commandArgs, {
944
1147
  ...spawnOpts,
1148
+ detached: resolveDetached(spawnOpts.detached, forwardTerminationSignals),
945
1149
  stdio: [stdinMode, stdoutHandler.mode, stderrHandler.mode],
946
1150
  });
1151
+ const child = attachProcessControls(proc, terminationOptions);
1152
+ const unregisterSignalForwarding = forwardTerminationSignals
1153
+ ? registerParentSignalForwarding(child)
1154
+ : undefined;
947
1155
 
948
1156
  const stdinWrite = writeStdin(proc, stdin);
949
1157
 
@@ -952,38 +1160,61 @@ export function spawn(...args: SpawnArgs<ExecOptions>): ChildProcessResult {
952
1160
  queueLineReads(reads, proc.stderr, stderrHandler);
953
1161
 
954
1162
  const completion = (async (): Promise<ExecResult> => {
955
- let exitCode = 1;
956
- let commandNotFoundError: NodeJS.ErrnoException | undefined;
957
1163
  try {
958
- exitCode = await waitForExit(proc);
959
- await Promise.all([stdinWrite, ...reads]);
960
- } catch (err) {
961
- if (!isCommandNotFoundError(err)) {
962
- await Promise.allSettled([stdinWrite, ...reads]);
963
- throw err;
1164
+ let exitCode = 1;
1165
+ let commandNotFoundError: NodeJS.ErrnoException | undefined;
1166
+ try {
1167
+ exitCode = await waitForExit(proc);
1168
+ await Promise.all([stdinWrite, ...reads]);
1169
+ } catch (err) {
1170
+ if (!isCommandNotFoundError(err)) {
1171
+ await Promise.allSettled([stdinWrite, ...reads]);
1172
+ throw err;
1173
+ }
1174
+ proc.stdin?.destroy();
1175
+ proc.stdout?.destroy();
1176
+ proc.stderr?.destroy();
1177
+ await Promise.allSettled([stdinWrite]);
1178
+ for (const read of reads) void read.catch(() => undefined);
1179
+ commandNotFoundError = err;
1180
+ exitCode = COMMAND_NOT_FOUND_EXIT_CODE;
964
1181
  }
965
- proc.stdin?.destroy();
966
- proc.stdout?.destroy();
967
- proc.stderr?.destroy();
968
- await Promise.allSettled([stdinWrite]);
969
- for (const read of reads) void read.catch(() => undefined);
970
- commandNotFoundError = err;
971
- exitCode = COMMAND_NOT_FOUND_EXIT_CODE;
972
- }
973
1182
 
974
- const result = createExecResult(exitCode, stdoutLines, stderrLines, trim);
975
- if (check && result.exitCode !== 0) {
976
- const err = execError(command, commandArgs, result);
977
- if (commandNotFoundError) err.cause = commandNotFoundError;
978
- throw err;
1183
+ const result = createExecResult(exitCode, stdoutLines, stderrLines, trim);
1184
+ if (check && result.exitCode !== 0) {
1185
+ const err = execError(command, commandArgs, result);
1186
+ if (commandNotFoundError) err.cause = commandNotFoundError;
1187
+ throw err;
1188
+ }
1189
+ return result;
1190
+ } finally {
1191
+ unregisterSignalForwarding?.();
979
1192
  }
980
- return result;
981
1193
  })();
982
1194
 
983
1195
  // Attach the promise interface onto the live child so ONE object is both. The
984
1196
  // methods are bound to `completion` so `this` is the real promise; the child
985
1197
  // owns no conflicting `then`/`catch`/`finally`, so this is purely additive.
986
- return attachPromise(proc, completion);
1198
+ return attachPromise(child, completion);
1199
+ }
1200
+
1201
+ function attachProcessControls(
1202
+ child: ChildProcess,
1203
+ defaults: ChildProcessTerminationOptions,
1204
+ ): ChildProcessResult {
1205
+ const controlled = child as ChildProcessResult;
1206
+ let shutdownPromise: Promise<void> | undefined;
1207
+ const shutdown = (
1208
+ options: ChildProcessTerminationOptions,
1209
+ terminateSignal = DEFAULT_TERMINATE_SIGNAL,
1210
+ ): Promise<void> => {
1211
+ rejectSignalOptions(options);
1212
+ shutdownPromise ??= terminateProcessTree(child, { ...defaults, ...options }, terminateSignal);
1213
+ return shutdownPromise;
1214
+ };
1215
+ controlled.shutdown = (options = {}) => shutdown(options);
1216
+ parentSignalShutdowns.set(controlled, (signal) => shutdown({}, signal));
1217
+ return controlled;
987
1218
  }
988
1219
 
989
1220
  /**
@@ -1014,6 +1245,7 @@ function attachPromise<T extends object, R>(target: T, promise: Promise<R>): T &
1014
1245
  */
1015
1246
  export function spawnSync(...args: SpawnArgs<SyncExecOptions>): ExecResult {
1016
1247
  const { command, commandArgs, options = {} } = parseSpawnArgs(args);
1248
+ rejectSignalOptions(options);
1017
1249
  const { stdin, stdout, stderr, check, trim, ...spawnOpts } = options;
1018
1250
  const stdinMode: ExecStdio = isStdinPayload(stdin) ? "pipe" : (stdin ?? "inherit");
1019
1251
  const captureStdout = stdout === "capture";