@zudojs/lifecycle 1.0.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
@@ -48,7 +54,19 @@ If a **critical** component (the default) fails any startup phase,
48
54
  `start()` rolls the application back (`stop` → `dispose`) and then
49
55
  **rejects** with a `LifecycleStartError`. Register a component with
50
56
  `{ critical: false }` when its failure should not abort startup — the
51
- component is marked `FAILED` and startup continues.
57
+ component is marked `FAILED` and startup continues. A failed component
58
+ takes no further part in startup (its later hooks are not invoked), and
59
+ components that `dependsOn` it are not started either: they are marked
60
+ `FAILED` with a `LifecycleComponentError` naming the failed dependency,
61
+ and their own `critical` flag decides whether startup aborts.
62
+
63
+ Rollback only undoes phases that ran: `stop()` is called on components
64
+ whose `start` phase ran, and `dispose()` on components whose
65
+ `initialize` phase ran.
66
+
67
+ Calling `shutdown()` while `start()` is in flight waits for the
68
+ executing stage to settle, tears down, and makes `start()` reject with a
69
+ `LifecycleStartError` — later stages are never launched.
52
70
 
53
71
  ## Shutdown
54
72
 
@@ -97,8 +115,8 @@ lifecycle.
97
115
  ```typescript
98
116
  createLifecycleManager({
99
117
  concurrency: 10, // parallel component operations per stage
100
- shutdownTimeout: 30_000, // global shutdown deadline (ms)
101
- handleSignals: true, // install process signal handlers
118
+ shutdownTimeout: 30_000, // global shutdown deadline (ms); Infinity = none
119
+ handleSignals: true, // install signal handlers on start()
102
120
  signals: ["SIGINT", "SIGTERM"], // defaults to DEFAULT_SHUTDOWN_SIGNALS
103
121
  });
104
122
  ```
