@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/README.md +17 -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 +29 -39
- package/lib/src/exec.js +264 -79
- package/package.json +2 -2
- package/src/config-utils.ts +8 -18
- package/src/exec.ts +364 -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,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
|
-
/**
|
|
325
|
-
function
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
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.
|
|
831
|
-
*
|
|
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
|
|
839
|
-
* @
|
|
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
|
-
|
|
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
|
|
848
|
-
options.
|
|
849
|
-
"
|
|
1052
|
+
const terminateTimeout = timeoutValue(
|
|
1053
|
+
options.terminateTimeout ?? DEFAULT_TERMINATE_TIMEOUT_MS,
|
|
1054
|
+
"terminateTimeout",
|
|
850
1055
|
);
|
|
851
|
-
const
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
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");
|
|
1060
|
+
if (snapshot === undefined) {
|
|
1061
|
+
try {
|
|
1062
|
+
process.kill(child.pid, terminateSignal);
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
if (!isMissingProcessError(error)) throw error;
|
|
889
1065
|
}
|
|
890
|
-
|
|
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
|
|
894
|
-
signalProcesses(
|
|
895
|
-
const
|
|
1071
|
+
const terminated = new Set<string>();
|
|
1072
|
+
signalProcesses(tree, terminateSignal, terminated);
|
|
1073
|
+
const killTargets = await waitForProcessTreeExit(
|
|
896
1074
|
retained,
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1075
|
+
tree,
|
|
1076
|
+
terminateSignal,
|
|
1077
|
+
terminated,
|
|
1078
|
+
terminateTimeout,
|
|
901
1079
|
pollIntervalMs,
|
|
902
1080
|
true,
|
|
903
1081
|
);
|
|
904
|
-
if (
|
|
905
|
-
}
|
|
1082
|
+
if (killTargets.length === 0) return;
|
|
906
1083
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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`, `.
|
|
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
|
-
|
|
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 =
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
await Promise.
|
|
963
|
-
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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(
|
|
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";
|