@dbx-tools/core 0.9.69 → 0.9.70

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