procband 0.1.0 → 0.2.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.
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
 
@@ -34,4 +35,5 @@ console.log(result)
34
35
  - Concepts and lifecycle: [docs/context.md](docs/context.md)
35
36
  - Minimal usage example: [examples/basic-usage.ts](examples/basic-usage.ts)
36
37
  - Restart example: [examples/restart-on-failure.ts](examples/restart-on-failure.ts)
38
+ - Sequencing example: [examples/sequenced-processes.ts](examples/sequenced-processes.ts)
37
39
  - Exact exported signatures: [dist/index.d.mts](dist/index.d.mts)
package/dist/index.d.mts CHANGED
@@ -190,6 +190,13 @@ interface ProcessResult {
190
190
  * Final child exit code, or `null` when the process exited by signal.
191
191
  */
192
192
  code: number | null;
193
+ /**
194
+ * Shell-style exit status for the final process outcome.
195
+ *
196
+ * This is equal to `code` for normal exits, or `128 + signalNumber` when the
197
+ * process exited by signal.
198
+ */
199
+ exitCode: number;
193
200
  /**
194
201
  * Final terminating signal, or `null` when the process exited normally.
195
202
  */
@@ -258,7 +265,7 @@ interface ProcbandProcess extends ChildProcess, PromiseLike<ProcessResult> {
258
265
  /**
259
266
  * Start supervising a single subprocess.
260
267
  *
261
- * The returned `ProccoliProcess` starts immediately, prefixes child output,
268
+ * The returned `ProcbandProcess` starts immediately, prefixes child output,
262
269
  * exposes line-based matching helpers, and resolves when the process reaches a
263
270
  * terminal state with no further restart pending.
264
271
  *
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,7 +346,7 @@ 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
  *
@@ -376,9 +388,11 @@ 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
398
  validateProcessConfig(config);
@@ -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,23 @@ 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
689
  if (!config.name) throw new Error("ProcessConfig.name is required");
664
690
  if (!config.command) throw new Error("ProcessConfig.command is required");
665
691
  validateProcessColor(config.color);
666
692
  }
693
+ function getResultExitCode(code, signal) {
694
+ if (code != null) return code;
695
+ if (signal) return 128 + (constants.signals[signal] ?? 0);
696
+ return 1;
697
+ }
698
+ function propagateFailure(exitCode) {
699
+ if (propagatedFailure) return;
700
+ propagatedFailure = true;
701
+ if (process.exitCode == null || process.exitCode === 0) process.exitCode = exitCode;
702
+ for (const proc of liveProcesses) proc.stop().catch(() => {});
703
+ }
667
704
  //#endregion
668
705
  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,11 +93,20 @@ 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.
95
112
 
@@ -101,6 +118,8 @@ Supervision adds four behaviors on top of raw `spawn()`:
101
118
  - A thrown `match()` callback only unsubscribes that callback.
102
119
  - Errors from `ProcessConfig.stderr` stop teeing to that sink but do not stop
103
120
  supervision.
121
+ - Unobserved terminal failures do not reject promises. They set the parent
122
+ `process.exitCode` and start stopping sibling `procband` processes.
104
123
  - `stop()` may reject if tree-kill fails with a non-`ESRCH` error.
105
124
 
106
125
  # 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.0",
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": {