@fuzdev/fuz_util 0.45.3 → 0.47.0

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.
@@ -7,215 +7,767 @@ import {styleText as st} from 'node:util';
7
7
 
8
8
  import {Logger} from './log.js';
9
9
  import {print_error, print_key_value} from './print.js';
10
- import type {Result} from './result.js';
10
+ import {noop} from './function.js';
11
11
 
12
12
  const log = new Logger('process');
13
13
 
14
- export interface SpawnedProcess {
14
+ //
15
+ // Spawn Result Types
16
+ //
17
+
18
+ /**
19
+ * Spawn failed before the process could run.
20
+ *
21
+ * @example ENOENT when command not found
22
+ */
23
+ export interface SpawnResultError {
24
+ ok: false;
15
25
  child: ChildProcess;
16
- closed: Promise<SpawnResult>;
26
+ error: Error;
27
+ code: null;
28
+ signal: null;
29
+ }
30
+
31
+ /**
32
+ * Process ran and exited with a code.
33
+ * `ok` is true when `code` is 0.
34
+ */
35
+ export interface SpawnResultExited {
36
+ ok: boolean;
37
+ child: ChildProcess;
38
+ error: null;
39
+ code: number;
40
+ signal: null;
17
41
  }
18
42
 
19
- export interface Spawned {
43
+ /**
44
+ * Process was terminated by a signal (e.g., SIGTERM, SIGKILL).
45
+ */
46
+ export interface SpawnResultSignaled {
47
+ ok: false;
20
48
  child: ChildProcess;
21
- signal: NodeJS.Signals | null;
22
- code: number | null;
49
+ error: null;
50
+ code: null;
51
+ signal: NodeJS.Signals;
52
+ }
53
+
54
+ /**
55
+ * Discriminated union representing all possible spawn outcomes.
56
+ * Use type guards `spawn_result_is_error`, `spawn_result_is_signaled`,
57
+ * and `spawn_result_is_exited` to narrow the type.
58
+ */
59
+ export type SpawnResult = SpawnResultError | SpawnResultExited | SpawnResultSignaled;
60
+
61
+ //
62
+ // Type Guards
63
+ //
64
+
65
+ /**
66
+ * Type guard for spawn errors (process failed to start).
67
+ */
68
+ export const spawn_result_is_error = (result: SpawnResult): result is SpawnResultError =>
69
+ result.error !== null;
70
+
71
+ /**
72
+ * Type guard for signal termination.
73
+ */
74
+ export const spawn_result_is_signaled = (result: SpawnResult): result is SpawnResultSignaled =>
75
+ result.signal !== null;
76
+
77
+ /**
78
+ * Type guard for normal exit with code.
79
+ */
80
+ export const spawn_result_is_exited = (result: SpawnResult): result is SpawnResultExited =>
81
+ result.code !== null;
82
+
83
+ //
84
+ // Spawn Options
85
+ //
86
+
87
+ /**
88
+ * Options for spawning processes, extending Node's `SpawnOptions`.
89
+ */
90
+ export interface SpawnProcessOptions extends SpawnOptions {
91
+ /**
92
+ * AbortSignal to cancel the process.
93
+ * When aborted, sends SIGTERM to the child.
94
+ */
95
+ signal?: AbortSignal;
96
+ /**
97
+ * Timeout in milliseconds. Must be non-negative.
98
+ * Sends SIGTERM when exceeded. A value of 0 triggers immediate SIGTERM.
99
+ */
100
+ timeout_ms?: number;
23
101
  }
24
102
 
25
- // TODO are `code` and `signal` more related than that?
26
- // e.g. should this be a union type where one is always `null`?
27
- export type SpawnResult = Result<Spawned, Spawned>;
103
+ /**
104
+ * Options for killing processes.
105
+ */
106
+ export interface DespawnOptions {
107
+ /**
108
+ * Signal to send.
109
+ * @default 'SIGTERM'
110
+ */
111
+ signal?: NodeJS.Signals;
112
+ /**
113
+ * Timeout in ms before escalating to SIGKILL. Must be non-negative.
114
+ * Useful for processes that may ignore SIGTERM. A value of 0 triggers immediate SIGKILL escalation.
115
+ */
116
+ timeout_ms?: number;
117
+ }
118
+
119
+ //
120
+ // Process Handle Types
121
+ //
28
122
 
29
123
  /**
30
- * A convenient promise wrapper around `spawn_process`
31
- * intended for commands that have an end, not long running-processes like watchers.
32
- * Any more advanced usage should use `spawn_process` directly for access to the `child` process.
124
+ * Handle for a spawned process with access to the child and completion promise.
33
125
  */
34
- export const spawn = (...args: Parameters<typeof spawn_process>): Promise<SpawnResult> =>
35
- spawn_process(...args).closed;
126
+ export interface SpawnedProcess {
127
+ /** The underlying Node.js ChildProcess */
128
+ child: ChildProcess;
129
+ /** Resolves when the process exits */
130
+ closed: Promise<SpawnResult>;
131
+ }
36
132
 
133
+ /**
134
+ * Result of `spawn_out` with captured output streams.
135
+ */
37
136
  export interface SpawnedOut {
38
137
  result: SpawnResult;
138
+ /** Captured stdout, or null if stream unavailable */
39
139
  stdout: string | null;
140
+ /** Captured stderr, or null if stream unavailable */
40
141
  stderr: string | null;
41
142
  }
42
143
 
144
+ //
145
+ // Internal Helpers
146
+ //
147
+
43
148
  /**
44
- * Similar to `spawn` but buffers and returns `stdout` and `stderr` as strings.
149
+ * Creates a promise that resolves when the child process closes.
150
+ *
151
+ * Handles both 'error' and 'close' events with deduplication because Node.js
152
+ * can emit both for certain failures (e.g., spawn ENOENT emits 'error' then 'close').
153
+ * The `resolved` flag ensures we only resolve once with the first event's data.
45
154
  */
46
- export const spawn_out = async (
47
- command: string,
48
- args: ReadonlyArray<string> = [],
49
- options?: SpawnOptions,
50
- ): Promise<SpawnedOut> => {
51
- const {child, closed} = spawn_process(command, args, {...options, stdio: 'pipe'});
52
- let stdout: string | null = null;
53
- child.stdout!.on('data', (data: Buffer) => {
54
- stdout = (stdout ?? '') + data.toString();
155
+ const create_closed_promise = (child: ChildProcess): Promise<SpawnResult> => {
156
+ let resolve: (v: SpawnResult) => void;
157
+ let resolved = false;
158
+ const closed: Promise<SpawnResult> = new Promise((r) => (resolve = r));
159
+
160
+ child.once('error', (err) => {
161
+ if (resolved) return;
162
+ resolved = true;
163
+ resolve({ok: false, child, error: err, code: null, signal: null});
55
164
  });
56
- let stderr: string | null = null;
57
- child.stderr!.on('data', (data: Buffer) => {
58
- stderr = (stderr ?? '') + data.toString();
165
+
166
+ child.once('close', (code, signal) => {
167
+ if (resolved) return;
168
+ resolved = true;
169
+ if (signal !== null) {
170
+ resolve({ok: false, child, error: null, code: null, signal});
171
+ } else {
172
+ resolve({ok: code === 0, child, error: null, code: code ?? 0, signal: null});
173
+ }
59
174
  });
60
- const result = await closed;
61
- return {result, stdout, stderr};
175
+
176
+ return closed;
62
177
  };
63
178
 
64
179
  /**
65
- * Wraps the normal Node `childProcess.spawn` with graceful child shutdown behavior.
66
- * Also returns a convenient `closed` promise.
67
- * If you only need `closed`, prefer the shorthand function `spawn`.
68
- * @mutates global_spawn calls `register_global_spawn()` which adds to the module-level Set
180
+ * Sets up abort signal handling for a child process.
181
+ * @returns cleanup function to remove the listener
69
182
  */
70
- export const spawn_process = (
71
- command: string,
72
- args: ReadonlyArray<string> = [],
73
- options?: SpawnOptions,
74
- ): SpawnedProcess => {
75
- let resolve: (v: SpawnResult) => void;
76
- const closed: Promise<SpawnResult> = new Promise((r) => (resolve = r));
77
- const child = spawn_child_process(command, args, {stdio: 'inherit', ...options});
78
- const unregister = register_global_spawn(child);
79
- child.once('close', (code, signal) => {
80
- unregister();
81
- resolve(code ? {ok: false, child, code, signal} : {ok: true, child, code, signal});
82
- });
83
- return {closed, child};
183
+ const setup_abort_signal = (child: ChildProcess, signal: AbortSignal): (() => void) => {
184
+ if (signal.aborted) {
185
+ child.kill();
186
+ return noop;
187
+ }
188
+ const on_abort = () => child.kill();
189
+ signal.addEventListener('abort', on_abort, {once: true});
190
+ return () => signal.removeEventListener('abort', on_abort);
84
191
  };
85
192
 
86
- export const print_child_process = (child: ChildProcess): string =>
87
- `${st('gray', 'pid(')}${child.pid}${st('gray', ')')} ${st('green', child.spawnargs.join(' '))}`;
193
+ /**
194
+ * Validates timeout_ms option.
195
+ * @throws if timeout_ms is negative
196
+ */
197
+ const validate_timeout_ms = (timeout_ms: number | undefined): void => {
198
+ if (timeout_ms !== undefined && timeout_ms < 0) {
199
+ throw new Error(`timeout_ms must be non-negative, got ${timeout_ms}`);
200
+ }
201
+ };
88
202
 
89
203
  /**
90
- * We register spawned processes gloabally so we can gracefully exit child processes.
91
- * Otherwise, errors can cause zombie processes, sometimes blocking ports even!
204
+ * Sets up timeout handling for a child process.
205
+ * Note: timeout_ms of 0 triggers immediate SIGTERM (use with caution).
206
+ * @returns cleanup function to clear the timeout
92
207
  */
93
- export const global_spawn: Set<ChildProcess> = new Set();
208
+ const setup_timeout = (child: ChildProcess, timeout_ms: number): (() => void) => {
209
+ const timeout_id = setTimeout(() => child.kill('SIGTERM'), timeout_ms);
210
+ return () => clearTimeout(timeout_id);
211
+ };
212
+
213
+ //
214
+ // Process Registry
215
+ //
94
216
 
95
217
  /**
96
- * Returns a function that unregisters the `child`.
97
- * @param child the child process to register
98
- * @returns cleanup function that removes the child from `global_spawn`
99
- * @mutates global_spawn adds child to the module-level Set, and the returned function removes it
218
+ * Manages a collection of spawned processes for lifecycle tracking and cleanup.
219
+ *
220
+ * The default instance `process_registry_default` is used by module-level functions.
221
+ * Create separate instances for isolated process groups or testing.
222
+ *
223
+ * @example
224
+ * ```ts
225
+ * // Use default registry via module functions
226
+ * const result = await spawn('echo', ['hello']);
227
+ *
228
+ * // Or create isolated registry for testing
229
+ * const registry = new ProcessRegistry();
230
+ * const {child, closed} = registry.spawn('node', ['server.js']);
231
+ * await registry.despawn_all();
232
+ * ```
100
233
  */
101
- export const register_global_spawn = (child: ChildProcess): (() => void) => {
102
- if (global_spawn.has(child)) {
103
- log.error(st('red', 'already registered global spawn:'), print_child_process(child));
234
+ export class ProcessRegistry {
235
+ /** All currently tracked child processes */
236
+ readonly processes: Set<ChildProcess> = new Set();
237
+
238
+ #error_handler: ((err: Error, origin: NodeJS.UncaughtExceptionOrigin) => void) | null = null;
239
+
240
+ /**
241
+ * Spawns a process and tracks it in this registry.
242
+ * The process is automatically unregistered when it exits.
243
+ *
244
+ * @param command - The command to run
245
+ * @param args - Arguments to pass to the command
246
+ * @param options - Spawn options including `signal` and `timeout_ms`
247
+ * @returns Handle with `child` process and `closed` promise
248
+ */
249
+ spawn(
250
+ command: string,
251
+ args: ReadonlyArray<string> = [],
252
+ options?: SpawnProcessOptions,
253
+ ): SpawnedProcess {
254
+ const {signal, timeout_ms, ...spawn_options} = options ?? {};
255
+ validate_timeout_ms(timeout_ms);
256
+ const child = spawn_child_process(command, args, {stdio: 'inherit', ...spawn_options});
257
+
258
+ this.processes.add(child);
259
+ const closed = create_closed_promise(child);
260
+
261
+ let cleanup_abort: (() => void) | undefined;
262
+ if (signal) {
263
+ cleanup_abort = setup_abort_signal(child, signal);
264
+ }
265
+
266
+ let cleanup_timeout: (() => void) | undefined;
267
+ if (timeout_ms !== undefined) {
268
+ cleanup_timeout = setup_timeout(child, timeout_ms);
269
+ }
270
+
271
+ void closed.then(() => {
272
+ this.processes.delete(child);
273
+ cleanup_abort?.();
274
+ cleanup_timeout?.();
275
+ });
276
+
277
+ return {child, closed};
104
278
  }
105
- global_spawn.add(child);
106
- return () => {
107
- if (!global_spawn.has(child)) {
108
- log.error(st('red', 'spawn not registered:'), print_child_process(child));
279
+
280
+ /**
281
+ * Spawns a process and captures stdout/stderr as strings.
282
+ * Sets `stdio: 'pipe'` automatically.
283
+ *
284
+ * @param command - The command to run
285
+ * @param args - Arguments to pass to the command
286
+ * @param options - Spawn options
287
+ * @returns Result with captured `stdout` and `stderr`.
288
+ * - `null` means spawn failed (ENOENT, etc.) or stream was unavailable
289
+ * - `''` (empty string) means process ran but produced no output
290
+ * - non-empty string contains the captured output
291
+ */
292
+ async spawn_out(
293
+ command: string,
294
+ args: ReadonlyArray<string> = [],
295
+ options?: SpawnProcessOptions,
296
+ ): Promise<SpawnedOut> {
297
+ const {child, closed} = this.spawn(command, args, {...options, stdio: 'pipe'});
298
+ const stdout_chunks: Array<string> = [];
299
+ const stderr_chunks: Array<string> = [];
300
+ // Track whether streams were available (not null)
301
+ const stdout_available = child.stdout !== null;
302
+ const stderr_available = child.stderr !== null;
303
+ const on_stdout = (data: Buffer): void => {
304
+ stdout_chunks.push(data.toString());
305
+ };
306
+ const on_stderr = (data: Buffer): void => {
307
+ stderr_chunks.push(data.toString());
308
+ };
309
+ child.stdout?.on('data', on_stdout);
310
+ child.stderr?.on('data', on_stderr);
311
+ const result = await closed;
312
+ // Clean up listeners explicitly
313
+ child.stdout?.off('data', on_stdout);
314
+ child.stderr?.off('data', on_stderr);
315
+ // If spawn failed (error result), streams are meaningless - return null
316
+ // Otherwise: '' = available but empty, string = has content
317
+ const spawn_failed = spawn_result_is_error(result);
318
+ const stdout = spawn_failed || !stdout_available ? null : stdout_chunks.join('');
319
+ const stderr = spawn_failed || !stderr_available ? null : stderr_chunks.join('');
320
+ return {result, stdout, stderr};
321
+ }
322
+
323
+ /**
324
+ * Kills a child process and waits for it to exit.
325
+ *
326
+ * @param child - The child process to kill
327
+ * @param options - Kill options including signal and timeout
328
+ * @returns The spawn result after the process exits
329
+ */
330
+ async despawn(child: ChildProcess, options?: DespawnOptions): Promise<SpawnResult> {
331
+ const {signal = 'SIGTERM', timeout_ms} = options ?? {};
332
+ validate_timeout_ms(timeout_ms);
333
+
334
+ // Already exited with code
335
+ if (child.exitCode !== null) {
336
+ return {
337
+ ok: child.exitCode === 0,
338
+ child,
339
+ error: null,
340
+ code: child.exitCode,
341
+ signal: null,
342
+ };
109
343
  }
110
- global_spawn.delete(child);
111
- };
112
- };
344
+ // Already terminated by signal
345
+ if (child.signalCode !== null) {
346
+ return {
347
+ ok: false,
348
+ child,
349
+ error: null,
350
+ code: null,
351
+ signal: child.signalCode,
352
+ };
353
+ }
354
+
355
+ log.debug('despawning', print_child_process(child));
356
+ const closed = create_closed_promise(child);
357
+
358
+ // Escalate to SIGKILL after timeout
359
+ if (timeout_ms !== undefined) {
360
+ const timeout_id = setTimeout(() => child.kill('SIGKILL'), timeout_ms);
361
+ void closed.then(() => clearTimeout(timeout_id));
362
+ }
363
+
364
+ child.kill(signal);
365
+ return closed;
366
+ }
367
+
368
+ /**
369
+ * Kills all processes in this registry.
370
+ *
371
+ * @param options - Kill options applied to all processes
372
+ * @returns Array of spawn results
373
+ */
374
+ async despawn_all(options?: DespawnOptions): Promise<Array<SpawnResult>> {
375
+ return Promise.all([...this.processes].map((child) => this.despawn(child, options)));
376
+ }
377
+
378
+ /**
379
+ * Attaches an `uncaughtException` handler that kills all processes before exiting.
380
+ * Prevents zombie processes when the parent crashes.
381
+ *
382
+ * By default uses SIGKILL for immediate termination. Set `graceful_timeout_ms`
383
+ * to attempt SIGTERM first (allowing processes to clean up) before escalating
384
+ * to SIGKILL after the timeout.
385
+ *
386
+ * Note: Node's uncaughtException handler cannot await async operations, so
387
+ * graceful shutdown uses a blocking busy-wait. This may not be sufficient
388
+ * for processes that need significant cleanup time.
389
+ *
390
+ * @param options - Configuration options
391
+ * @param options.to_error_label - Customize error label, return `null` for default
392
+ * @param options.map_error_text - Customize error text, return `''` to silence
393
+ * @param options.handle_error - Called after cleanup, defaults to `process.exit(1)`
394
+ * @param options.graceful_timeout_ms - If set, sends SIGTERM first and waits this
395
+ * many ms before SIGKILL. Recommended: 100-500ms. If null/undefined, uses
396
+ * immediate SIGKILL (default).
397
+ * @returns Cleanup function to remove the handler
398
+ */
399
+ attach_error_handler(options?: {
400
+ to_error_label?: (err: Error, origin: NodeJS.UncaughtExceptionOrigin) => string | null;
401
+ map_error_text?: (err: Error, origin: NodeJS.UncaughtExceptionOrigin) => string | null;
402
+ handle_error?: (err: Error, origin: NodeJS.UncaughtExceptionOrigin) => void;
403
+ graceful_timeout_ms?: number | null;
404
+ }): () => void {
405
+ if (this.#error_handler) {
406
+ throw new Error('Error handler already attached to this registry');
407
+ }
408
+
409
+ const {
410
+ to_error_label,
411
+ map_error_text,
412
+ handle_error = () => process.exit(1),
413
+ graceful_timeout_ms,
414
+ } = options ?? {};
415
+
416
+ this.#error_handler = (err, origin): void => {
417
+ const label = to_error_label?.(err, origin) ?? origin;
418
+ if (label) {
419
+ const error_text = map_error_text?.(err, origin) ?? print_error(err);
420
+ if (error_text) {
421
+ new Logger(label).error(error_text);
422
+ }
423
+ }
424
+
425
+ if (graceful_timeout_ms != null && graceful_timeout_ms > 0) {
426
+ // Attempt graceful shutdown with SIGTERM first
427
+ for (const child of this.processes) {
428
+ child.kill('SIGTERM');
429
+ }
430
+ // Busy-wait (blocking) - only option in sync handler.
431
+ // Warning: This will peg the CPU during the wait period.
432
+ const deadline = Date.now() + graceful_timeout_ms;
433
+ while (Date.now() < deadline) {
434
+ // spin
435
+ }
436
+ }
437
+
438
+ // Force kill all (including any that survived SIGTERM)
439
+ for (const child of this.processes) {
440
+ child.kill('SIGKILL');
441
+ }
442
+ this.processes.clear();
443
+ handle_error(err, origin);
444
+ };
445
+
446
+ process.on('uncaughtException', this.#error_handler);
447
+
448
+ return () => {
449
+ if (this.#error_handler) {
450
+ process.off('uncaughtException', this.#error_handler);
451
+ this.#error_handler = null;
452
+ }
453
+ };
454
+ }
455
+ }
456
+
457
+ //
458
+ // Default Registry
459
+ //
113
460
 
114
461
  /**
115
- * Kills a child process and returns a `SpawnResult`.
462
+ * Default process registry used by module-level spawn functions.
463
+ * For testing or isolated process groups, create a new `ProcessRegistry` instance.
116
464
  */
117
- export const despawn = (child: ChildProcess): Promise<SpawnResult> => {
118
- let resolve: (v: SpawnResult) => void;
119
- const closed: Promise<SpawnResult> = new Promise((r) => (resolve = r));
120
- log.debug('despawning', print_child_process(child));
121
- child.once('close', (code, signal) => {
122
- resolve(code ? {ok: false, child, code, signal} : {ok: true, child, code, signal});
123
- });
124
- child.kill();
125
- return closed;
126
- };
465
+ export const process_registry_default = new ProcessRegistry();
466
+
467
+ //
468
+ // Module-Level Spawn Functions
469
+ //
127
470
 
128
471
  /**
129
- * Kills all globally registered child processes.
130
- * @mutates global_spawn indirectly removes processes through `despawn()` calls
472
+ * Spawns a process with graceful shutdown behavior.
473
+ * Returns a handle with access to the `child` process and `closed` promise.
474
+ *
475
+ * @example
476
+ * ```ts
477
+ * const {child, closed} = spawn_process('node', ['server.js']);
478
+ * // Later...
479
+ * child.kill();
480
+ * const result = await closed;
481
+ * ```
131
482
  */
132
- export const despawn_all = (): Promise<Array<SpawnResult>> =>
133
- Promise.all(Array.from(global_spawn, (child) => despawn(child)));
483
+ export const spawn_process = (
484
+ command: string,
485
+ args: ReadonlyArray<string> = [],
486
+ options?: SpawnProcessOptions,
487
+ ): SpawnedProcess => process_registry_default.spawn(command, args, options);
134
488
 
135
489
  /**
136
- * Attaches the `'uncoughtException'` event to despawn all processes,
137
- * and enables custom error logging with `to_error_label`.
138
- * @param to_error_label - Customize the error label or return `null` for the default `origin`.
139
- * @param map_error_text - Customize the error text. Return `''` to silence, or `null` for the default `print_error(err)`.
490
+ * Spawns a process and returns a promise that resolves when it exits.
491
+ * Use this for commands that complete (not long-running processes).
492
+ *
493
+ * @example
494
+ * ```ts
495
+ * const result = await spawn('npm', ['install']);
496
+ * if (!result.ok) console.error('Install failed');
497
+ * ```
140
498
  */
141
- export const attach_process_error_handlers = (
142
- to_error_label?: (err: Error, origin: NodeJS.UncaughtExceptionOrigin) => string | null,
143
- map_error_text?: (err: Error, origin: NodeJS.UncaughtExceptionOrigin) => string | null,
144
- handle_error: (err: Error, origin: NodeJS.UncaughtExceptionOrigin) => void = () =>
145
- process.exit(1),
146
- ): void => {
147
- process.on('uncaughtException', async (err, origin): Promise<void> => {
148
- const label = to_error_label?.(err, origin) ?? origin;
149
- if (label) {
150
- const error_text = map_error_text?.(err, origin) ?? print_error(err);
151
- if (error_text) {
152
- new Logger(label).error(error_text);
153
- }
154
- }
155
- await despawn_all();
156
- handle_error(err, origin);
157
- });
158
- };
499
+ export const spawn = (
500
+ command: string,
501
+ args: ReadonlyArray<string> = [],
502
+ options?: SpawnProcessOptions,
503
+ ): Promise<SpawnResult> => spawn_process(command, args, options).closed;
159
504
 
160
505
  /**
161
- * Formats a `SpawnResult` for printing.
506
+ * Spawns a process and captures stdout/stderr as strings.
507
+ *
508
+ * @example
509
+ * ```ts
510
+ * const {result, stdout} = await spawn_out('git', ['status', '--porcelain']);
511
+ * if (result.ok && stdout) console.log(stdout);
512
+ * ```
513
+ */
514
+ export const spawn_out = (
515
+ command: string,
516
+ args: ReadonlyArray<string> = [],
517
+ options?: SpawnProcessOptions,
518
+ ): Promise<SpawnedOut> => process_registry_default.spawn_out(command, args, options);
519
+
520
+ /**
521
+ * Kills a child process and returns the result.
522
+ *
523
+ * @example
524
+ * ```ts
525
+ * const result = await despawn(child, {timeout_ms: 5000});
526
+ * // If process ignores SIGTERM, SIGKILL sent after 5s
527
+ * ```
528
+ */
529
+ export const despawn = (child: ChildProcess, options?: DespawnOptions): Promise<SpawnResult> =>
530
+ process_registry_default.despawn(child, options);
531
+
532
+ /**
533
+ * Kills all processes in the default registry.
534
+ */
535
+ export const despawn_all = (options?: DespawnOptions): Promise<Array<SpawnResult>> =>
536
+ process_registry_default.despawn_all(options);
537
+
538
+ /**
539
+ * Attaches an `uncaughtException` handler to the default registry.
540
+ *
541
+ * @see ProcessRegistry.attach_error_handler
542
+ */
543
+ export const attach_process_error_handler = (
544
+ options?: Parameters<ProcessRegistry['attach_error_handler']>[0],
545
+ ): (() => void) => process_registry_default.attach_error_handler(options);
546
+
547
+ //
548
+ // Formatting Utilities
549
+ //
550
+
551
+ /**
552
+ * Formats a child process for display.
553
+ *
554
+ * @example `pid(1234) <- node server.js`
555
+ */
556
+ export const print_child_process = (child: ChildProcess): string =>
557
+ `${st('gray', 'pid(')}${child.pid ?? 'none'}${st('gray', ')')} ← ${st('green', child.spawnargs.join(' '))}`;
558
+
559
+ /**
560
+ * Formats a spawn result for display.
561
+ * Returns `'ok'` for success, or the error/signal/code for failures.
162
562
  */
163
563
  export const print_spawn_result = (result: SpawnResult): string => {
164
564
  if (result.ok) return 'ok';
165
- let text = result.code === null ? '' : print_key_value('code', result.code);
166
- if (result.signal !== null) text += (text ? ' ' : '') + print_key_value('signal', result.signal);
167
- return text;
565
+ if (spawn_result_is_error(result)) return result.error.message;
566
+ if (spawn_result_is_signaled(result)) return print_key_value('signal', result.signal);
567
+ return print_key_value('code', result.code);
168
568
  };
169
569
 
170
- // TODO might want to expand this API for some use cases - assumes always running
570
+ /**
571
+ * Formats a spawn result for use in error messages.
572
+ */
573
+ export const spawn_result_to_message = (result: SpawnResult): string => {
574
+ if (spawn_result_is_error(result)) return `error: ${result.error.message}`;
575
+ if (spawn_result_is_signaled(result)) return `signal ${result.signal}`;
576
+ return `code ${result.code}`;
577
+ };
578
+
579
+ //
580
+ // Restartable Process
581
+ //
582
+
583
+ /**
584
+ * Handle for a process that can be restarted.
585
+ * Exposes `closed` promise for observing exits and implementing restart policies.
586
+ */
171
587
  export interface RestartableProcess {
172
- restart: () => void;
588
+ /**
589
+ * Restart the process, killing the current one if active.
590
+ * Concurrent calls are coalesced - multiple calls before the first completes
591
+ * will share the same restart operation.
592
+ */
593
+ restart: () => Promise<void>;
594
+ /** Kill the process and set `active` to false */
173
595
  kill: () => Promise<void>;
596
+ /**
597
+ * Whether this handle is managing a process.
598
+ *
599
+ * Note: This reflects handle state, not whether the underlying OS process is executing.
600
+ * Remains `true` after a process exits naturally until `kill()` or `restart()` is called.
601
+ * To check if the process actually exited, await `closed`.
602
+ */
603
+ readonly active: boolean;
604
+ /** The current child process, or null if not active */
605
+ readonly child: ChildProcess | null;
606
+ /** Promise that resolves when the current process exits */
607
+ readonly closed: Promise<SpawnResult>;
608
+ /**
609
+ * Promise that resolves when the initial `spawn_process()` call completes.
610
+ *
611
+ * Note: This resolves when the spawn syscall returns, NOT when the process
612
+ * is "ready" or has produced output. For commands that fail immediately
613
+ * (e.g., ENOENT), `spawned` still resolves - check `closed` for errors.
614
+ *
615
+ * @example
616
+ * ```ts
617
+ * const rp = spawn_restartable_process('node', ['server.js']);
618
+ * await rp.spawned; // Safe to access rp.child now
619
+ * ```
620
+ */
621
+ readonly spawned: Promise<void>;
174
622
  }
175
623
 
176
624
  /**
177
- * Like `spawn_process` but with `restart` and `kill`,
178
- * handling many concurrent `restart` calls gracefully.
625
+ * Spawns a process that can be restarted.
626
+ * Handles concurrent restart calls gracefully.
627
+ *
628
+ * Note: The `signal` and `timeout_ms` options are reapplied on each restart.
629
+ * If the AbortSignal is already aborted when `restart()` is called, the new
630
+ * process will be killed immediately.
631
+ *
632
+ * @example Simple restart on crash
633
+ * ```ts
634
+ * const rp = spawn_restartable_process('node', ['server.js']);
635
+ *
636
+ * while (rp.active) {
637
+ * const result = await rp.closed;
638
+ * if (result.ok) break; // Clean exit
639
+ * await rp.restart();
640
+ * }
641
+ * ```
642
+ *
643
+ * @example Restart with backoff
644
+ * ```ts
645
+ * const rp = spawn_restartable_process('node', ['server.js']);
646
+ * let failures = 0;
647
+ *
648
+ * while (rp.active) {
649
+ * const result = await rp.closed;
650
+ * if (result.ok || ++failures > 5) break;
651
+ * await new Promise((r) => setTimeout(r, 1000 * failures));
652
+ * await rp.restart();
653
+ * }
654
+ * ```
179
655
  */
180
656
  export const spawn_restartable_process = (
181
657
  command: string,
182
658
  args: ReadonlyArray<string> = [],
183
- options?: SpawnOptions,
659
+ options?: SpawnProcessOptions,
184
660
  ): RestartableProcess => {
185
- let spawned: SpawnedProcess | null = null;
186
- let restarting: Promise<any> | null = null;
187
- const close = async (): Promise<void> => {
188
- if (!spawned) return;
189
- restarting = spawned.closed;
190
- spawned.child.kill();
191
- spawned = null;
192
- await restarting;
193
- restarting = null;
661
+ let spawned_process: SpawnedProcess | null = null;
662
+ let pending_close: Promise<SpawnResult> | null = null;
663
+ let pending_restart: Promise<void> | null = null;
664
+ let pending_kill: Promise<void> | null = null;
665
+ // Deferred promise - resolves when first process spawns
666
+ let closed_promise: Promise<SpawnResult>;
667
+ let resolve_closed: (result: SpawnResult) => void;
668
+ const reset_closed_promise = (): void => {
669
+ closed_promise = new Promise((r) => (resolve_closed = r));
194
670
  };
195
- const restart = async (): Promise<void> => {
196
- if (restarting) return restarting;
197
- if (spawned) await close();
198
- spawned = spawn_process(command, args, {stdio: 'inherit', ...options});
671
+ reset_closed_promise();
672
+
673
+ // Resolve when first spawn completes to avoid race conditions
674
+ let resolve_spawned: () => void;
675
+ const spawned: Promise<void> = new Promise((r) => (resolve_spawned = r));
676
+
677
+ const do_close = async (): Promise<void> => {
678
+ if (!spawned_process) return;
679
+ pending_close = spawned_process.closed;
680
+ spawned_process.child.kill();
681
+ spawned_process = null;
682
+ await pending_close;
683
+ pending_close = null;
684
+ };
685
+
686
+ const do_restart = async (): Promise<void> => {
687
+ // Wait for any in-progress kill or close before restarting
688
+ if (pending_kill) await pending_kill;
689
+ if (pending_close) await pending_close;
690
+ if (spawned_process) await do_close();
691
+ spawned_process = spawn_process(command, args, {stdio: 'inherit', ...options});
692
+ // Forward the spawned process's closed promise to our exposed one
693
+ void spawned_process.closed.then((result) => {
694
+ resolve_closed(result);
695
+ });
199
696
  };
697
+
698
+ // Coalesce concurrent restart calls - multiple calls share one restart
699
+ const restart = (): Promise<void> => {
700
+ if (!pending_restart) {
701
+ // Reset the closed promise for the new process
702
+ reset_closed_promise();
703
+ pending_restart = do_restart().finally(() => {
704
+ pending_restart = null;
705
+ });
706
+ }
707
+ return pending_restart;
708
+ };
709
+
710
+ // Wait for any pending restart to complete first, ensuring we kill
711
+ // the newly spawned process rather than racing with it
200
712
  const kill = async (): Promise<void> => {
201
- if (restarting) await restarting;
202
- await close();
713
+ if (pending_kill) return pending_kill;
714
+ pending_kill = (async () => {
715
+ if (pending_restart) await pending_restart;
716
+ if (pending_close) await pending_close;
717
+ await do_close();
718
+ })();
719
+ try {
720
+ await pending_kill;
721
+ } finally {
722
+ pending_kill = null;
723
+ }
724
+ };
725
+
726
+ // Start immediately and resolve spawned promise when done
727
+ void restart().then(() => resolve_spawned());
728
+
729
+ return {
730
+ restart,
731
+ kill,
732
+ get active() {
733
+ return spawned_process !== null;
734
+ },
735
+ get child() {
736
+ return spawned_process?.child ?? null;
737
+ },
738
+ get closed() {
739
+ return closed_promise;
740
+ },
741
+ get spawned() {
742
+ return spawned;
743
+ },
203
744
  };
204
- // Start immediately -- it sychronously starts the process so there's no need to await.
205
- void restart();
206
- return {restart, kill};
207
745
  };
208
746
 
747
+ //
748
+ // Utility Functions
749
+ //
750
+
209
751
  /**
210
- * Check if a PID is still running.
752
+ * Checks if a process with the given PID is running.
753
+ * Uses signal 0 which checks existence without sending a signal.
754
+ *
755
+ * @param pid - The process ID to check (must be a positive integer)
756
+ * @returns `true` if the process exists (even without permission to signal it),
757
+ * `false` if the process doesn't exist or if pid is invalid (non-positive, non-integer, NaN, Infinity)
211
758
  */
212
759
  export const process_is_pid_running = (pid: number): boolean => {
760
+ // Handle NaN, Infinity, negative, zero, non-integers, and fractional values
761
+ if (!Number.isInteger(pid) || pid <= 0) return false;
213
762
  try {
214
- // Sending signal 0 doesn't actually send a signal, just checks if process exists
215
763
  process.kill(pid, 0);
216
764
  return true;
217
- } catch (err: any) {
218
- // ESRCH = no such process, EPERM = exists but no permission
219
- return err.code === 'EPERM';
765
+ } catch (err: unknown) {
766
+ // ESRCH = no such process
767
+ // EPERM = process exists but we lack permission to signal it
768
+ // Safely access .code in case of unexpected error types
769
+ const code =
770
+ err && typeof err === 'object' && 'code' in err ? (err as {code: string}).code : undefined;
771
+ return code === 'EPERM';
220
772
  }
221
773
  };