@zudojs/lifecycle 1.1.0 → 1.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
@@ -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,25 @@ createLifecycleManager({
118
124
  Per-component: `id`, `dependsOn`, `priority`, `critical`, `timeout`,
119
125
  `retry: { attempts, delay, maxDelay, backoff }`.
120
126
 
127
+ `priority` orders components that share a dependency level, and it is a
128
+ barrier rather than a hint: every component at one priority completes the
129
+ phase before the next priority starts, so `priority: 10` really does start
130
+ before `priority: 0`. Components sharing a priority still run together, up
131
+ to `concurrency`. Shutdown mirrors it — within a level the lowest priority
132
+ stops first and the highest stops last.
133
+
134
+ `timeout` and `shutdownTimeout` accept `Infinity` for "no bound"; NaN and
135
+ negative values throw a `RangeError` when registered or constructed, and
136
+ finite values above 2^31-1 ms are clamped to the largest timer delay.
137
+ `retry` covers hooks that fail; a hook that times out is not retried,
138
+ because it is still running and a second call would overlap it.
139
+ `shutdown()` waits (within its deadline) for such an abandoned hook to
140
+ settle before calling `stop()`.
141
+
142
+ With `handleSignals`, SIGINT/SIGTERM listeners are installed by `start()`,
143
+ not by the constructor, and removed once shutdown finishes. A second
144
+ signal while shutdown is running exits the process with code 1.
145
+
121
146
  ## Use Cases
122
147
 
123
148
  - Coordinating service startup and shutdown
@@ -28,11 +28,21 @@ export interface LifecycleRegistrationOptions {
28
28
  readonly id?: string;
29
29
  /** IDs of components that must start before this one. */
30
30
  readonly dependsOn?: readonly string[];
31
- /** Priority for ordering within the same dependency level. Higher = earlier. */
31
+ /**
32
+ * Priority for ordering within the same dependency level. Higher = earlier.
33
+ *
34
+ * Priority is a barrier, not a hint: every component at one priority
35
+ * finishes the phase before the next priority begins, and shutdown
36
+ * runs the mirror image (lowest priority stops first). Components
37
+ * sharing a priority still run concurrently.
38
+ */
32
39
  readonly priority?: number;
33
40
  /** If true, application startup fails when this component fails. Defaults to true. */
34
41
  readonly critical?: boolean;
35
- /** Timeout in ms for individual component operations. */
42
+ /**
43
+ * Timeout in ms for individual component operations. `Infinity` means
44
+ * no bound; NaN and negative values are rejected at registration.
45
+ */
36
46
  readonly timeout?: number;
37
47
  /** Retry configuration for failed operations. */
38
48
  readonly retry?: LifecycleRetryOptions;
@@ -23,12 +23,29 @@ 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
  */
29
38
  execute(registration: LifecycleRegistration, phase: LifecyclePhase, context: LifecycleContext): Promise<ExecutionResult>;
30
39
  /**
31
- * Executes a stage of components in parallel with concurrency limit.
40
+ * Executes a stage of components, honouring priority as a barrier.
41
+ *
42
+ * The stage arrives already ordered by priority (descending for
43
+ * startup, ascending for shutdown). Components sharing a priority run
44
+ * together, limited by `concurrency`; the next priority group only
45
+ * begins once the previous one has settled. Launching the whole stage
46
+ * concurrently made `priority` observable only at `concurrency: 1`,
47
+ * so a `priority: 100` component documented as starting first lost
48
+ * the race to any sibling with a faster hook.
32
49
  */
33
50
  executeStage(registrations: readonly LifecycleRegistration[], phase: LifecyclePhase, context: LifecycleContext, concurrency: number): Promise<readonly ExecutionResult[]>;
34
51
  }
@@ -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);
@@ -72,17 +96,49 @@ export class LifecycleExecutor {
72
96
  };
73
97
  }
74
98
  /**
75
- * Executes a stage of components in parallel with concurrency limit.
99
+ * Executes a stage of components, honouring priority as a barrier.
100
+ *
101
+ * The stage arrives already ordered by priority (descending for
102
+ * startup, ascending for shutdown). Components sharing a priority run
103
+ * together, limited by `concurrency`; the next priority group only
104
+ * begins once the previous one has settled. Launching the whole stage
105
+ * concurrently made `priority` observable only at `concurrency: 1`,
106
+ * so a `priority: 100` component documented as starting first lost
107
+ * the race to any sibling with a faster hook.
76
108
  */
