@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/README.md +14 -6
- package/index.ts +2 -2
- package/lib/index.d.ts +2 -2
- package/lib/index.js +2 -2
- package/lib/src/config-utils.d.ts +2 -10
- package/lib/src/config-utils.js +7 -16
- package/lib/src/exec.d.ts +22 -41
- package/lib/src/exec.js +239 -79
- package/package.json +2 -2
- package/src/config-utils.ts +8 -18
- package/src/exec.ts +336 -132
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
|
|
62
|
-
const
|
|
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 `.
|
|
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 = () =>
|
|
142
|
+
* const shutdown = () => proc.shutdown();
|
|
134
143
|
* const { exitCode } = await proc;
|
|
135
144
|
*/
|
|
136
|
-
export type ChildProcessResult = ChildProcess &
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
/**
|
|
155
|
-
export interface
|
|
156
|
-
/**
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
|
|
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
|
-
/**
|
|
325
|
-
function
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
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.
|
|
831
|
-
*
|
|
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
|
|
839
|
-
* @
|
|
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
|
-
|
|
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
|
|
848
|
-
options.
|
|
849
|
-
"
|
|
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
|
|
856
|
-
const
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
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
|
|
894
|
-
signalProcesses(
|
|
895
|
-
const
|
|
1043
|
+
const terminated = new Set<string>();
|
|
1044
|
+
signalProcesses(tree, terminateSignal, terminated);
|
|
1045
|
+
const killTargets = await waitForProcessTreeExit(
|
|
896
1046
|
retained,
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1047
|
+
tree,
|
|
1048
|
+
terminateSignal,
|
|
1049
|
+
terminated,
|
|
1050
|
+
terminateTimeout,
|
|
901
1051
|
pollIntervalMs,
|
|
902
1052
|
true,
|
|
903
1053
|
);
|
|
904
|
-
if (
|
|
905
|
-
}
|
|
1054
|
+
if (killTargets.length === 0) return;
|
|
906
1055
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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`, `.
|
|
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
|
-
|
|
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 =
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
await Promise.
|
|
963
|
-
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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(
|
|
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";
|