@dbx-tools/core 0.9.69 → 0.9.71
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/index.ts +3 -3
- package/lib/index.d.ts +3 -3
- package/lib/index.js +2 -2
- package/lib/src/exec.d.ts +38 -6
- package/lib/src/exec.js +287 -2
- package/lib/src/project-utils.d.ts +41 -1
- package/lib/src/project-utils.js +149 -27
- package/package.json +2 -2
- package/src/exec.ts +378 -6
- package/src/project-utils.ts +225 -24
package/src/exec.ts
CHANGED
|
@@ -49,13 +49,21 @@ import {
|
|
|
49
49
|
spawn as nodeSpawn,
|
|
50
50
|
spawnSync as nodeSpawnSync,
|
|
51
51
|
} from "node:child_process";
|
|
52
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
52
53
|
import * as readline from "node:readline";
|
|
53
54
|
import { Readable } from "node:stream";
|
|
55
|
+
import { text } from "node:stream/consumers";
|
|
54
56
|
import { finished } from "node:stream/promises";
|
|
55
57
|
|
|
56
58
|
/** Shell-compatible exit code returned when an executable cannot be found. */
|
|
57
59
|
export const COMMAND_NOT_FOUND_EXIT_CODE = 127;
|
|
58
60
|
|
|
61
|
+
const DEFAULT_GRACEFUL_TIMEOUT_MS = 10_000;
|
|
62
|
+
const DEFAULT_FORCE_TIMEOUT_MS = 4_000;
|
|
63
|
+
const DEFAULT_KILL_POLL_INTERVAL_MS = 100;
|
|
64
|
+
const WINDOWS_PROCESS_SNAPSHOT_SCRIPT =
|
|
65
|
+
"Get-CimInstance Win32_Process | ForEach-Object { '{0} {1} {2}' -f $_.ProcessId, $_.ParentProcessId, $_.CreationDate.ToFileTimeUtc() }";
|
|
66
|
+
|
|
59
67
|
/** Stdio mode for a subprocess fd. */
|
|
60
68
|
export type ExecStdio = "inherit" | "pipe" | "ignore";
|
|
61
69
|
|
|
@@ -114,17 +122,16 @@ export type ExecResult = {
|
|
|
114
122
|
* This is additive - the object still satisfies `Promise<ExecResult>`, so every
|
|
115
123
|
* existing `await spawn(...)` / `spawn(...).then(...)` caller is unchanged. What
|
|
116
124
|
* is new is that the same value is the live child: callers that want to supervise
|
|
117
|
-
* it
|
|
118
|
-
* spawn.
|
|
119
|
-
* supervises both a bare `ChildProcess` (e.g. portr) and a `spawn()` result.
|
|
125
|
+
* it can read `.pid`, call `.kill(signal)`, and listen for `exit` without a
|
|
126
|
+
* second spawn.
|
|
120
127
|
*
|
|
121
128
|
* @example Await it (unchanged)
|
|
122
129
|
* const { stdout } = await spawn("git", ["rev-parse", "HEAD"], { stdout: "capture" });
|
|
123
130
|
*
|
|
124
131
|
* @example Supervise it
|
|
125
132
|
* const proc = spawn("bun", ["src/server.ts"]);
|
|
126
|
-
*
|
|
127
|
-
* const { exitCode } = await proc;
|
|
133
|
+
* const shutdown = () => kill(proc);
|
|
134
|
+
* const { exitCode } = await proc;
|
|
128
135
|
*/
|
|
129
136
|
export type ChildProcessResult = ChildProcess & Promise<ExecResult>;
|
|
130
137
|
|
|
@@ -144,6 +151,20 @@ export type ExecOptions = Omit<SpawnOptions, "stdio"> & {
|
|
|
144
151
|
trim?: boolean;
|
|
145
152
|
};
|
|
146
153
|
|
|
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
|
+
/** Time to wait for graceful shutdown. Defaults to 10 seconds. */
|
|
159
|
+
gracefulTimeoutMs?: number;
|
|
160
|
+
/** Signal sent to survivors. `false` disables forced termination. Defaults to `SIGKILL`. */
|
|
161
|
+
forceSignal?: NodeJS.Signals | false;
|
|
162
|
+
/** Time to poll and force newly discovered descendants. Defaults to 4 seconds. */
|
|
163
|
+
forceTimeoutMs?: number;
|
|
164
|
+
/** Interval used to refresh and poll the process tree. Defaults to 100 milliseconds. */
|
|
165
|
+
pollIntervalMs?: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
147
168
|
/** Stdio mode for {@link spawnSync} (no per-line callbacks). */
|
|
148
169
|
export type SyncExecStdio = ExecStdio | "capture";
|
|
149
170
|
|
|
@@ -187,6 +208,19 @@ interface ParsedSpawnArgs<T extends SpawnOptions> {
|
|
|
187
208
|
options?: T;
|
|
188
209
|
}
|
|
189
210
|
|
|
211
|
+
/** One operating-system process captured before tree shutdown begins. */
|
|
212
|
+
interface ProcessSnapshotEntry {
|
|
213
|
+
pid: number;
|
|
214
|
+
parentPid: number;
|
|
215
|
+
identity: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Current retained processes plus descendants first observed by one refresh. */
|
|
219
|
+
interface RefreshedProcessTree {
|
|
220
|
+
processes: ProcessSnapshotEntry[];
|
|
221
|
+
discovered: ProcessSnapshotEntry[];
|
|
222
|
+
}
|
|
223
|
+
|
|
190
224
|
function parseSpawnArgs<T extends SpawnOptions>(input: SpawnArgs<T>): ParsedSpawnArgs<T> {
|
|
191
225
|
let [value, ...values] = input;
|
|
192
226
|
const [command, ...commandArgs] = shlex(value!);
|
|
@@ -271,6 +305,258 @@ function isCommandNotFoundError(error: unknown): error is NodeJS.ErrnoException
|
|
|
271
305
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
272
306
|
}
|
|
273
307
|
|
|
308
|
+
/** Return whether a process operation failed because the PID no longer exists. */
|
|
309
|
+
function isMissingProcessError(error: unknown): error is NodeJS.ErrnoException {
|
|
310
|
+
return error instanceof Error && "code" in error && error.code === "ESRCH";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Validate a finite non-negative timeout. */
|
|
314
|
+
function timeoutValue(value: number, name: string): number {
|
|
315
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
316
|
+
throw new RangeError(`${name} must be a finite non-negative number`);
|
|
317
|
+
}
|
|
318
|
+
return value;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Validate a finite positive polling interval. */
|
|
322
|
+
function pollIntervalValue(value: number): number {
|
|
323
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
324
|
+
throw new RangeError("pollIntervalMs must be a finite positive number");
|
|
325
|
+
}
|
|
326
|
+
return value;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Parse the common `pid parentPid identity` process-snapshot format. */
|
|
330
|
+
function parseProcessSnapshot(lines: readonly string[]): ProcessSnapshotEntry[] {
|
|
331
|
+
const snapshot: ProcessSnapshotEntry[] = [];
|
|
332
|
+
for (const line of lines) {
|
|
333
|
+
const match = /^\s*(\d+)\s+(\d+)(?:\s+(.+?))?\s*$/.exec(line);
|
|
334
|
+
const identity = match?.[3]?.trim();
|
|
335
|
+
if (!match || !identity) continue;
|
|
336
|
+
snapshot.push({
|
|
337
|
+
pid: Number.parseInt(match[1]!, 10),
|
|
338
|
+
parentPid: Number.parseInt(match[2]!, 10),
|
|
339
|
+
identity,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return snapshot;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Return whether a `/proc` record disappeared or is inaccessible during a snapshot. */
|
|
346
|
+
function isUnavailableProcRecord(error: unknown): error is NodeJS.ErrnoException {
|
|
347
|
+
if (!(error instanceof Error) || !("code" in error)) return false;
|
|
348
|
+
return error.code === "ENOENT" || error.code === "EACCES" || error.code === "EPERM";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Parse Linux `/proc/<pid>/stat` parent PID and exact process-start ticks. */
|
|
352
|
+
function parseLinuxProcessStat(pid: number, stat: string): ProcessSnapshotEntry | undefined {
|
|
353
|
+
const commandEnd = stat.lastIndexOf(")");
|
|
354
|
+
if (commandEnd < 0) return undefined;
|
|
355
|
+
const fields = stat
|
|
356
|
+
.slice(commandEnd + 1)
|
|
357
|
+
.trim()
|
|
358
|
+
.split(/\s+/);
|
|
359
|
+
const parentPid = Number.parseInt(fields[1]!, 10);
|
|
360
|
+
const startTicks = fields[19];
|
|
361
|
+
if (!Number.isSafeInteger(parentPid) || !startTicks) return undefined;
|
|
362
|
+
return { pid, parentPid, identity: startTicks };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Capture Linux process identities from `/proc` without second-level truncation. */
|
|
366
|
+
async function linuxProcessSnapshot(): Promise<ProcessSnapshotEntry[]> {
|
|
367
|
+
const directories = await readdir("/proc", { withFileTypes: true });
|
|
368
|
+
const entries = await Promise.all(
|
|
369
|
+
directories
|
|
370
|
+
.filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name))
|
|
371
|
+
.map(async (entry): Promise<ProcessSnapshotEntry | undefined> => {
|
|
372
|
+
const pid = Number.parseInt(entry.name, 10);
|
|
373
|
+
try {
|
|
374
|
+
return parseLinuxProcessStat(pid, await readFile(`/proc/${entry.name}/stat`, "utf8"));
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (isUnavailableProcRecord(error)) return undefined;
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
}),
|
|
380
|
+
);
|
|
381
|
+
return entries.filter((entry): entry is ProcessSnapshotEntry => entry !== undefined);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Capture the current PID, parent PID, and process-start identity table.
|
|
386
|
+
*
|
|
387
|
+
* Linux reads exact start ticks from `/proc`. Windows uses the built-in
|
|
388
|
+
* PowerShell CIM provider. macOS and other POSIX platforms combine `ps`
|
|
389
|
+
* process start time and executable name.
|
|
390
|
+
*/
|
|
391
|
+
async function processSnapshot(): Promise<ProcessSnapshotEntry[]> {
|
|
392
|
+
if (process.platform === "linux") return linuxProcessSnapshot();
|
|
393
|
+
|
|
394
|
+
const command = process.platform === "win32" ? "powershell.exe" : "ps";
|
|
395
|
+
const args =
|
|
396
|
+
process.platform === "win32"
|
|
397
|
+
? ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_PROCESS_SNAPSHOT_SCRIPT]
|
|
398
|
+
: ["-A", "-o", "pid=", "-o", "ppid=", "-o", "lstart=", "-o", "comm="];
|
|
399
|
+
const proc = nodeSpawn(command, args, {
|
|
400
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
401
|
+
windowsHide: true,
|
|
402
|
+
});
|
|
403
|
+
const stdout = text(proc.stdout!);
|
|
404
|
+
const stderr = text(proc.stderr!);
|
|
405
|
+
const [exitCode, stdoutText, stderrText] = await Promise.all([waitForExit(proc), stdout, stderr]);
|
|
406
|
+
if (exitCode !== 0) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`${commandLabel(command, args)} failed (exit ${exitCode})${
|
|
409
|
+
stderrText ? `: ${stderrText.trim()}` : ""
|
|
410
|
+
}`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
return parseProcessSnapshot(linesFromCapturedOutput(stdoutText));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Group a process snapshot by parent PID. */
|
|
417
|
+
function processChildren(
|
|
418
|
+
snapshot: readonly ProcessSnapshotEntry[],
|
|
419
|
+
): Map<number, ProcessSnapshotEntry[]> {
|
|
420
|
+
const children = new Map<number, ProcessSnapshotEntry[]>();
|
|
421
|
+
for (const entry of snapshot) {
|
|
422
|
+
const siblings = children.get(entry.parentPid) ?? [];
|
|
423
|
+
siblings.push(entry);
|
|
424
|
+
children.set(entry.parentPid, siblings);
|
|
425
|
+
}
|
|
426
|
+
return children;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Select one snapshotted process tree in descendant-first signal order. */
|
|
430
|
+
function processTree(
|
|
431
|
+
snapshot: readonly ProcessSnapshotEntry[],
|
|
432
|
+
rootPid: number,
|
|
433
|
+
): ProcessSnapshotEntry[] {
|
|
434
|
+
const root = snapshot.find((entry) => entry.pid === rootPid);
|
|
435
|
+
if (!root) throw new Error(`Unable to identify child process ${rootPid}`);
|
|
436
|
+
|
|
437
|
+
const children = processChildren(snapshot);
|
|
438
|
+
const tree: ProcessSnapshotEntry[] = [];
|
|
439
|
+
const visited = new Set<number>([rootPid]);
|
|
440
|
+
const appendDescendants = (parentPid: number): void => {
|
|
441
|
+
for (const child of children.get(parentPid) ?? []) {
|
|
442
|
+
if (visited.has(child.pid)) continue;
|
|
443
|
+
visited.add(child.pid);
|
|
444
|
+
appendDescendants(child.pid);
|
|
445
|
+
tree.push(child);
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
appendDescendants(rootPid);
|
|
449
|
+
tree.push(root);
|
|
450
|
+
return tree;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Return whether two snapshots identify the same process lifetime. */
|
|
454
|
+
function sameProcess(previous: ProcessSnapshotEntry, current: ProcessSnapshotEntry): boolean {
|
|
455
|
+
return previous.identity === current.identity;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Refresh retained process identities and merge descendants created after the
|
|
460
|
+
* initial snapshot.
|
|
461
|
+
*/
|
|
462
|
+
function refreshProcessTree(
|
|
463
|
+
retained: Map<number, ProcessSnapshotEntry>,
|
|
464
|
+
snapshot: readonly ProcessSnapshotEntry[],
|
|
465
|
+
): RefreshedProcessTree {
|
|
466
|
+
const currentByPid = new Map(snapshot.map((entry) => [entry.pid, entry]));
|
|
467
|
+
const children = processChildren(snapshot);
|
|
468
|
+
const processes = new Map<number, ProcessSnapshotEntry>();
|
|
469
|
+
const discovered: ProcessSnapshotEntry[] = [];
|
|
470
|
+
const queue: ProcessSnapshotEntry[] = [];
|
|
471
|
+
|
|
472
|
+
for (const previous of retained.values()) {
|
|
473
|
+
const current = currentByPid.get(previous.pid);
|
|
474
|
+
if (!current || !sameProcess(previous, current)) continue;
|
|
475
|
+
retained.set(current.pid, current);
|
|
476
|
+
processes.set(current.pid, current);
|
|
477
|
+
queue.push(current);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const visited = new Set(processes.keys());
|
|
481
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
482
|
+
const parent = queue[index]!;
|
|
483
|
+
for (const child of children.get(parent.pid) ?? []) {
|
|
484
|
+
const previous = retained.get(child.pid);
|
|
485
|
+
if (!previous || !sameProcess(previous, child)) {
|
|
486
|
+
retained.set(child.pid, child);
|
|
487
|
+
discovered.push(child);
|
|
488
|
+
}
|
|
489
|
+
processes.set(child.pid, child);
|
|
490
|
+
if (visited.has(child.pid)) continue;
|
|
491
|
+
visited.add(child.pid);
|
|
492
|
+
queue.push(child);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return { processes: [...processes.values()], discovered };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Build the per-lifetime key used to prevent repeated signals after PID reuse. */
|
|
500
|
+
function processIdentity(entry: ProcessSnapshotEntry): string {
|
|
501
|
+
return `${entry.pid}:${entry.identity}`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Signal each process identity once, ignoring PIDs that already exited. */
|
|
505
|
+
function signalProcesses(
|
|
506
|
+
processes: readonly ProcessSnapshotEntry[],
|
|
507
|
+
signal: NodeJS.Signals,
|
|
508
|
+
signaled: Set<string>,
|
|
509
|
+
): void {
|
|
510
|
+
const errors: unknown[] = [];
|
|
511
|
+
for (const entry of processes) {
|
|
512
|
+
const identity = processIdentity(entry);
|
|
513
|
+
if (signaled.has(identity)) continue;
|
|
514
|
+
signaled.add(identity);
|
|
515
|
+
try {
|
|
516
|
+
process.kill(entry.pid, signal);
|
|
517
|
+
} catch (error) {
|
|
518
|
+
if (!isMissingProcessError(error)) errors.push(error);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (errors.length > 0) {
|
|
522
|
+
throw new AggregateError(errors, `Failed to send ${signal} to the complete process tree`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Pause without detaching the shutdown operation from the event loop. */
|
|
527
|
+
function delay(milliseconds: number): Promise<void> {
|
|
528
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Poll refreshed process snapshots until the retained tree exits or the
|
|
533
|
+
* timeout elapses. Descendants created during shutdown are retained and receive
|
|
534
|
+
* the current shutdown signal once.
|
|
535
|
+
*/
|
|
536
|
+
async function waitForProcessTreeExit(
|
|
537
|
+
retained: Map<number, ProcessSnapshotEntry>,
|
|
538
|
+
initialProcesses: readonly ProcessSnapshotEntry[],
|
|
539
|
+
signal: NodeJS.Signals,
|
|
540
|
+
signaled: Set<string>,
|
|
541
|
+
timeoutMs: number,
|
|
542
|
+
pollIntervalMs: number,
|
|
543
|
+
): Promise<ProcessSnapshotEntry[]> {
|
|
544
|
+
let survivors = [...initialProcesses];
|
|
545
|
+
const deadline = Date.now() + timeoutMs;
|
|
546
|
+
while (survivors.length > 0) {
|
|
547
|
+
const remainingMs = deadline - Date.now();
|
|
548
|
+
if (remainingMs > 0) await delay(Math.min(pollIntervalMs, remainingMs));
|
|
549
|
+
|
|
550
|
+
const refreshed = refreshProcessTree(retained, await processSnapshot());
|
|
551
|
+
if (refreshed.discovered.length > 0) {
|
|
552
|
+
signalProcesses(refreshed.discovered, signal, signaled);
|
|
553
|
+
}
|
|
554
|
+
survivors = refreshed.processes;
|
|
555
|
+
if (Date.now() >= deadline) break;
|
|
556
|
+
}
|
|
557
|
+
return survivors;
|
|
558
|
+
}
|
|
559
|
+
|
|
274
560
|
/**
|
|
275
561
|
* Build the object returned from {@link spawn} with live line arrays and lazy
|
|
276
562
|
* trimmed `stdout` / `stderr` getters derived from those lines.
|
|
@@ -530,6 +816,92 @@ function linesFromCapturedOutput(output: string): string[] {
|
|
|
530
816
|
return output.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
531
817
|
}
|
|
532
818
|
|
|
819
|
+
/**
|
|
820
|
+
* Stop a child process and descendants discovered through operating-system
|
|
821
|
+
* process snapshots.
|
|
822
|
+
*
|
|
823
|
+
* The initial tree is retained before signaling so descendants remain
|
|
824
|
+
* targetable after their parent exits or they are reparented. Polling refreshes
|
|
825
|
+
* the process table, merges newly created descendants, and sends each one the
|
|
826
|
+
* active shutdown signal once. Either phase can be disabled for graceful-only
|
|
827
|
+
* or force-only termination. macOS and other POSIX platforms use `ps`; Windows
|
|
828
|
+
* uses the built-in PowerShell CIM process provider.
|
|
829
|
+
*
|
|
830
|
+
* Windows does not implement POSIX signal semantics. Node terminates processes
|
|
831
|
+
* unconditionally for supported signals there, including `SIGTERM`.
|
|
832
|
+
*
|
|
833
|
+
* @param child - Root child process whose snapshotted tree should stop
|
|
834
|
+
* @param options - Graceful signal, force signal, timeout, and polling policy
|
|
835
|
+
* @throws When the process table cannot be captured, a signal cannot be sent,
|
|
836
|
+
* or identity-matched processes remain after the final enabled phase
|
|
837
|
+
*/
|
|
838
|
+
export async function kill(child: ChildProcess, options: KillOptions = {}): Promise<void> {
|
|
839
|
+
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) {
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const gracefulTimeoutMs = timeoutValue(
|
|
844
|
+
options.gracefulTimeoutMs ?? DEFAULT_GRACEFUL_TIMEOUT_MS,
|
|
845
|
+
"gracefulTimeoutMs",
|
|
846
|
+
);
|
|
847
|
+
const forceTimeoutMs = timeoutValue(
|
|
848
|
+
options.forceTimeoutMs ?? DEFAULT_FORCE_TIMEOUT_MS,
|
|
849
|
+
"forceTimeoutMs",
|
|
850
|
+
);
|
|
851
|
+
const pollIntervalMs = pollIntervalValue(options.pollIntervalMs ?? DEFAULT_KILL_POLL_INTERVAL_MS);
|
|
852
|
+
const snapshot = await processSnapshot();
|
|
853
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
854
|
+
const tree = processTree(snapshot, child.pid);
|
|
855
|
+
const retained = new Map(tree.map((entry) => [entry.pid, entry]));
|
|
856
|
+
const gracefulSignal = options.gracefulSignal === undefined ? "SIGTERM" : options.gracefulSignal;
|
|
857
|
+
const forceSignal = options.forceSignal === undefined ? "SIGKILL" : options.forceSignal;
|
|
858
|
+
if (gracefulSignal === false && forceSignal === false) {
|
|
859
|
+
throw new Error("At least one process-tree termination signal must be enabled");
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
let forceTargets = tree;
|
|
863
|
+
if (gracefulSignal !== false) {
|
|
864
|
+
const gracefulSignaled = new Set<string>();
|
|
865
|
+
signalProcesses(tree, gracefulSignal, gracefulSignaled);
|
|
866
|
+
forceTargets = await waitForProcessTreeExit(
|
|
867
|
+
retained,
|
|
868
|
+
tree,
|
|
869
|
+
gracefulSignal,
|
|
870
|
+
gracefulSignaled,
|
|
871
|
+
gracefulTimeoutMs,
|
|
872
|
+
pollIntervalMs,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
if (forceTargets.length === 0) return;
|
|
876
|
+
if (forceSignal === false) {
|
|
877
|
+
if (gracefulSignal === false) {
|
|
878
|
+
throw new Error("At least one process-tree termination signal must be enabled");
|
|
879
|
+
}
|
|
880
|
+
throw processTerminationError(gracefulSignal, forceTargets);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const forceSignaled = new Set<string>();
|
|
884
|
+
signalProcesses(forceTargets, forceSignal, forceSignaled);
|
|
885
|
+
const forcedSurvivors = await waitForProcessTreeExit(
|
|
886
|
+
retained,
|
|
887
|
+
forceTargets,
|
|
888
|
+
forceSignal,
|
|
889
|
+
forceSignaled,
|
|
890
|
+
forceTimeoutMs,
|
|
891
|
+
pollIntervalMs,
|
|
892
|
+
);
|
|
893
|
+
if (forcedSurvivors.length > 0) throw processTerminationError(forceSignal, forcedSurvivors);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function processTerminationError(
|
|
897
|
+
signal: NodeJS.Signals,
|
|
898
|
+
processes: readonly ProcessSnapshotEntry[],
|
|
899
|
+
): Error {
|
|
900
|
+
return new Error(
|
|
901
|
+
`Processes did not terminate after ${signal}: ${processes.map((entry) => entry.pid).join(", ")}`,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
533
905
|
/**
|
|
534
906
|
* Spawn a subprocess. Returns the LIVE child handle that is ALSO a
|
|
535
907
|
* `Promise<ExecResult>` resolving on exit - see {@link ChildProcessResult}.
|
|
@@ -539,7 +911,7 @@ function linesFromCapturedOutput(output: string): string[] {
|
|
|
539
911
|
* or rejects (spawn error other than a missing executable, or `check` on non-zero
|
|
540
912
|
* exit). Existing `await spawn(...)` callers are unaffected. The handle half lets a
|
|
541
913
|
* caller supervise the process (`.pid`, `.kill(signal)`, `exit` event) without a
|
|
542
|
-
* second spawn
|
|
914
|
+
* second spawn.
|
|
543
915
|
*
|
|
544
916
|
* @param command - Executable to run (resolved on `PATH` when `shell` is set on options)
|
|
545
917
|
* @param args - Arguments passed verbatim to the executable
|