77
109
  async executeStage(registrations, phase, context, concurrency) {
78
110
  const results = [];
79
- await withConcurrency(registrations, concurrency, async (reg) => {
80
- const result = await this.execute(reg, phase, context);
81
- results.push(result);
82
- });
111
+ for (const batch of groupByPriority(registrations)) {
112
+ await withConcurrency(batch, concurrency, async (reg) => {
113
+ const result = await this.execute(reg, phase, context);
114
+ results.push(result);
115
+ });
116
+ }
83
117
  return results;
84
118
  }
85
119
  }
120
+ /**
121
+ * Splits an already-ordered stage into runs of equal priority.
122
+ *
123
+ * Consecutive grouping preserves whatever order the execution plan
124
+ * produced, so a caller that does not care about priority (every
125
+ * component at the default 0) still gets a single fully concurrent
126
+ * batch.
127
+ */
128
+ function groupByPriority(registrations) {
129
+ const batches = [];
130
+ let current;
131
+ let currentPriority;
132
+ for (const reg of registrations) {
133
+ if (current === undefined || reg.priority !== currentPriority) {
134
+ current = [];
135
+ currentPriority = reg.priority;
136
+ batches.push(current);
137
+ }
138
+ current.push(reg);
139
+ }
140
+ return batches;
141
+ }
86
142
  /** Calculates retry delay with backoff. */
