@dbx-tools/core 0.9.120 → 0.9.122

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,151 @@ 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
+ }
243
+
244
+ interface SignalExitEmitter {
245
+ count?: unknown;
246
+ }
247
+
248
+ interface ProcessWithSignalExitEmitter extends NodeJS.Process {
249
+ __signal_exit_emitter__?: SignalExitEmitter;
250
+ }
251
+
252
+ type ParentSignalShutdown = (signal: NodeJS.Signals) => Promise<void>;
253
+
254
+ const parentSignalForwarderState = functionUtils.memoize((): ParentSignalForwarderState => ({
255
+ children: new Map(),
256
+ handlers: new Map(),
257
+ forwardingSignals: new Set(),
258
+ }));
259
+ const parentSignalShutdowns = new WeakMap<ChildProcessResult, ParentSignalShutdown>();
260
+
261
+ function registerParentSignalForwarding(child: ChildProcessResult): (() => void) | undefined {
262
+ const pid = child.pid;
263
+ if (pid === undefined) return undefined;
264
+ const state = parentSignalForwarderState();
265
+ installParentSignalHandlers(state);
266
+ state.children.set(pid, child);
267
+ return () => {
268
+ if (state.children.get(pid) === child) state.children.delete(pid);
269
+ };
270
+ }
271
+
272
+ function installParentSignalHandlers(state: ParentSignalForwarderState): void {
273
+ if (state.handlers.size > 0) return;
274
+ for (const signal of FORWARDED_PARENT_SIGNALS) {
275
+ const handler = () => {
276
+ try {
277
+ forwardParentSignal(state, signal);
278
+ } catch (error) {
279
+ reportParentSignalError(signal, undefined, error);
280
+ }
281
+ };
282
+ state.handlers.set(signal, handler);
283
+ process.prependListener(signal, handler);
284
+ }
285
+ }
286
+
287
+ function forwardParentSignal(state: ParentSignalForwarderState, signal: NodeJS.Signals): void {
288
+ if (state.forwardingSignals.has(signal)) return;
289
+ const shutdowns: Promise<void>[] = [];
290
+ for (const [pid, child] of state.children) {
291
+ if (child.exitCode !== null || child.signalCode !== null) {
292
+ state.children.delete(pid);
293
+ continue;
294
+ }
295
+ shutdowns.push(shutdownChildForParentSignal(child, pid, signal));
296
+ }
297
+
298
+ const handler = state.handlers.get(signal);
299
+ const completed = Promise.all(shutdowns).catch((error) => {
300
+ reportParentSignalError(signal, undefined, error);
301
+ });
302
+ if (!handler || process.listenerCount(signal) !== passiveSignalListenerCount() + 1) {
303
+ void completed;
304
+ return;
305
+ }
306
+ state.forwardingSignals.add(signal);
307
+ void completed
308
+ .then(() => {
309
+ process.off(signal, handler);
310
+ state.handlers.delete(signal);
311
+ state.forwardingSignals.delete(signal);
312
+ try {
313
+ process.kill(process.pid, signal);
314
+ } catch (error) {
315
+ reportParentSignalError(signal, process.pid, error);
316
+ }
317
+ })
318
+ .catch((error) => {
319
+ reportParentSignalError(signal, undefined, error);
320
+ });
321
+ }
322
+
323
+ function shutdownChildForParentSignal(
324
+ child: ChildProcessResult,
325
+ pid: number,
326
+ signal: NodeJS.Signals,
327
+ ): Promise<void> {
328
+ try {
329
+ const shutdown = parentSignalShutdowns.get(child);
330
+ return (shutdown ? shutdown(signal) : child.shutdown()).catch((error) => {
331
+ reportParentSignalError(signal, pid, error);
332
+ });
333
+ } catch (error) {
334
+ reportParentSignalError(signal, pid, error);
335
+ return Promise.resolve();
336
+ }
337
+ }
338
+
339
+ function reportParentSignalError(
340
+ signal: NodeJS.Signals,
341
+ pid: number | undefined,
342
+ error: unknown,
343
+ ): void {
344
+ if (isExpectedProcessShutdownError(error)) return;
345
+ try {
346
+ logger.warn("failed to forward parent signal", {
347
+ signal,
348
+ ...(pid === undefined ? {} : { pid }),
349
+ error,
350
+ });
351
+ } catch {
352
+ // Signal forwarding must never prevent other process listeners from running.
353
+ }
354
+ }
355
+
356
+ function isExpectedProcessShutdownError(error: unknown): boolean {
357
+ if (isMissingProcessError(error)) return true;
358
+ return error instanceof AggregateError && error.errors.every(isExpectedProcessShutdownError);
359
+ }
360
+
361
+ function passiveSignalListenerCount(): number {
362
+ const legacyEmitter = (process as ProcessWithSignalExitEmitter).__signal_exit_emitter__;
363
+ const modernEmitter = (globalThis as typeof globalThis & Record<symbol, unknown>)[
364
+ Symbol.for("signal-exit emitter")
365
+ ] as SignalExitEmitter | undefined;
366
+ return signalExitEmitterCount(legacyEmitter) + signalExitEmitterCount(modernEmitter);
367
+ }
368
+
369
+ function signalExitEmitterCount(emitter: SignalExitEmitter | undefined): number {
370
+ return typeof emitter?.count === "number" ? emitter.count : 0;
371
+ }
372
+
373
+ function resolveDetached(
374
+ detached: boolean | undefined,
375
+ forwardTerminationSignals: boolean,
376
+ ): boolean | undefined {
377
+ if (detached !== undefined || forwardTerminationSignals || process.platform === "win32") {
378
+ return detached;
379
+ }
380
+ return true;
381
+ }
382
+
226
383
  function parseSpawnArgs<T extends SpawnOptions>(input: SpawnArgs<T>): ParsedSpawnArgs<T> {
227
384
  let [value, ...values] = input;
228
385
 
@@ -321,12 +478,13 @@ function timeoutValue(value: number, name: string): number {
321
478
  return value;
322
479
  }
323
480
 
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;
481
+ /** Reject caller-selected signals so platform shutdown policy stays internal. */
482
+ function rejectSignalOptions(options: object): void {
483
+ const unsupported = options as { terminateSignal?: unknown; killSignal?: unknown };
484
+ if (unsupported.terminateSignal === undefined && unsupported.killSignal === undefined) return;
485
+ throw new TypeError(
486
+ "Process signals are selected internally; configure terminateTimeout and killTimeout instead",
487
+ );
330
488
  }
331
489
 
332
490
  /** Parse the common `pid parentPid identity` process-snapshot format. */
@@ -389,7 +547,8 @@ async function linuxProcessSnapshot(): Promise<ProcessSnapshotEntry[]> {
389
547
  *
390
548
  * Linux reads exact start ticks from `/proc`. Windows uses the built-in
391
549
  * PowerShell CIM provider. macOS and other POSIX platforms combine `ps`
392
- * process start time and executable name.
550
+ * process start time and executable name. The helper process is detached so a
551
+ * terminal SIGINT to this process group cannot abort the snapshot.
393
552
  */
394
553
  async function processSnapshot(): Promise<ProcessSnapshotEntry[]> {
395
554
  if (process.platform === "linux") return linuxProcessSnapshot();
@@ -402,6 +561,7 @@ async function processSnapshot(): Promise<ProcessSnapshotEntry[]> {
402
561
  const proc = nodeSpawn(command, args, {
403
562
  stdio: ["ignore", "pipe", "pipe"],
404
563
  windowsHide: true,
564
+ detached: true,
405
565
  });
406
566
  const stdout = text(proc.stdout!);
407
567
  const stderr = text(proc.stderr!);
@@ -416,6 +576,16 @@ async function processSnapshot(): Promise<ProcessSnapshotEntry[]> {
416
576
  return parseProcessSnapshot(linesFromCapturedOutput(stdoutText));
417
577
  }
418
578
 
579
+ /** Read a process snapshot, or `undefined` when the process table cannot be listed. */
580
+ async function tryProcessSnapshot(): Promise<ProcessSnapshotEntry[] | undefined> {
581
+ try {
582
+ return await processSnapshot();
583
+ } catch (error) {
584
+ logger.debug("process snapshot failed", { error: errorUtils.toError(error) });
585
+ return undefined;
586
+ }
587
+ }
588
+
419
589
  /** Group a process snapshot by parent PID. */
420
590
  function processChildren(
421
591
  snapshot: readonly ProcessSnapshotEntry[],
@@ -551,7 +721,12 @@ async function waitForProcessTreeExit(
551
721
  const remainingMs = deadline - Date.now();
552
722
  if (remainingMs > 0) await delay(Math.min(pollIntervalMs, remainingMs));
553
723
 
554
- const refreshed = refreshProcessTree(retained, await processSnapshot());
724
+ const snapshot = await tryProcessSnapshot();
725
+ if (snapshot === undefined) {
726
+ if (Date.now() >= deadline) break;
727
+ continue;
728
+ }
729
+ const refreshed = refreshProcessTree(retained, snapshot);
555
730
  if (signalDiscovered && refreshed.discovered.length > 0) {
556
731
  signalProcesses(refreshed.discovered, signal, signaled);
557
732
  }
@@ -827,90 +1002,75 @@ function linesFromCapturedOutput(output: string): string[] {
827
1002
  * The initial tree is retained before signaling so descendants remain
828
1003
  * targetable after their parent exits or they are reparented. Polling refreshes
829
1004
  * 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.
1005
+ * active shutdown signal once. macOS and other POSIX platforms use `ps`;
1006
+ * Windows uses the built-in PowerShell CIM process provider.
833
1007
  *
834
1008
  * Windows does not implement POSIX signal semantics. Node terminates processes
835
1009
  * unconditionally for supported signals there, including `SIGTERM`.
836
1010
  *
837
1011
  * @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
1012
+ * @param options - Graceful and forced shutdown timeout policy
1013
+ * @param terminateSignal - Internal signal selected from the parent event or platform default
841
1014
  */
842
- export async function kill(child: ChildProcess, options: KillOptions = {}): Promise<void> {
1015
+ async function terminateProcessTree(
1016
+ child: ChildProcess,
1017
+ options: ChildProcessTerminationOptions = {},
1018
+ terminateSignal: NodeJS.Signals = DEFAULT_TERMINATE_SIGNAL,
1019
+ ): Promise<void> {
843
1020
  if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) {
844
1021
  return;
845
1022
  }
846
1023
 
847
- const gracefulTimeoutMs = timeoutValue(
848
- options.gracefulTimeoutMs ?? DEFAULT_GRACEFUL_TIMEOUT_MS,
849
- "gracefulTimeoutMs",
850
- );
851
- const forceTimeoutMs = timeoutValue(
852
- options.forceTimeoutMs ?? DEFAULT_FORCE_TIMEOUT_MS,
853
- "forceTimeoutMs",
1024
+ const terminateTimeout = timeoutValue(
1025
+ options.terminateTimeout ?? DEFAULT_TERMINATE_TIMEOUT_MS,
1026
+ "terminateTimeout",
854
1027
  );
855
- const pollIntervalMs = pollIntervalValue(options.pollIntervalMs ?? DEFAULT_KILL_POLL_INTERVAL_MS);
856
- const snapshot = await processSnapshot();
1028
+ const killTimeout = timeoutValue(options.killTimeout ?? DEFAULT_KILL_TIMEOUT_MS, "killTimeout");
1029
+ const pollIntervalMs = DEFAULT_KILL_POLL_INTERVAL_MS;
1030
+ const snapshot = await tryProcessSnapshot();
857
1031
  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");
1032
+ if (snapshot === undefined) {
1033
+ try {
1034
+ process.kill(child.pid, terminateSignal);
1035
+ } catch (error) {
1036
+ if (!isMissingProcessError(error)) throw error;
889
1037
  }
890
- throw processTerminationError(gracefulSignal, forceTargets);
1038
+ return;
891
1039
  }
1040
+ const tree = processTree(snapshot, child.pid);
1041
+ const retained = new Map(tree.map((entry) => [entry.pid, entry]));
892
1042
 
893
- const forceSignaled = new Set<string>();
894
- signalProcesses(forceTargets, forceSignal, forceSignaled);
895
- const forcedSurvivors = await waitForProcessTreeExit(
1043
+ const terminated = new Set<string>();
1044
+ signalProcesses(tree, terminateSignal, terminated);
1045
+ const killTargets = await waitForProcessTreeExit(
896
1046
  retained,
897
- forceTargets,
898
- forceSignal,
899
- forceSignaled,
900
- forceTimeoutMs,
1047
+ tree,
1048
+ terminateSignal,
1049
+ terminated,
1050
+ terminateTimeout,
901
1051
  pollIntervalMs,
902
1052
  true,
903
1053
  );
904
- if (forcedSurvivors.length > 0) throw processTerminationError(forceSignal, forcedSurvivors);
905
- }
1054
+ if (killTargets.length === 0) return;
906
1055
 
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(", ")}`,
1056
+ const killed = new Set<string>();
1057
+ signalProcesses(killTargets, DEFAULT_KILL_SIGNAL, killed);
1058
+ const survivors = await waitForProcessTreeExit(
1059
+ retained,
1060
+ killTargets,
1061
+ DEFAULT_KILL_SIGNAL,
1062
+ killed,
1063
+ killTimeout,
1064
+ pollIntervalMs,
1065
+ true,
913
1066
  );
1067
+ if (survivors.length === 0) return;
1068
+ logger.warn("process tree still running after kill timeout", {
1069
+ rootPid: child.pid,
1070
+ survivorPids: survivors.map((entry) => entry.pid),
1071
+ killSignal: DEFAULT_KILL_SIGNAL,
1072
+ killTimeout,
1073
+ });
914
1074
  }
915
1075
 
916
1076
  /**
@@ -921,7 +1081,7 @@ function processTerminationError(
921
1081
  * exit (and any line reads / stdin write), then resolves to the {@link ExecResult}
922
1082
  * or rejects (spawn error other than a missing executable, or `check` on non-zero
923
1083
  * exit). Existing `await spawn(...)` callers are unaffected. The handle half lets a
924
- * caller supervise the process (`.pid`, `.kill(signal)`, `exit` event) without a
1084
+ * caller supervise the process (`.pid`, `.shutdown()`, `exit` event) without a
925
1085
  * second spawn.
926
1086
  *
927
1087
  * @param command - Executable to run (resolved on `PATH` when `shell` is set on options)
@@ -933,7 +1093,22 @@ function processTerminationError(
933
1093
  */
934
1094
  export function spawn(...args: SpawnArgs<ExecOptions>): ChildProcessResult {
935
1095
  const { command, commandArgs, options = {} } = parseSpawnArgs(args);
936
- const { stdin, stdout, stderr, check, trim, ...spawnOpts } = options;
1096
+ rejectSignalOptions(options);
1097
+ const {
1098
+ stdin,
1099
+ stdout,
1100
+ stderr,
1101
+ forwardTerminationSignals = true,
1102
+ terminateTimeout,
1103
+ killTimeout,
1104
+ check,
1105
+ trim,
1106
+ ...spawnOpts
1107
+ } = options;
1108
+ const terminationOptions: ChildProcessTerminationOptions = {
1109
+ terminateTimeout,
1110
+ killTimeout,
1111
+ };
937
1112
  const stdoutLines: string[] = [];
938
1113
  const stderrLines: string[] = [];
939
1114
  const stdoutHandler = resolveStdio(stdout, stdoutLines);
@@ -942,8 +1117,13 @@ export function spawn(...args: SpawnArgs<ExecOptions>): ChildProcessResult {
942
1117
 
943
1118
  const proc = nodeSpawn(command, commandArgs, {
944
1119
  ...spawnOpts,
1120
+ detached: resolveDetached(spawnOpts.detached, forwardTerminationSignals),
945
1121
  stdio: [stdinMode, stdoutHandler.mode, stderrHandler.mode],
946
1122
  });
1123
+ const child = attachProcessControls(proc, terminationOptions);
1124
+ const unregisterSignalForwarding = forwardTerminationSignals
1125
+ ? registerParentSignalForwarding(child)
1126
+ : undefined;
947
1127
 
948
1128
  const stdinWrite = writeStdin(proc, stdin);
949
1129
 
@@ -952,38 +1132,61 @@ export function spawn(...args: SpawnArgs<ExecOptions>): ChildProcessResult {
952
1132
  queueLineReads(reads, proc.stderr, stderrHandler);
953
1133
 
954
1134
  const completion = (async (): Promise<ExecResult> => {
955
- let exitCode = 1;
956
- let commandNotFoundError: NodeJS.ErrnoException | undefined;
957
1135
  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;
1136
+ let exitCode = 1;
1137
+ let commandNotFoundError: NodeJS.ErrnoException | undefined;
1138
+ try {
1139
+ exitCode = await waitForExit(proc);
1140
+ await Promise.all([stdinWrite, ...reads]);
1141
+ } catch (err) {
1142
+ if (!isCommandNotFoundError(err)) {
1143
+ await Promise.allSettled([stdinWrite, ...reads]);
1144
+ throw err;
1145
+ }
1146
+ proc.stdin?.destroy();
1147
+ proc.stdout?.destroy();
1148
+ proc.stderr?.destroy();
1149
+ await Promise.allSettled([stdinWrite]);
1150
+ for (const read of reads) void read.catch(() => undefined);
1151
+ commandNotFoundError = err;
1152
+ exitCode = COMMAND_NOT_FOUND_EXIT_CODE;
964
1153
  }
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
1154
 
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;
1155
+ const result = createExecResult(exitCode, stdoutLines, stderrLines, trim);
1156
+ if (check && result.exitCode !== 0) {
1157
+ const err = execError(command, commandArgs, result);
1158
+ if (commandNotFoundError) err.cause = commandNotFoundError;
1159
+ throw err;
1160
+ }
1161
+ return result;
1162
+ } finally {
1163
+ unregisterSignalForwarding?.();
979
1164
  }
980
- return result;
981
1165
  })();
982
1166
 
983
1167
  // Attach the promise interface onto the live child so ONE object is both. The
984
1168
  // methods are bound to `completion` so `this` is the real promise; the child
985
1169
  // owns no conflicting `then`/`catch`/`finally`, so this is purely additive.
986
- return attachPromise(proc, completion);
1170
+ return attachPromise(child, completion);
1171
+ }
1172
+
1173
+ function attachProcessControls(
1174
+ child: ChildProcess,
1175
+ defaults: ChildProcessTerminationOptions,
1176
+ ): ChildProcessResult {
1177
+ const controlled = child as ChildProcessResult;
1178
+ let shutdownPromise: Promise<void> | undefined;
1179
+ const shutdown = (
1180
+ options: ChildProcessTerminationOptions,
1181
+ terminateSignal = DEFAULT_TERMINATE_SIGNAL,
1182
+ ): Promise<void> => {
1183
+ rejectSignalOptions(options);
1184
+ shutdownPromise ??= terminateProcessTree(child, { ...defaults, ...options }, terminateSignal);
1185
+ return shutdownPromise;
1186
+ };
1187
+ controlled.shutdown = (options = {}) => shutdown(options);
1188
+ parentSignalShutdowns.set(controlled, (signal) => shutdown({}, signal));
1189
+ return controlled;
987
1190
  }
988
1191
 
989
1192
  /**
@@ -1014,6 +1217,7 @@ function attachPromise<T extends object, R>(target: T, promise: Promise<R>): T &
1014
1217
  */
1015
1218
  export function spawnSync(...args: SpawnArgs<SyncExecOptions>): ExecResult {
1016
1219
  const { command, commandArgs, options = {} } = parseSpawnArgs(args);
1220
+ rejectSignalOptions(options);
1017
1221
  const { stdin, stdout, stderr, check, trim, ...spawnOpts } = options;
1018
1222
  const stdinMode: ExecStdio = isStdinPayload(stdin) ? "pipe" : (stdin ?? "inherit");
1019
1223
  const captureStdout = stdout === "capture";