@@ -106,6 +124,18 @@ createLifecycleManager({
106
124
  Per-component: `id`, `dependsOn`, `priority`, `critical`, `timeout`,
107
125
  `retry: { attempts, delay, maxDelay, backoff }`.
108
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
+
109
139
  ## Use Cases
110
140
 
111
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
@@ -47,7 +56,19 @@ export async function withAbort(fn, signal) {
47
56
  reject(new Error("Operation aborted"));
48
57
  };
49
58
  signal.addEventListener("abort", onAbort, { once: true });
50
- fn(signal)
59
+ // A synchronously throwing `fn` rejected via the Promise executor
60
+ // but skipped both `.then` branches, so its abort listener was
61
+ // never removed and accumulated on a long-lived signal.
62
+ let operation;
63
+ try {
64
+ operation = fn(signal);
65
+ }
66
+ catch (error) {
67
+ signal.removeEventListener("abort", onAbort);
68
+ reject(error);
69
+ return;
70
+ }
71
+ operation
51
72
  .then((result) => {
52
73
  signal.removeEventListener("abort", onAbort);
53
74
  resolve(result);
@@ -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
@@ -5,6 +5,7 @@
5
5
  * plus helper functions for component state transitions and event emission.
6
6
  */
7
7
  import { LifecycleState } from "@zudojs/constants";
8
+ import type { LifecyclePhase } from "@zudojs/constants";
8
9
  import type { LifecycleStateMachine } from "../lifecycleState/lifecycleState.machine.js";
9
10
  import type { LifecycleRegistry } from "../lifecycleRegistry/lifecycleRegistry.core.js";
10
11
  import type { LifecycleExecutor } from "../lifecycleExecutor/lifecycleExecutor.core.js";
@@ -20,6 +21,16 @@ export interface LifecycleManagerContext {
20
21
  readonly shutdownTimeout: number;
21
22
  readonly componentStates: Map<string, LifecycleStateMachine>;
22
23
  readonly results: Map<string, ExecutionResult[]>;
24
+ /**
25
+ * Startup phases that were actually run for each component.
26
+ *
27
+ * Shutdown consults this so `stop()` is only invoked on components
28
+ * whose `start` phase ran and `dispose()` only on components whose
29
+ * `initialize` phase ran. Rollback after an early failure used to
30
+ * call `stop()` on components that had never started, which for a
31
+ * real server throws and was then reported as a component failure.
32
+ */
33
+ readonly attempted: Map<string, Set<LifecyclePhase>>;
23
34
  startTime: number;
24
35
  /**
25
36
  * Cancellation source for the current run.
@@ -39,6 +50,16 @@ export interface LifecycleManagerContext {
39
50
  * teardown.
40
51
  */
41
52
  shutdownPromise?: Promise<void>;
53
+ /**
54
+ * The startup stage currently executing, if any.
55
+ *
56
+ * A shutdown requested mid-startup waits for this to settle before
57
+ * running `stop()`, so a component is never stopped while its own
58
+ * `start()` is still in flight, and startup checks for a requested
59
+ * shutdown before launching each further stage so no hook runs
60
+ * after teardown has completed.
61
+ */
62
+ inFlight?: Promise<unknown>;
42
63
  }
43
64
  /** Records an execution result against its component. */
44
65
  export declare function recordResult(ctx: LifecycleManagerContext, result: ExecutionResult): void;
@@ -47,6 +68,18 @@ export declare function recordResult(ctx: LifecycleManagerContext, result: Execu
47
68
  * No-op if the transition is not valid from the current state.
48
69
  */
49
70
  export declare function transitionComponent(ctx: LifecycleManagerContext, id: string, targetState: LifecycleState): void;
71
+ /**
72
+ * Marks a component as FAILED regardless of its current state.
73
+ *
74
+ * Used for components that are skipped because a dependency failed:
75
+ * they have not entered the phase, so no validated transition leads to
76
+ * FAILED from their current (IDLE / INITIALIZED / STARTED) state.
77
+ */
78
+ export declare function failComponent(ctx: LifecycleManagerContext, id: string): void;
79
+ /** Records that a startup phase ran for a component. */
80
+ export declare function markAttempted(ctx: LifecycleManagerContext, id: string, phase: LifecyclePhase): void;
81
+ /** Returns whether a startup phase ran for a component. */
82
+ export declare function wasAttempted(ctx: LifecycleManagerContext, id: string, phase: LifecyclePhase): boolean;
50
83
  /**
51
84
  * Transitions multiple component state machines to the same target state.
52
85
  */
@@ -20,6 +20,26 @@ export function transitionComponent(ctx, id, targetState) {
20
20
  sm.transition(targetState);
21
21
  }
22
22
  }
23
+ /**
24
+ * Marks a component as FAILED regardless of its current state.
25
+ *
26
+ * Used for components that are skipped because a dependency failed:
27
+ * they have not entered the phase, so no validated transition leads to
28
+ * FAILED from their current (IDLE / INITIALIZED / STARTED) state.
29
+ */
30
+ export function failComponent(ctx, id) {
31
+ ctx.componentStates.get(id)?.forceState(LifecycleState.FAILED);
32
+ }
33
+ /** Records that a startup phase ran for a component. */
34
+ export function markAttempted(ctx, id, phase) {
35
+ const phases = ctx.attempted.get(id) ?? new Set();
36
+ phases.add(phase);
37
+ ctx.attempted.set(id, phases);
38
+ }
39
+ /** Returns whether a startup phase ran for a component. */
40
+ export function wasAttempted(ctx, id, phase) {
41
+ return ctx.attempted.get(id)?.has(phase) ?? false;
42
+ }
23
43
  /**
24
44
  * Transitions multiple component state machines to the same target state.
25
45
  */
@@ -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"),
@@ -39,17 +45,16 @@ export class LifecycleManager {
39
45
  shutdownTimeout: options.shutdownTimeout ?? LIFECYCLE_DEFAULT_SHUTDOWN_TIMEOUT,
40
46
  componentStates: new Map(),
41
47
  results: new Map(),
48
+ attempted: new Map(),
42
49
  startTime: 0,
43
50
  controller: new AbortController(),
44
51
  };
45
- if (options.handleSignals !== false) {
46
- this._removeSignalHandlers = installSignalHandlers({
47
- signals: options.signals,
48
- handler: () => {
49
- void this.shutdown();
50
- },
51
- });
52
- }
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;
53
58
  }
54
59
  /** Registers a component with the lifecycle manager. */
55
60
  register(component, options = {}) {
@@ -69,7 +74,20 @@ export class LifecycleManager {
69
74
  if (this._startPromise) {
70
75
  return this._startPromise;
71
76
  }
72
- 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
+ });
73
91
  return this._startPromise;
74
92
  }
75
93
  /**
@@ -79,7 +97,16 @@ export class LifecycleManager {
79
97
  async shutdown() {
80
98
  // performShutdown is itself single-flight, so a shutdown started by
81
99
  // startup rollback and one started here are the SAME run.
82
- 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;
83
110
  }
84
111
  /** Returns the current application state. */
85
112
  get state() {
@@ -111,8 +138,7 @@ export class LifecycleManager {
111
138
  * component teardown — call `shutdown()` first for that.
112
139
  */
113
140
  dispose() {
114
- this._removeSignalHandlers?.();
115
- this._removeSignalHandlers = undefined;
141
+ this.releaseSignalHandlers();
116
142
  this._ctx.events.clear();
117
143
  }
118
144
  }
@@ -6,7 +6,8 @@
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 { emitComponentFailed, recordResult, transitionComponent, } from "./lifecycleManager.context.js";
9
+ import { isBounded, toTimerDelay } from "../lifecycleInternal/index.js";
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];
12
13
  /**
@@ -38,6 +39,16 @@ async function runShutdown(ctx) {
38
39
  }
39
40
  ctx.events.emit("application:stopping", {});
40
41
  const deadline = Date.now() + ctx.shutdownTimeout;
42
+ // A startup stage still executing must settle before its components
43
+ // are stopped, otherwise `stop()` overlaps the component's own
44
+ // `start()`. Startup itself refuses to launch further stages once
45
+ // `shutdownPromise` is set, so this wait is bounded by one stage.
46
+ if (ctx.inFlight !== undefined) {
47
+ await raceDeadline(ctx, ctx.inFlight.then(() => undefined, () => undefined), Math.max(deadline - Date.now(), 1));
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));
41
52
  // The shutdown deadline used to be checked only BETWEEN the two
42
53
  // phases, so a single hook that never settled hung shutdown (and the
43
54
  // process) forever. Race the whole phase against the remaining
@@ -72,12 +83,19 @@ async function runShutdown(ctx) {
72
83
  * so it can never hold the event loop open.
73
84
  */
74
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
+ }
75
93
  let timer;
76
94
  const expiry = new Promise((resolve) => {
77
95
  timer = setTimeout(() => {
78
96
  ctx.controller.abort(new Error(`Lifecycle shutdown exceeded its ${ctx.shutdownTimeout}ms deadline.`));
79
97
  resolve();
80
- }, remainingMs);
98
+ }, toTimerDelay(remainingMs));
81
99
  });
