@zudojs/lifecycle 1.1.0 → 1.1.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
@@ -3,6 +3,12 @@
3
3
  Application and component lifecycle orchestration with a state machine,
4
4
  dependency ordering, graceful shutdown, rollback, and signal handling.
5
5
 
6
+ <!-- zudo-docs:start -->
7
+
8
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-lifecycle](https://zudojs.oyinlola.site/docs/packages-lifecycle) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-lifecycle.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
9
+
10
+ <!-- zudo-docs:end -->
11
+
6
12
  ## Installation
7
13
 
8
14
  ```bash
@@ -109,8 +115,8 @@ lifecycle.
109
115
  ```typescript
110
116
  createLifecycleManager({
111
117
  concurrency: 10, // parallel component operations per stage
112
- shutdownTimeout: 30_000, // global shutdown deadline (ms)
113
- handleSignals: true, // install process signal handlers
118
+ shutdownTimeout: 30_000, // global shutdown deadline (ms); Infinity = none
119
+ handleSignals: true, // install signal handlers on start()
114
120
  signals: ["SIGINT", "SIGTERM"], // defaults to DEFAULT_SHUTDOWN_SIGNALS
115
121
  });
116
122
  ```
@@ -118,6 +124,18 @@ createLifecycleManager({
118
124
  Per-component: `id`, `dependsOn`, `priority`, `critical`, `timeout`,
119
125
  `retry: { attempts, delay, maxDelay, backoff }`.
120
126
 
127
+ `timeout` and `shutdownTimeout` accept `Infinity` for "no bound"; NaN and
128
+ negative values throw a `RangeError` when registered or constructed, and
129
+ finite values above 2^31-1 ms are clamped to the largest timer delay.
130
+ `retry` covers hooks that fail; a hook that times out is not retried,
131
+ because it is still running and a second call would overlap it.
132
+ `shutdown()` waits (within its deadline) for such an abandoned hook to
133
+ settle before calling `stop()`.
134
+
135
+ With `handleSignals`, SIGINT/SIGTERM listeners are installed by `start()`,
136
+ not by the constructor, and removed once shutdown finishes. A second
137
+ signal while shutdown is running exits the process with code 1.
138
+
121
139
  ## Use Cases
122
140
 
123
141
  - Coordinating service startup and shutdown
@@ -32,7 +32,10 @@ export interface LifecycleRegistrationOptions {
32
32
  readonly priority?: number;
33
33
  /** If true, application startup fails when this component fails. Defaults to true. */
34
34
  readonly critical?: boolean;
35
- /** Timeout in ms for individual component operations. */
35
+ /**
36
+ * Timeout in ms for individual component operations. `Infinity` means
37
+ * no bound; NaN and negative values are rejected at registration.
38
+ */
36
39
  readonly timeout?: number;
37
40
  /** Retry configuration for failed operations. */
38
41
  readonly retry?: LifecycleRetryOptions;
@@ -23,6 +23,15 @@ export interface ExecutionResult {
23
23
  * Executes lifecycle component hooks with timeout, retry, and concurrency support.
24
24
  */
25
25
  export declare class LifecycleExecutor {
26
+ /** Hook invocations still running after their timeout fired. */
27
+ private readonly abandoned;
28
+ /**
29
+ * Resolves once every hook abandoned by a timeout has settled.
30
+ *
31
+ * Shutdown waits on this before stopping components, so `stop()`
32
+ * never overlaps a `start()` that is still running.
33
+ */
34
+ settleAbandoned(): Promise<void>;
26
35
  /**
27
36
  * Executes a single component hook.
28
37
  */
@@ -5,11 +5,24 @@
5
5
  */
6
6
  import { withTimeout, withConcurrency } from "../lifecycleInternal/index.js";
7
7
  import { getComponentMethod } from "../lifecyclePhase/index.js";
8
- import { LifecycleComponentError } from "@zudojs/errors";
8
+ import { LifecycleComponentError, LifecycleTimeoutError, } from "@zudojs/errors";
9
9
  /**
10
10
  * Executes lifecycle component hooks with timeout, retry, and concurrency support.
11
11
  */
12
12
  export class LifecycleExecutor {
13
+ /** Hook invocations still running after their timeout fired. */
14
+ abandoned = new Set();
15
+ /**
16
+ * Resolves once every hook abandoned by a timeout has settled.
17
+ *
18
+ * Shutdown waits on this before stopping components, so `stop()`
19
+ * never overlaps a `start()` that is still running.
20
+ */
21
+ async settleAbandoned() {
22
+ while (this.abandoned.size > 0) {
23
+ await Promise.allSettled([...this.abandoned]);
24
+ }
25
+ }
13
26
  /**
14
27
  * Executes a single component hook.
15
28
  */
@@ -36,12 +49,13 @@ export class LifecycleExecutor {
36
49
  lastError ??= new LifecycleComponentError(registration.id, phase, context.signal.reason);
37
50
  break;
38
51
  }
52
+ let invocation;
39
53
  try {
40
- await withTimeout(async () => {
41
- const result = hook.call(registration.component, context);
42
- if (result instanceof Promise) {
43
- await result;
44
- }
54
+ await withTimeout(() => {
55
+ invocation = (async () => {
56
+ await hook.call(registration.component, context);
57
+ })();
58
+ return invocation;
45
59
  }, registration.timeout, registration.id, phase);
46
60
  return {
47
61
  id: registration.id,
@@ -52,6 +66,16 @@ export class LifecycleExecutor {
52
66
  }
53
67
  catch (error) {
54
68
  lastError = error;
69
+ // A timed-out hook is still running; withTimeout cannot cancel
70
+ // it. Retrying would run the same start() concurrently (three
71
+ // listen() calls on one port), so a timeout is final and the
72
+ // abandoned invocation is tracked for shutdown to wait on.
73
+ if (error instanceof LifecycleTimeoutError && invocation) {
74
+ const abandoned = invocation.catch(() => undefined);
75
+ this.abandoned.add(abandoned);
76
+ void abandoned.finally(() => this.abandoned.delete(abandoned));
77
+ break;
78
+ }
55
79
  if (attempt < maxAttempts - 1) {
56
80
  const delay = calculateDelay(retryConfig, attempt);
57
81
  await sleep(delay, context.signal);
@@ -6,6 +6,10 @@
6
6
  /**
7
7
  * Executes an async operation with a timeout.
8
8
  * Throws LifecycleTimeoutError if the timeout is exceeded.
9
+ *
10
+ * `Infinity` runs the operation unbounded; values above the largest
11
+ * timer delay are clamped to it. NaN or a negative value rejects with a
12
+ * RangeError instead of arming a 1 ms timer.
9
13
  */
10
14
  export declare function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, componentId: string, phase: string): Promise<T>;
11
15
  /**
@@ -4,15 +4,24 @@
4
4
  * Async utilities for timeout, abort, and concurrency control.
5
5
  */
6
6
  import { LifecycleTimeoutError } from "@zudojs/errors";
7
+ import { assertTimeoutBudget, isBounded, toTimerDelay, } from "./timeoutBudget.core.js";
7
8
  /**
8
9
  * Executes an async operation with a timeout.
9
10
  * Throws LifecycleTimeoutError if the timeout is exceeded.
11
+ *
12
+ * `Infinity` runs the operation unbounded; values above the largest
13
+ * timer delay are clamped to it. NaN or a negative value rejects with a
14
+ * RangeError instead of arming a 1 ms timer.
10
15
  */
11
16
  export async function withTimeout(fn, timeoutMs, componentId, phase) {
17
+ assertTimeoutBudget("timeout", timeoutMs);
18
+ if (!isBounded(timeoutMs)) {
19
+ return fn();
20
+ }
12
21
  return new Promise((resolve, reject) => {
13
22
  const timer = setTimeout(() => {
14
23
  reject(new LifecycleTimeoutError(componentId, phase, timeoutMs));
15
- }, timeoutMs);
24
+ }, toTimerDelay(timeoutMs));
16
25
  // The timer must be cleared on EVERY exit path. A synchronously
17
26
  // throwing `fn` used to escape before `.catch` was attached,
18
27
  // leaving an armed timer that kept the event loop alive for the
@@ -7,4 +7,5 @@ export { DependencyGraph } from "./dependencyGraph.core.js";
7
7
  export { topologicalSort, reverseTopologicalSort, } from "./topologicalSort.core.js";
8
8
  export type { TopologicalStage } from "./topologicalSort.core.js";
9
9
  export { withTimeout, withAbort, withConcurrency } from "./asyncUtils.core.js";
10
+ export { MAX_TIMER_DELAY, assertTimeoutBudget, isBounded, toTimerDelay, } from "./timeoutBudget.core.js";
10
11
  //# sourceMappingURL=index.d.ts.map
@@ -6,4 +6,5 @@
6
6
  export { DependencyGraph } from "./dependencyGraph.core.js";
7
7
  export { topologicalSort, reverseTopologicalSort, } from "./topologicalSort.core.js";
8
8
  export { withTimeout, withAbort, withConcurrency } from "./asyncUtils.core.js";
9
+ export { MAX_TIMER_DELAY, assertTimeoutBudget, isBounded, toTimerDelay, } from "./timeoutBudget.core.js";
9
10
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @zudojs/lifecycle/internal/timeout-budget
3
+ *
4
+ * Validation and normalisation for millisecond budgets handed to timers.
5
+ */
6
+ /** Largest delay `setTimeout` can represent (2^31 - 1 ms). */
7
+ export declare const MAX_TIMER_DELAY = 2147483647;
8
+ /**
9
+ * Validates a millisecond budget.
10
+ *
11
+ * `Infinity` is accepted and means "no bound". NaN, negative values and
12
+ * non-numbers are rejected up front: `setTimeout` silently turns them
13
+ * (and `Infinity`) into a 1 ms timer, and `LifecycleTimeoutError`'s
14
+ * constructor then threw inside that timer callback, which crashed the
15
+ * process instead of failing the component.
16
+ *
17
+ * @param name - Option name, used in the error message.
18
+ * @param value - The budget to check.
19
+ * @throws RangeError when the value is not a non-negative number.
20
+ */
21
+ export declare function assertTimeoutBudget(name: string, value: unknown): void;
22
+ /**
23
+ * Whether a validated budget actually bounds anything.
24
+ */
25
+ export declare function isBounded(value: number): boolean;
26
+ /**
27
+ * Clamps a finite budget to what a timer can represent.
28
+ */
29
+ export declare function toTimerDelay(value: number): number;
30
+ //# sourceMappingURL=timeoutBudget.core.d.ts.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @zudojs/lifecycle/internal/timeout-budget
3
+ *
4
+ * Validation and normalisation for millisecond budgets handed to timers.
5
+ */
6
+ /** Largest delay `setTimeout` can represent (2^31 - 1 ms). */
7
+ export const MAX_TIMER_DELAY = 2_147_483_647;
8
+ /**
9
+ * Validates a millisecond budget.
10
+ *
11
+ * `Infinity` is accepted and means "no bound". NaN, negative values and
12
+ * non-numbers are rejected up front: `setTimeout` silently turns them
13
+ * (and `Infinity`) into a 1 ms timer, and `LifecycleTimeoutError`'s
14
+ * constructor then threw inside that timer callback, which crashed the
15
+ * process instead of failing the component.
16
+ *
17
+ * @param name - Option name, used in the error message.
18
+ * @param value - The budget to check.
19
+ * @throws RangeError when the value is not a non-negative number.
20
+ */
21
+ export function assertTimeoutBudget(name, value) {
22
+ if (typeof value !== "number" || Number.isNaN(value) || value < 0) {
23
+ throw new RangeError(`${name} must be a non-negative number of milliseconds (Infinity for no bound), got ${String(value)}.`);
24
+ }
25
+ }
26
+ /**
27
+ * Whether a validated budget actually bounds anything.
28
+ */
29
+ export function isBounded(value) {
30
+ return Number.isFinite(value);
31
+ }
32
+ /**
33
+ * Clamps a finite budget to what a timer can represent.
34
+ */
35
+ export function toTimerDelay(value) {
36
+ return Math.min(Math.max(0, value), MAX_TIMER_DELAY);
37
+ }
38
+ //# sourceMappingURL=timeoutBudget.core.js.map
@@ -13,9 +13,16 @@ import { LifecycleEventEmitter } from "../lifecycleEvents/lifecycleEvents.core.j
13
13
  export interface LifecycleManagerOptions {
14
14
  /** Maximum concurrent component operations. */
15
15
  readonly concurrency?: number;
16
- /** Global shutdown timeout in ms. */
16
+ /**
17
+ * Global shutdown timeout in ms. `Infinity` means no deadline; NaN
18
+ * and negative values are rejected by the constructor.
19
+ */
17
20
  readonly shutdownTimeout?: number;
18
- /** Whether to automatically install signal handlers. */
21
+ /**
22
+ * Whether to install SIGINT/SIGTERM handlers. They are installed by
23
+ * `start()` (not the constructor) and removed once shutdown finishes,
24
+ * and a second signal during shutdown exits with code 1.
25
+ */
19
26
  readonly handleSignals?: boolean;
20
27
  /** Signals to listen for. */
21
28
  readonly signals?: readonly NodeJS.Signals[];
@@ -37,6 +44,8 @@ export declare class LifecycleManager {
37
44
  private readonly _ctx;
38
45
  private _startPromise?;
39
46
  private _removeSignalHandlers?;
47
+ private readonly _handleSignals;
48
+ private readonly _signals;
40
49
  constructor(options?: LifecycleManagerOptions);
41
50
  /** Registers a component with the lifecycle manager. */
42
51
  register(component: LifecycleComponent, options?: LifecycleRegistrationOptions): void;
@@ -50,6 +59,7 @@ export declare class LifecycleManager {
50
59
  * Idempotent — returns the same promise if called multiple times.
51
60
  */
52
61
  shutdown(): Promise<void>;
62
+ private releaseSignalHandlers;
53
63
  /** Returns the current application state. */
54
64
  get state(): LifecycleState;
55
65
  /** Returns the event emitter for lifecycle events. */
@@ -12,6 +12,7 @@ import { LifecycleEventEmitter } from "../lifecycleEvents/lifecycleEvents.core.j
12
12
  import { installSignalHandlers } from "../lifecycleSignal/lifecycleSignal.handler.js";
13
13
  import { performStartup } from "./lifecycleManager.startup.js";
14
14
  import { performShutdown } from "./lifecycleManager.shutdown.js";
15
+ import { assertTimeoutBudget } from "../lifecycleInternal/index.js";
15
16
  /**
16
17
  * Orchestrates application and component lifecycle.
17
18
  *
@@ -29,7 +30,12 @@ export class LifecycleManager {
29
30
  _ctx;
30
31
  _startPromise;
31
32
  _removeSignalHandlers;
33
+ _handleSignals;
34
+ _signals;
32
35
  constructor(options = {}) {
36
+ if (options.shutdownTimeout !== undefined) {
37
+ assertTimeoutBudget("shutdownTimeout", options.shutdownTimeout);
38
+ }
33
39
  this._ctx = {
34
40
  registry: new LifecycleRegistry(),
35
41
  state: new LifecycleStateMachine("application"),
@@ -43,14 +49,12 @@ export class LifecycleManager {
43
49
  startTime: 0,
44
50
  controller: new AbortController(),
45
51
  };
46
- if (options.handleSignals !== false) {
47
- this._removeSignalHandlers = installSignalHandlers({
48
- signals: options.signals,
49
- handler: () => {
50
- void this.shutdown();
51
- },
52
- });
53
- }
52
+ // Installing in the constructor disabled Ctrl-C for the whole
53
+ // process as soon as a manager existed (tests, libraries), and the
54
+ // listener outlived shutdown, so a process with a leaked handle
55
+ // could no longer be interrupted.
56
+ this._handleSignals = options.handleSignals !== false;
57
+ this._signals = options.signals;
54
58
  }
55
59
  /** Registers a component with the lifecycle manager. */
56
60
  register(component, options = {}) {
@@ -70,7 +74,20 @@ export class LifecycleManager {
70
74
  if (this._startPromise) {
71
75
  return this._startPromise;
72
76
  }
73
- this._startPromise = performStartup(this._ctx);
77
+ if (this._handleSignals && this._removeSignalHandlers === undefined) {
78
+ this._removeSignalHandlers = installSignalHandlers({
79
+ signals: this._signals,
80
+ handler: () => {
81
+ void this.shutdown();
82
+ },
83
+ });
84
+ }
85
+ this._startPromise = performStartup(this._ctx).catch(async (error) => {
86
+ // A failed startup rolls back through the shared shutdown.
87
+ await this._ctx.shutdownPromise?.catch(() => undefined);
88
+ this.releaseSignalHandlers();
89
+ throw error;
90
+ });
74
91
  return this._startPromise;
75
92
  }
76
93
  /**
@@ -80,7 +97,16 @@ export class LifecycleManager {
80
97
  async shutdown() {
81
98
  // performShutdown is itself single-flight, so a shutdown started by
82
99
  // startup rollback and one started here are the SAME run.
83
- return performShutdown(this._ctx);
100
+ try {
101
+ await performShutdown(this._ctx);
102
+ }
103
+ finally {
104
+ this.releaseSignalHandlers();
105
+ }
106
+ }
107
+ releaseSignalHandlers() {
108
+ this._removeSignalHandlers?.();
109
+ this._removeSignalHandlers = undefined;
84
110
  }
85
111
  /** Returns the current application state. */
86
112
  get state() {
@@ -112,8 +138,7 @@ export class LifecycleManager {
112
138
  * component teardown — call `shutdown()` first for that.
113
139
  */
114
140
  dispose() {
115
- this._removeSignalHandlers?.();
116
- this._removeSignalHandlers = undefined;
141
+ this.releaseSignalHandlers();
117
142
  this._ctx.events.clear();
118
143
  }
119
144
  }
@@ -6,6 +6,7 @@
6
6
  import { LifecyclePhase, LifecycleState } from "@zudojs/constants";
7
7
  import { buildExecutionPlan } from "../lifecyclePlan/lifecyclePlan.core.js";
8
8
  import { createLifecycleContext } from "../lifecycleContext/lifecycleContext.type.js";
9
+ import { isBounded, toTimerDelay } from "../lifecycleInternal/index.js";
9
10
  import { emitComponentFailed, recordResult, transitionComponent, wasAttempted, } from "./lifecycleManager.context.js";
10
11
  /** Shutdown phases in execution order. */
11
12
  const SHUTDOWN_PHASES = [LifecyclePhase.STOP, LifecyclePhase.DISPOSE];
@@ -45,6 +46,9 @@ async function runShutdown(ctx) {
45
46
  if (ctx.inFlight !== undefined) {
46
47
  await raceDeadline(ctx, ctx.inFlight.then(() => undefined, () => undefined), Math.max(deadline - Date.now(), 1));
47
48
  }
49
+ // Hooks abandoned by a component timeout are still running; stopping
50
+ // their component now would overlap its own start().
51
+ await raceDeadline(ctx, ctx.executor.settleAbandoned(), Math.max(deadline - Date.now(), 1));
48
52
  // The shutdown deadline used to be checked only BETWEEN the two
49
53
  // phases, so a single hook that never settled hung shutdown (and the
50
54
  // process) forever. Race the whole phase against the remaining
@@ -79,12 +83,19 @@ async function runShutdown(ctx) {
79
83
  * so it can never hold the event loop open.
80
84
  */
81
85
  async function raceDeadline(ctx, phase, remainingMs) {
86
+ // An unbounded budget (shutdownTimeout: Infinity) waits for the phase.
87
+ // Handing Infinity to setTimeout fired after 1 ms and abandoned every
88
+ // stop()/dispose() while reporting the application DISPOSED.
89
+ if (!isBounded(remainingMs)) {
90
+ await phase.catch(() => { });
91
+ return;
92
+ }
82
93
  let timer;
83
94
  const expiry = new Promise((resolve) => {
84
95
  timer = setTimeout(() => {
85
96
  ctx.controller.abort(new Error(`Lifecycle shutdown exceeded its ${ctx.shutdownTimeout}ms deadline.`));
86
97
  resolve();
87
- }, remainingMs);
98
+ }, toTimerDelay(remainingMs));
88
99
  });
89
100
  try {
90
101
  await Promise.race([phase, expiry]);
@@ -4,7 +4,7 @@
4
4
  * Lifecycle registry — manages component registration, validation, and lookup.
5
5
  */
6
6
  import { LIFECYCLE_DEFAULT_TIMEOUT } from "@zudojs/constants";
7
- import { DependencyGraph } from "../lifecycleInternal/index.js";
7
+ import { DependencyGraph, assertTimeoutBudget, } from "../lifecycleInternal/index.js";
8
8
  /**
9
9
  * Registry for lifecycle components.
10
10
  * Validates registration, builds dependency graph, and freezes on demand.
@@ -19,6 +19,9 @@ export class LifecycleRegistry {
19
19
  throw new Error("Cannot register components after registry is frozen");
20
20
  }
21
21
  const id = options.id ?? component.name;
22
+ if (options.timeout !== undefined) {
23
+ assertTimeoutBudget(`Component "${id}" timeout`, options.timeout);
24
+ }
22
25
  if (this._registrations.has(id)) {
23
26
  throw new Error(`Component "${id}" is already registered`);
24
27
  }
@@ -9,6 +9,15 @@ export interface SignalHandlerOptions {
9
9
  readonly signals?: readonly NodeJS.Signals[];
10
10
  /** Function to call when a signal is received. */
11
11
  readonly handler: () => void;
12
+ /**
13
+ * Whether a second signal, received while the first is still being
14
+ * handled, exits the process with code 1. Defaults to `true`: the
15
+ * installed listener replaces Node's default exit, so without this a
16
+ * wedged shutdown could not be interrupted short of SIGKILL.
17
+ */
18
+ readonly forceExitOnSecondSignal?: boolean;
19
+ /** Exit hook, injected for testing. Defaults to `process.exit`. */
20
+ readonly exit?: (code: number) => void;
12
21
  }
13
22
  /** Default signal configuration for graceful shutdown. */
14
23
  export declare const DEFAULT_SHUTDOWN_SIGNALS: readonly NodeJS.Signals[];
@@ -15,9 +15,18 @@ export function installSignalHandlers(options) {
15
15
  // changing the constant had no effect on the actual default.
16
16
  const signals = options.signals ?? DEFAULT_SHUTDOWN_SIGNALS;
17
17
  const handler = options.handler;
18
+ const forceExit = options.forceExitOnSecondSignal ?? true;
19
+ const exit = options.exit ?? ((code) => process.exit(code));
20
+ let received = false;
18
21
  const installed = [];
19
22
  for (const signal of signals) {
20
23
  const listener = () => {
24
+ if (received) {
25
+ if (forceExit)
26
+ exit(1);
27
+ return;
28
+ }
29
+ received = true;
21
30
  handler();
22
31
  };
23
32
  process.on(signal, listener);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/lifecycle",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Application and component lifecycle orchestration with state machine, dependency ordering, graceful shutdown, rollback, and signals.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -24,8 +24,8 @@
24
24
  "!dist/.tsbuildinfo"
25
25
  ],
26
26
  "dependencies": {
27
- "@zudojs/errors": "1.0.1",
28
- "@zudojs/constants": "1.0.1"
27
+ "@zudojs/errors": "1.1.0",
28
+ "@zudojs/constants": "1.1.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^26.4.1",