87
143
  function calculateDelay(config, attempt) {
88
144
  const base = config.delay ?? 500;
@@ -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
  /**
@@ -3,16 +3,25 @@
3
3
  *
4
4
  * Async utilities for timeout, abort, and concurrency control.
5
5
  */
6
- import { LifecycleTimeoutError } from "@zudojs/errors";
6
+ import { ErrorCode, LifecycleError, 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
@@ -40,11 +49,11 @@ export async function withTimeout(fn, timeoutMs, componentId, phase) {
40
49
  */
41
50
  export async function withAbort(fn, signal) {
42
51
  if (signal.aborted) {
43
- throw new Error("Operation aborted");
52
+ throw abortError();
44
53
  }
45
54
  return new Promise((resolve, reject) => {
46
55
  const onAbort = () => {
47
- reject(new Error("Operation aborted"));
56
+ reject(abortError());
48
57
  };
49
58
  signal.addEventListener("abort", onAbort, { once: true });
50
59
  // A synchronously throwing `fn` rejected via the Promise executor
@@ -70,6 +79,12 @@ export async function withAbort(fn, signal) {
70
79
  });
71
80
  });
72
81
  }
82
+ /** The typed error surfaced when a `withAbort` operation is cancelled. */
83
+ function abortError() {
84
+ return new LifecycleError("Operation aborted", {
85
+ code: ErrorCode.LIFECYCLE_COMPONENT,
86
+ });
87
+ }
73
88
  /**
74
89
  * Executes async operations with a concurrency limit.
75
90
  */
@@ -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
@@ -14,6 +14,13 @@ export type TopologicalStage = readonly string[];
14
14
  export declare function topologicalSort(graph: DependencyGraph, priorities?: ReadonlyMap<string, number>): readonly TopologicalStage[];
15
15
  /**
16
16
  * Performs reverse topological sort for shutdown ordering.
17
+ *
18
+ * Both the stage list AND each stage's contents are reversed, so a
19
+ * shutdown is the exact mirror of the startup order: within a stage the
20
+ * lowest-priority component is torn down first and the highest-priority
21
+ * one last. Only the stage list used to be reversed, which left every
22
+ * stage in descending-priority order — harmless while stages ran fully
23
+ * concurrently, but wrong now that priority is a real sub-stage barrier.
17
24
  */
18
25
  export declare function reverseTopologicalSort(graph: DependencyGraph, priorities?: ReadonlyMap<string, number>): readonly TopologicalStage[];
19
26
  //# sourceMappingURL=topologicalSort.core.d.ts.map
@@ -42,9 +42,16 @@ export function topologicalSort(graph, priorities) {
42
42
  }
43
43
  /**
44
44
  * Performs reverse topological sort for shutdown ordering.
45
+ *
46
+ * Both the stage list AND each stage's contents are reversed, so a
47
+ * shutdown is the exact mirror of the startup order: within a stage the
48
+ * lowest-priority component is torn down first and the highest-priority
49
+ * one last. Only the stage list used to be reversed, which left every
50
+ * stage in descending-priority order — harmless while stages ran fully
51
+ * concurrently, but wrong now that priority is a real sub-stage barrier.
45
52
  */
46
53
  export function reverseTopologicalSort(graph, priorities) {
47
54
  const stages = topologicalSort(graph, priorities);
48
- return Object.freeze([...stages].reverse().map((stage) => Object.freeze([...stage])));
55
+ return Object.freeze([...stages].reverse().map((stage) => Object.freeze([...stage].reverse())));
49
56
  }
50
57
  //# sourceMappingURL=topologicalSort.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
@@ -62,6 +66,14 @@ async function runShutdown(ctx) {
62
66
  catch {
63
67
  // Shutdown must continue even if individual components fail.
64
68
  }
69
+ // A stop()/dispose() hook that blew its own component timeout joins
70
+ // the abandoned set DURING this phase, so the pre-phase settle above
71
+ // cannot have covered it. Without this wait, DISPOSE ran on top of a
72
+ // stop() that was still draining and shutdown() resolved (reporting
73
+ // DISPOSED) while the hook kept running — the exact overlap the
74
+ // pre-phase settle was added to prevent. Still bounded by the global
75
+ // shutdown deadline.
76
+ await raceDeadline(ctx, ctx.executor.settleAbandoned(), Math.max(deadline - Date.now(), 1));
65
77
  }
66
78
  ctx.state.forceState(LifecycleState.DISPOSED);
67
79
  ctx.events.emit("application:stopped", {
@@ -79,12 +91,19 @@ async function runShutdown(ctx) {
79
91
  * so it can never hold the event loop open.
80
92
  */
81
93
  async function raceDeadline(ctx, phase, remainingMs) {
94
+ // An unbounded budget (shutdownTimeout: Infinity) waits for the phase.
95
+ // Handing Infinity to setTimeout fired after 1 ms and abandoned every
96
+ // stop()/dispose() while reporting the application DISPOSED.
97
+ if (!isBounded(remainingMs)) {
98
+ await phase.catch(() => { });
99
+ return;
100
+ }
82
101
  let timer;
83
102
  const expiry = new Promise((resolve) => {
84
103
  timer = setTimeout(() => {
85
104
  ctx.controller.abort(new Error(`Lifecycle shutdown exceeded its ${ctx.shutdownTimeout}ms deadline.`));
86
105
  resolve();
87
- }, remainingMs);
106
+ }, toTimerDelay(remainingMs));
88
107
  });
89
108
  try {
90
109
  await Promise.race([phase, expiry]);
@@ -4,7 +4,8 @@
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
+ import { ErrorCode, LifecycleError } from "@zudojs/errors";
8
9
  /**
9
10
  * Registry for lifecycle components.
10
11
  * Validates registration, builds dependency graph, and freezes on demand.
@@ -16,11 +17,17 @@ export class LifecycleRegistry {
16
17
  /** Registers a component with optional configuration. */
17
18
  register(component, options = {}) {
18
19
  if (this._frozen) {
19
- throw new Error("Cannot register components after registry is frozen");
20
+ throw new LifecycleError("Cannot register components after registry is frozen", { code: ErrorCode.LIFECYCLE_COMPONENT });
20
21
  }
21
22
  const id = options.id ?? component.name;
23
+ if (options.timeout !== undefined) {
24
+ assertTimeoutBudget(`Component "${id}" timeout`, options.timeout);
25
+ }
22
26
  if (this._registrations.has(id)) {
23
- throw new Error(`Component "${id}" is already registered`);
27
+ throw new LifecycleError(`Component "${id}" is already registered`, {
28
+ code: ErrorCode.LIFECYCLE_COMPONENT,
29
+ componentId: id,
30
+ });
24
31
  }
25
32
  const registration = {
26
33
  id,
@@ -48,7 +55,11 @@ export class LifecycleRegistry {
48
55
  for (const [id, reg] of this._registrations) {
49
56
  for (const dep of reg.dependsOn) {
50
57
  if (!this._registrations.has(dep)) {
51
- throw new Error(`Component "${id}" depends on "${dep}" which is not registered`);
58
+ throw new LifecycleError(`Component "${id}" depends on "${dep}" which is not registered`, {
59
+ code: ErrorCode.LIFECYCLE_DEPENDENCY,
60
+ componentId: id,
61
+ metadata: { dependency: dep },
62
+ });
52
63
  }
53
64
  }
54
65
  }
@@ -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.2.0",
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.2.0",
28
+ "@zudojs/constants": "1.1.1"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^26.4.1",