82
100
  try {
83
101
  await Promise.race([phase, expiry]);
@@ -91,11 +109,24 @@ async function raceDeadline(ctx, phase, remainingMs) {
91
109
  // rejection once the race has been decided.
92
110
  void phase.catch(() => { });
93
111
  }
94
- /** Executes a single shutdown phase across all registered components. */
112
+ /**
113
+ * Executes a single shutdown phase across all registered components.
114
+ *
115
+ * A hook only runs for components that reached the matching startup
116
+ * phase: `stop()` when `start` ran, `dispose()` when `initialize` ran.
117
+ * Rollback after an early failure — and `shutdown()` on a manager that
118
+ * was never started — used to call `stop()` on components that had
119
+ * never started; a real server's `close()` throws in that situation
120
+ * and the phantom failure was then recorded against the component.
121
+ */
95
122
  async function executeShutdownPhase(ctx, phase) {
96
123
  const plan = buildExecutionPlan(ctx.registry.getAll(), phase);
97
124
  const context = createLifecycleContext(phase, ctx.startTime, ctx.controller.signal);
98
- const failureState = phase === LifecyclePhase.STOP
125
+ const isStop = phase === LifecyclePhase.STOP;
126
+ const prerequisite = isStop
127
+ ? LifecyclePhase.START
128
+ : LifecyclePhase.INITIALIZE;
129
+ const successState = isStop
99
130
  ? LifecycleState.STOPPED
100
131
  : LifecycleState.DISPOSED;
101
132
  for (const stage of plan.stages) {
@@ -104,13 +135,30 @@ async function executeShutdownPhase(ctx, phase) {
104
135
  .filter((r) => r !== undefined);
105
136
  if (stageRegs.length === 0)
106
137
  continue;
138
+ const runnable = [];
107
139
  for (const reg of stageRegs) {
108
- transitionComponent(ctx, reg.id, LifecycleState.STOPPING);
140
+ if (!wasAttempted(ctx, reg.id, prerequisite)) {
141
+ // Never reached the phase this hook undoes. It still ends up
142
+ // DISPOSED so status reflects the teardown.
143
+ if (!isStop) {
144
+ transitionComponent(ctx, reg.id, LifecycleState.DISPOSED);
145
+ }
146
+ continue;
147
+ }
148
+ runnable.push(reg);
149
+ // Only the stop phase moves a component into STOPPING; dispose
150
+ // runs from STOPPED (or FAILED) and transitions straight to
151
+ // DISPOSED.
152
+ if (isStop) {
153
+ transitionComponent(ctx, reg.id, LifecycleState.STOPPING);
154
+ }
109
155
  ctx.events.emit("component:stopping", {
110
156
  component: { componentId: reg.id },
111
157
  });
112
158
  }
113
- const results = await ctx.executor.executeStage(stageRegs, phase, context, ctx.concurrency);
159
+ if (runnable.length === 0)
160
+ continue;
161
+ const results = await ctx.executor.executeStage(runnable, phase, context, ctx.concurrency);
114
162
  // Shutdown results used to be discarded entirely: a component whose
115
163
  // stop() or dispose() threw was still reported as cleanly STOPPED,
116
164
  // its failure never reached getStatus() or the event stream, and
@@ -118,7 +166,7 @@ async function executeShutdownPhase(ctx, phase) {
118
166
  for (const result of results) {
119
167
  recordResult(ctx, result);
120
168
  if (result.success) {
121
- transitionComponent(ctx, result.id, failureState);
169
+ transitionComponent(ctx, result.id, successState);
122
170
  ctx.events.emit("component:stopped", {
123
171
  component: { componentId: result.id, duration: result.duration },
124
172
  });
@@ -4,10 +4,10 @@
4
4
  * Startup orchestration — initializes, starts, and readies components.
5
5
  */
6
6
  import { LifecyclePhase, LifecycleState } from "@zudojs/constants";
7
- import { LifecycleStartError } from "@zudojs/errors";
7
+ import { LifecycleComponentError, LifecycleStartError } from "@zudojs/errors";
8
8
  import { buildExecutionPlan } from "../lifecyclePlan/lifecyclePlan.core.js";
9
9
  import { createLifecycleContext } from "../lifecycleContext/lifecycleContext.type.js";
10
- import { transitionComponent, transitionComponentBatch, emitComponentFailed, recordResult, } from "./lifecycleManager.context.js";
10
+ import { transitionComponent, transitionComponentBatch, emitComponentFailed, failComponent, markAttempted, recordResult, } from "./lifecycleManager.context.js";
11
11
  import { performShutdown } from "./lifecycleManager.shutdown.js";
12
12
  /** Maps startup phases to their target component state after success. */
13
13
  const SUCCESS_STATE = {
@@ -68,6 +68,7 @@ export async function performStartup(ctx) {
68
68
  if (failedInit) {
69
69
  throw new LifecycleStartError(failedInit.id, failedInit.error);
70
70
  }
71
+ assertNotShuttingDown(ctx, LifecyclePhase.INITIALIZE);
71
72
  ctx.state.transition(LifecycleState.INITIALIZED);
72
73
  ctx.events.emit("application:initialized", {
73
74
  duration: Date.now() - ctx.startTime,
@@ -77,6 +78,7 @@ export async function performStartup(ctx) {
77
78
  if (failedStart) {
78
79
  throw new LifecycleStartError(failedStart.id, failedStart.error);
79
80
  }
81
+ assertNotShuttingDown(ctx, LifecyclePhase.START);
80
82
  ctx.state.transition(LifecycleState.STARTED);
81
83
  // A failing `ready` hook on a critical component used to be
82
84
  // ignored completely: no state change, no rollback, and start()
@@ -85,6 +87,7 @@ export async function performStartup(ctx) {
85
87
  if (failedReady) {
86
88
  throw new LifecycleStartError(failedReady.id, failedReady.error);
87
89
  }
90
+ assertNotShuttingDown(ctx, LifecyclePhase.READY);
88
91
  ctx.state.transition(LifecycleState.READY);
89
92
  ctx.events.emit("application:ready", {
90
93
  duration: Date.now() - ctx.startTime,
@@ -94,7 +97,10 @@ export async function performStartup(ctx) {
94
97
  // Rollback happens on exactly one path, so a completed teardown is
95
98
  // never re-entered and its DISPOSED state is never overwritten
96
99
  // with FAILED.
97
- if (ctx.state.state !== LifecycleState.FAILED &&
100
+ // When the failure IS a requested shutdown, the teardown already
101
+ // owns the application state.
102
+ if (ctx.shutdownPromise === undefined &&
103
+ ctx.state.state !== LifecycleState.FAILED &&
98
104
  ctx.state.state !== LifecycleState.DISPOSED) {
99
105
  ctx.state.forceState(LifecycleState.FAILED);
100
106
  }
@@ -102,9 +108,40 @@ export async function performStartup(ctx) {
102
108
  throw error;
103
109
  }
104
110
  }
111
+ /**
112
+ * Throws when a shutdown has been requested while startup is running.
113
+ *
114
+ * Startup used to keep launching later stages after `shutdown()` had
115
+ * already torn everything down: a component started that way was
116
+ * never stopped, and the eventual failure was an opaque
117
+ * LifecycleStateError from the DISPOSED → INITIALIZED transition.
118
+ */
119
+ function assertNotShuttingDown(ctx, phase) {
120
+ if (ctx.shutdownPromise !== undefined) {
121
+ throw new LifecycleStartError("application", new LifecycleComponentError("application", phase, new Error("Startup was cancelled because shutdown was requested while the application was starting.")));
122
+ }
123
+ }
105
124
  /**
106
125
  * Executes a single startup phase across all registered components.
107
126
  * Returns the failure of the first critical component, or undefined.
127
+ *
128
+ * Two bookkeeping rules apply to every stage:
129
+ *
130
+ * - A component that FAILED an earlier phase, or whose dependency has
131
+ * failed, does not enter this phase. It used to have `start()` and
132
+ * `ready()` invoked after its own `initialize()` had thrown, and its
133
+ * dependents were started as if the dependency were healthy —
134
+ * silently voiding the `dependsOn` contract. A skipped dependent is
135
+ * recorded as FAILED with a LifecycleComponentError naming the
136
+ * failed dependency, and its own `critical` flag decides whether
137
+ * startup aborts.
138
+ *
139
+ * - Every result of a stage is recorded, transitioned and announced
140
+ * before a critical failure aborts the phase. Returning on the first
141
+ * failed result dropped the results of siblings in the same stage,
142
+ * which were then left in INITIALIZING / STARTING forever (no
143
+ * transition leads out of those states except to their success or
144
+ * FAILED) even after rollback had disposed them.
108
145
  */
109
146
  async function executePhase(ctx, phase) {
110
147
  const plan = buildExecutionPlan(ctx.registry.getAll(), phase);
@@ -117,21 +154,62 @@ async function executePhase(ctx, phase) {
117
154
  .filter((r) => r !== undefined);
118
155
  if (stageRegs.length === 0)
119
156
  continue;
120
- transitionComponentBatch(ctx, stageRegs.map((r) => r.id), EXECUTING_STATE[phase]);
157
+ assertNotShuttingDown(ctx, phase);
158
+ const runnable = [];
159
+ let criticalFailure;
121
160
  for (const reg of stageRegs) {
161
+ if (ctx.componentStates.get(reg.id)?.state === LifecycleState.FAILED) {
162
+ // Already failed in an earlier phase; nothing more to run.
163
+ continue;
164
+ }
165
+ const failedDependency = reg.dependsOn.find((dep) => ctx.componentStates.get(dep)?.state === LifecycleState.FAILED);
166
+ if (failedDependency === undefined) {
167
+ runnable.push(reg);
168
+ continue;
169
+ }
170
+ const skipped = {
171
+ id: reg.id,
172
+ phase,
173
+ duration: 0,
174
+ success: false,
175
+ error: new LifecycleComponentError(reg.id, phase, new Error(`Component "${reg.id}" was not started because its dependency "${failedDependency}" failed.`)),
176
+ };
177
+ recordResult(ctx, skipped);
178
+ failComponent(ctx, reg.id);
179
+ emitComponentFailed(ctx, skipped);
180
+ if (reg.critical) {
181
+ criticalFailure ??= { id: reg.id, error: skipped.error };
182
+ }
183
+ }
184
+ if (criticalFailure) {
185
+ ctx.state.forceState(LifecycleState.FAILED);
186
+ return criticalFailure;
187
+ }
188
+ if (runnable.length === 0)
189
+ continue;
190
+ transitionComponentBatch(ctx, runnable.map((r) => r.id), EXECUTING_STATE[phase]);
191
+ for (const reg of runnable) {
192
+ markAttempted(ctx, reg.id, phase);
122
193
  ctx.events.emit(events.begin, {
123
194
  component: { componentId: reg.id },
124
195
  });
125
196
  }
126
- const results = await ctx.executor.executeStage(stageRegs, phase, context, ctx.concurrency);
197
+ const pending = ctx.executor.executeStage(runnable, phase, context, ctx.concurrency);
198
+ ctx.inFlight = pending;
199
+ let results;
200
+ try {
201
+ results = await pending;
202
+ }
203
+ finally {
204
+ ctx.inFlight = undefined;
205
+ }
127
206
  for (const result of results) {
128
207
  recordResult(ctx, result);
129
208
  if (!result.success) {
130
209
  transitionComponent(ctx, result.id, LifecycleState.FAILED);
131
210
  emitComponentFailed(ctx, result);
132
211
  if (ctx.registry.get(result.id)?.critical) {
133
- ctx.state.forceState(LifecycleState.FAILED);
134
- return { id: result.id, error: result.error };
212
+ criticalFailure ??= { id: result.id, error: result.error };
135
213
  }
136
214
  }
137
215
  else {
@@ -141,6 +219,10 @@ async function executePhase(ctx, phase) {
141
219
  });
142
220
  }
143
221
  }
222
+ if (criticalFailure) {
223
+ ctx.state.forceState(LifecycleState.FAILED);
224
+ return criticalFailure;
225
+ }
144
226
  }
145
227
  return undefined;
146
228
  }
@@ -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,8 +1,12 @@
1
1
  {
2
2
  "name": "@zudojs/lifecycle",
3
- "version": "1.0.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
+ "author": {
7
+ "name": "Oluwayemi Oyinlola",
8
+ "url": "https://github.com/oyinlola-tech"
9
+ },
6
10
  "type": "module",
7
11
  "main": "./dist/index.js",
8
12
  "module": "./dist/index.js",
@@ -20,8 +24,8 @@
20
24
  "!dist/.tsbuildinfo"
21
25
  ],
22
26
  "dependencies": {
23
- "@zudojs/errors": "1.0.0",
24
- "@zudojs/constants": "1.0.0"
27
+ "@zudojs/errors": "1.1.0",
28
+ "@zudojs/constants": "1.1.0"
25
29
  },
26
30
  "devDependencies": {
27
31
  "@types/node": "^26.4.1",