procband 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,8 @@
4
4
 
5
5
  `procband` supervises subprocesses from TypeScript. It prefixes child output,
6
6
  matches future log lines, waits for terminal exit, optionally restarts failed
7
- processes, and tree-kills descendants during shutdown.
7
+ processes, tree-kills descendants during shutdown, and propagates unobserved
8
+ terminal failures to the parent process.
8
9
 
9
10
  ## Installation
10
11
 
@@ -19,7 +20,6 @@ import process from 'node:process'
19
20
  import { supervise } from 'procband'
20
21
 
21
22
  const proc = supervise({
22
- name: 'api',
23
23
  command: process.execPath,
24
24
  args: ['-e', 'console.log("ready")'],
25
25
  })
@@ -34,4 +34,5 @@ console.log(result)
34
34
  - Concepts and lifecycle: [docs/context.md](docs/context.md)
35
35
  - Minimal usage example: [examples/basic-usage.ts](examples/basic-usage.ts)
36
36
  - Restart example: [examples/restart-on-failure.ts](examples/restart-on-failure.ts)
37
+ - Sequencing example: [examples/sequenced-processes.ts](examples/sequenced-processes.ts)
37
38
  - Exact exported signatures: [dist/index.d.mts](dist/index.d.mts)
package/dist/index.d.mts CHANGED
@@ -27,8 +27,10 @@ type MatchStream = 'stdout' | 'stderr' | 'both';
27
27
  interface ProcessConfig {
28
28
  /**
29
29
  * Stable identifier used in prefixed output and emitted match events.
30
+ *
31
+ * Defaults to the trailing `/[-\w]+$/` match from `command`.
30
32
  */
31
- name: string;
33
+ name?: string;
32
34
  /**
33
35
  * Executable or shell-free command name passed to `spawn()`.
34
36
  */
@@ -190,6 +192,13 @@ interface ProcessResult {
190
192
  * Final child exit code, or `null` when the process exited by signal.
191
193
  */
192
194
  code: number | null;
195
+ /**
196
+ * Shell-style exit status for the final process outcome.
197
+ *
198
+ * This is equal to `code` for normal exits, or `128 + signalNumber` when the
199
+ * process exited by signal.
200
+ */
201
+ exitCode: number;
193
202
  /**
194
203
  * Final terminating signal, or `null` when the process exited normally.
195
204
  */
@@ -258,14 +267,14 @@ interface ProcbandProcess extends ChildProcess, PromiseLike<ProcessResult> {
258
267
  /**
259
268
  * Start supervising a single subprocess.
260
269
  *
261
- * The returned `ProccoliProcess` starts immediately, prefixes child output,
270
+ * The returned `ProcbandProcess` starts immediately, prefixes child output,
262
271
  * exposes line-based matching helpers, and resolves when the process reaches a
263
272
  * terminal state with no further restart pending.
264
273
  *
265
274
  * @param config The subprocess configuration to supervise.
266
275
  * @returns A `ChildProcess`-compatible wrapper with procband-specific helpers.
267
- * @throws When `config` is invalid, such as a missing `name`, missing
268
- * `command`, or an invalid reserved color.
276
+ * @throws When `config` is invalid, such as a missing `command`, a `command`
277
+ * that does not produce a fallback `name`, or an invalid reserved color.
269
278
  * @example
270
279
  * ```ts
271
280
  * import process from 'node:process'
package/dist/index.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { EventEmitter } from "node:events";
3
+ import { constants } from "node:os";
3
4
  import process from "node:process";
4
5
  import ansiStyles from "ansi-styles";
5
6
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
6
- import treeKill from "@alloc/tree-kill";
7
+ import treeKill, { treeKillSync } from "@alloc/tree-kill";
7
8
  //#region src/colors.ts
8
9
  const prefixPalette = [
9
10
  [
@@ -270,7 +271,7 @@ function isFailedExit(code, signal) {
270
271
  //#region src/shutdown.ts
271
272
  const liveTargets = /* @__PURE__ */ new Set();
272
273
  let parentCleanupInstalled = false;
273
- let handlingSigint = false;
274
+ let handlingSignal = false;
274
275
  function registerCleanupTarget(target) {
275
276
  liveTargets.add(target);
276
277
  installParentCleanup();
@@ -280,7 +281,9 @@ function unregisterCleanupTarget(target) {
280
281
  if (liveTargets.size === 0) uninstallParentCleanup();
281
282
  }
282
283
  function killTreeBestEffort(child, signal) {
283
- treeKill(child, signal).catch(() => {});
284
+ try {
285
+ treeKillSync(child, signal);
286
+ } catch {}
284
287
  }
285
288
  async function stopChildTree(child, close, isClosed, signal, killAfterMs) {
286
289
  try {
@@ -301,29 +304,38 @@ function installParentCleanup() {
301
304
  if (parentCleanupInstalled) return;
302
305
  parentCleanupInstalled = true;
303
306
  process.on("SIGINT", onParentSigint);
307
+ process.on("SIGTERM", onParentSigterm);
304
308
  process.on("exit", onParentExit);
305
309
  }
306
310
  function uninstallParentCleanup() {
307
311
  if (!parentCleanupInstalled) return;
308
312
  parentCleanupInstalled = false;
309
313
  process.off("SIGINT", onParentSigint);
314
+ process.off("SIGTERM", onParentSigterm);
310
315
  process.off("exit", onParentExit);
311
316
  }
312
317
  function onParentExit() {
313
318
  for (const target of [...liveTargets]) target.cleanupFromExit();
314
319
  }
315
320
  function onParentSigint() {
316
- if (handlingSigint) return;
317
- handlingSigint = true;
318
- const pending = [...liveTargets].map((target) => target.cleanupFromSigint());
321
+ onParentSignal("SIGINT");
322
+ }
323
+ function onParentSigterm() {
324
+ onParentSignal("SIGTERM");
325
+ }
326
+ function onParentSignal(signal) {
327
+ if (handlingSignal) return;
328
+ handlingSignal = true;
329
+ const handler = signal === "SIGINT" ? onParentSigint : onParentSigterm;
330
+ const pending = [...liveTargets].map((target) => target.cleanupFromSignal(signal));
319
331
  Promise.allSettled(pending).finally(() => {
320
- handlingSigint = false;
321
- process.off("SIGINT", onParentSigint);
322
- if (process.listenerCount("SIGINT") === 0) {
323
- process.kill(process.pid, "SIGINT");
332
+ handlingSignal = false;
333
+ process.off(signal, handler);
334
+ if (process.listenerCount(signal) === 0) {
335
+ process.kill(process.pid, signal);
324
336
  return;
325
337
  }
326
- process.on("SIGINT", onParentSigint);
338
+ process.on(signal, handler);
327
339
  });
328
340
  }
329
341
  function isMissingProcessError(error) {
@@ -334,14 +346,14 @@ function isMissingProcessError(error) {
334
346
  /**
335
347
  * Start supervising a single subprocess.
336
348
  *
337
- * The returned `ProccoliProcess` starts immediately, prefixes child output,
349
+ * The returned `ProcbandProcess` starts immediately, prefixes child output,
338
350
  * exposes line-based matching helpers, and resolves when the process reaches a
339
351
  * terminal state with no further restart pending.
340
352
  *
341
353
  * @param config The subprocess configuration to supervise.
342
354
  * @returns A `ChildProcess`-compatible wrapper with procband-specific helpers.
343
- * @throws When `config` is invalid, such as a missing `name`, missing
344
- * `command`, or an invalid reserved color.
355
+ * @throws When `config` is invalid, such as a missing `command`, a `command`
356
+ * that does not produce a fallback `name`, or an invalid reserved color.
345
357
  * @example
346
358
  * ```ts
347
359
  * import process from 'node:process'
@@ -376,15 +388,17 @@ var ProcbandProcessImpl = class extends EventEmitter {
376
388
  stderrSinkActive = true;
377
389
  stderrSinkCleanup = null;
378
390
  stopPromise = null;
391
+ shutdownRequested = false;
379
392
  restartDisabled = false;
380
393
  finalized = false;
381
394
  lastResult = null;
395
+ terminalResultObserved = false;
382
396
  constructor(config) {
383
397
  super();
384
- validateProcessConfig(config);
398
+ const name = validateProcessConfig(config);
385
399
  this.config = config;
386
- this.name = config.name;
387
- this.label = config.label ?? config.name;
400
+ this.name = name;
401
+ this.label = config.label ?? name;
388
402
  this.color = resolveProcessColor(config.color);
389
403
  this.matches = new MatchRegistry(this.name);
390
404
  this.restart = new RestartController(config.restart);
@@ -395,6 +409,7 @@ var ProcbandProcessImpl = class extends EventEmitter {
395
409
  this.bindStderrSink();
396
410
  this.spawnAttempt();
397
411
  registerCleanupTarget(this);
412
+ liveProcesses.add(this);
398
413
  }
399
414
  get pid() {
400
415
  return this.currentChild?.pid;
@@ -443,17 +458,13 @@ var ProcbandProcessImpl = class extends EventEmitter {
443
458
  return this.matches.waitFor(pattern, options);
444
459
  }
445
460
  wait() {
461
+ this.markTerminalResultObserved();
446
462
  return this.finalPromise;
447
463
  }
448
464
  then(onfulfilled, onrejected) {
465
+ this.markTerminalResultObserved();
449
466
  return this.finalPromise.then(onfulfilled, onrejected);
450
467
  }
451
- catch(onrejected) {
452
- return this.finalPromise.catch(onrejected);
453
- }
454
- finally(onfinally) {
455
- return this.finalPromise.finally(onfinally);
456
- }
457
468
  kill(signal) {
458
469
  const child = this.currentChild;
459
470
  if (!child) return false;
@@ -472,26 +483,29 @@ var ProcbandProcessImpl = class extends EventEmitter {
472
483
  }
473
484
  async stop(options) {
474
485
  if (this.stopPromise) return this.stopPromise;
486
+ this.shutdownRequested = true;
475
487
  this.restartDisabled = true;
476
488
  this.restart.cancelDelay();
477
489
  this.stopPromise = this.stopActiveTree(options?.signal ?? "SIGTERM", options?.killAfterMs ?? 5e3);
478
490
  return this.stopPromise;
479
491
  }
480
492
  cleanupFromExit() {
493
+ this.shutdownRequested = true;
481
494
  this.restartDisabled = true;
482
495
  this.restart.cancelDelay();
483
496
  const child = this.currentChild;
484
497
  if (child) killTreeBestEffort(child);
485
498
  }
486
- async cleanupFromSigint() {
499
+ async cleanupFromSignal(signal) {
500
+ this.shutdownRequested = true;
487
501
  this.restartDisabled = true;
488
502
  this.restart.cancelDelay();
489
- await this.stopActiveTree("SIGINT", 1e3);
503
+ await this.stopActiveTree(signal, 1e3);
490
504
  }
491
505
  async stopActiveTree(signal, killAfterMs) {
492
506
  const attempt = this.attempt;
493
507
  if (!attempt || attempt.closed) {
494
- await this.wait().then(() => void 0);
508
+ await this.finalPromise;
495
509
  return;
496
510
  }
497
511
  await stopChildTree(attempt.child, attempt.close, () => attempt.closed, signal, killAfterMs);
@@ -602,6 +616,7 @@ var ProcbandProcessImpl = class extends EventEmitter {
602
616
  return {
603
617
  name: this.name,
604
618
  code,
619
+ exitCode: getResultExitCode(code, signal),
605
620
  signal,
606
621
  restarts: this.restart.restarts,
607
622
  restartSuppressed: this.restart.restartSuppressed
@@ -612,11 +627,20 @@ var ProcbandProcessImpl = class extends EventEmitter {
612
627
  this.finalized = true;
613
628
  this.restart.cancelDelay();
614
629
  this.lastResult = result;
630
+ liveProcesses.delete(this);
631
+ if (liveProcesses.size === 0) propagatedFailure = false;
632
+ if (this.shouldPropagateFailure(result)) propagateFailure(result.exitCode);
615
633
  this.matches.close(/* @__PURE__ */ new Error(`Process "${this.name}" exited before a matching line was observed`));
616
634
  this.unbindStderrSink();
617
635
  unregisterCleanupTarget(this);
618
636
  this.finalResolve(result);
619
637
  }
638
+ markTerminalResultObserved() {
639
+ this.terminalResultObserved = true;
640
+ }
641
+ shouldPropagateFailure(result) {
642
+ return result.exitCode !== 0 && !this.shutdownRequested && !this.terminalResultObserved;
643
+ }
620
644
  bindStderrSink() {
621
645
  const sink = this.stderrSink;
622
646
  if (!sink || typeof sink.on !== "function") return;
@@ -659,10 +683,28 @@ var ProcbandProcessImpl = class extends EventEmitter {
659
683
  this.handleLine(stream, line, false);
660
684
  }
661
685
  };
686
+ const liveProcesses = /* @__PURE__ */ new Set();
687
+ let propagatedFailure = false;
662
688
  function validateProcessConfig(config) {
663
- if (!config.name) throw new Error("ProcessConfig.name is required");
664
689
  if (!config.command) throw new Error("ProcessConfig.command is required");
665
690
  validateProcessColor(config.color);
691
+ const name = config.name || inferProcessName(config.command);
692
+ if (!name) throw new Error("ProcessConfig.name is required when command does not match /[-\\w]+$/");
693
+ return name;
694
+ }
695
+ function inferProcessName(command) {
696
+ return command.match(/[-\w]+$/)?.[0];
697
+ }
698
+ function getResultExitCode(code, signal) {
699
+ if (code != null) return code;
700
+ if (signal) return 128 + (constants.signals[signal] ?? 0);
701
+ return 1;
702
+ }
703
+ function propagateFailure(exitCode) {
704
+ if (propagatedFailure) return;
705
+ propagatedFailure = true;
706
+ if (process.exitCode == null || process.exitCode === 0) process.exitCode = exitCode;
707
+ for (const proc of liveProcesses) proc.stop().catch(() => {});
666
708
  }
667
709
  //#endregion
668
710
  export { supervise };
package/docs/context.md CHANGED
@@ -13,6 +13,7 @@ Supervision adds four behaviors on top of raw `spawn()`:
13
13
  - line-based matching for future output
14
14
  - optional restart policy with failure suppression
15
15
  - tree-aware shutdown for the child and its descendants
16
+ - parent-exit propagation for unobserved terminal failures
16
17
 
17
18
  # When to Use
18
19
 
@@ -55,7 +56,10 @@ Supervision adds four behaviors on top of raw `spawn()`:
55
56
  `waitFor()`.
56
57
  6. When a child exits, `procband` either finalizes or starts a new attempt,
57
58
  depending on the restart policy.
58
- 7. `await proc` or `await proc.wait()` resolves only after the process is
59
+ 7. A terminal failed exit that nobody observed through `await proc` or
60
+ `proc.wait()` sets `process.exitCode` and begins
61
+ stopping any other live `procband` processes in the same parent script.
62
+ 8. `await proc` or `await proc.wait()` resolves only after the process is
59
63
  terminal and no further restart will happen.
60
64
 
61
65
  # Common Tasks -> Recommended APIs
@@ -68,6 +72,10 @@ Supervision adds four behaviors on top of raw `spawn()`:
68
72
  `proc.stop()`
69
73
  - Inspect final exit state:
70
74
  `await proc` or `await proc.wait()`
75
+ - Take ownership of a process failure:
76
+ `await proc` or `await proc.wait()`
77
+ - Let an unobserved terminal failure fail the parent script:
78
+ Do not call `wait()` or await the thenable result
71
79
  - Capture raw child `stderr` in a file or custom stream:
72
80
  `ProcessConfig.stderr`
73
81
  - Retry failed exits with sane defaults:
@@ -85,22 +93,36 @@ Supervision adds four behaviors on top of raw `spawn()`:
85
93
  observed.
86
94
  - `await proc` resolves for both successful and failed exits. Inspect the
87
95
  returned `ProcessResult`.
96
+ - `ProcessResult.exitCode` exposes the shell-style exit status for the final
97
+ outcome, including signal exits.
98
+ - Calling `proc.wait()` or awaiting the thenable process marks its terminal
99
+ result as observed and suppresses default parent-exit propagation.
100
+ - An unobserved terminal failure sets `process.exitCode` to the first failing
101
+ process's `ProcessResult.exitCode` and starts stopping other live `procband`
102
+ processes in the same parent script.
88
103
  - The wrapper survives restarts, but inherited `pid`, `stdin`, `stdout`,
89
104
  `stderr`, and related `ChildProcess` fields always refer to the current active
90
105
  child attempt.
91
106
  - `kill()` only signals the current direct child. `stop()` disables restart and
92
107
  kills the full process tree.
108
+ - Parent cleanup installs both `SIGINT` and `SIGTERM` handlers while any live
109
+ supervised process exists.
93
110
  - `stderr` prefixes always use the reserved red, even when a custom process
94
111
  color is configured.
112
+ - `ProcessConfig.name` is optional. When omitted, it falls back to the
113
+ trailing `/[-\w]+$/` match from `command`.
95
114
 
96
115
  # Error Model
97
116
 
98
- - `supervise()` throws synchronously for invalid config such as missing `name`,
99
- missing `command`, or an invalid reserved color.
117
+ - `supervise()` throws synchronously for invalid config such as missing
118
+ `command`, a `command` that does not produce a fallback `name`, or an
119
+ invalid reserved color.
100
120
  - `waitFor()` rejects on timeout or terminal exit before a future match.
101
121
  - A thrown `match()` callback only unsubscribes that callback.
102
122
  - Errors from `ProcessConfig.stderr` stop teeing to that sink but do not stop
103
123
  supervision.
124
+ - Unobserved terminal failures do not reject promises. They set the parent
125
+ `process.exitCode` and start stopping sibling `procband` processes.
104
126
  - `stop()` may reject if tree-kill fails with a non-`ESRCH` error.
105
127
 
106
128
  # Terminology
@@ -0,0 +1,29 @@
1
+ import process from 'node:process'
2
+ import { supervise } from 'procband'
3
+
4
+ const api = supervise({
5
+ name: 'api',
6
+ command: process.execPath,
7
+ args: [
8
+ '-e',
9
+ [
10
+ 'console.log("booting")',
11
+ 'setTimeout(() => console.log("ready"), 20)',
12
+ 'setInterval(() => {}, 1000)',
13
+ ].join(';'),
14
+ ],
15
+ })
16
+
17
+ await api.waitFor('ready')
18
+
19
+ supervise({
20
+ name: 'worker',
21
+ command: process.execPath,
22
+ args: [
23
+ '-e',
24
+ [
25
+ 'console.log("watching")',
26
+ 'setTimeout(() => process.exit(1), 60)',
27
+ ].join(';'),
28
+ ],
29
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "procband",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Supervise subprocesses from TypeScript: prefix logs, wait for output patterns, and manage lifecycle/restarts.",
5
5
  "license": "MIT",
6
6
  "author": "Alec Larson",
@@ -27,7 +27,7 @@
27
27
  "vitest": "^4.1.2"
28
28
  },
29
29
  "dependencies": {
30
- "@alloc/tree-kill": "^1.3.0",
30
+ "@alloc/tree-kill": "^1.4.0",
31
31
  "ansi-styles": "^6.2.3"
32
32
  },
33
33
  "scripts": {