@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/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 (read `.pid`, `.kill(signal)`, listen for `exit`) can, without a second
118
- * spawn. `@dbx-tools/appkit`'s `bindProcess` consumes exactly this - one code path
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
- * ctx.bindProcess(proc); // live handle
127
- * const { exitCode } = await proc; // still a promise
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 - e.g. `@dbx-tools/appkit`'s `bindProcess`.
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