@zudojs/lifecycle 1.0.0 → 1.1.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
@@ -48,7 +48,19 @@ If a **critical** component (the default) fails any startup phase,
48
48
  `start()` rolls the application back (`stop` → `dispose`) and then
49
49
  **rejects** with a `LifecycleStartError`. Register a component with
50
50
  `{ critical: false }` when its failure should not abort startup — the
51
- component is marked `FAILED` and startup continues.
51
+ component is marked `FAILED` and startup continues. A failed component
52
+ takes no further part in startup (its later hooks are not invoked), and
53
+ components that `dependsOn` it are not started either: they are marked
54
+ `FAILED` with a `LifecycleComponentError` naming the failed dependency,
55
+ and their own `critical` flag decides whether startup aborts.
56
+
57
+ Rollback only undoes phases that ran: `stop()` is called on components
58
+ whose `start` phase ran, and `dispose()` on components whose
59
+ `initialize` phase ran.
60
+
61
+ Calling `shutdown()` while `start()` is in flight waits for the
62
+ executing stage to settle, tears down, and makes `start()` reject with a
63
+ `LifecycleStartError` — later stages are never launched.
52
64
 
53
65
  ## Shutdown
54
66
 
@@ -47,7 +47,19 @@ export async function withAbort(fn, signal) {
47
47
  reject(new Error("Operation aborted"));
48
48
  };
49
49
  signal.addEventListener("abort", onAbort, { once: true });
50
- fn(signal)
50
+ // A synchronously throwing `fn` rejected via the Promise executor
51
+ // but skipped both `.then` branches, so its abort listener was
52
+ // never removed and accumulated on a long-lived signal.
53
+ let operation;
54
+ try {
55
+ operation = fn(signal);
56
+ }
57
+ catch (error) {
58
+ signal.removeEventListener("abort", onAbort);
59
+ reject(error);
60
+ return;
61
+ }
62
+ operation
51
63
  .then((result) => {
52
64
  signal.removeEventListener("abort", onAbort);
53
65
  resolve(result);
@@ -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
  */
@@ -39,6 +39,7 @@ export class LifecycleManager {
39
39
  shutdownTimeout: options.shutdownTimeout ?? LIFECYCLE_DEFAULT_SHUTDOWN_TIMEOUT,
40
40
  componentStates: new Map(),
41
41
  results: new Map(),
42
+ attempted: new Map(),
42
43
  startTime: 0,
43
44
  controller: new AbortController(),
44
45
  };
@@ -6,7 +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 { emitComponentFailed, recordResult, transitionComponent, } from "./lifecycleManager.context.js";
9
+ import { emitComponentFailed, recordResult, transitionComponent, wasAttempted, } from "./lifecycleManager.context.js";
10
10
  /** Shutdown phases in execution order. */
11
11
  const SHUTDOWN_PHASES = [LifecyclePhase.STOP, LifecyclePhase.DISPOSE];
12
12
  /**
@@ -38,6 +38,13 @@ async function runShutdown(ctx) {
38
38
  }
39
39
  ctx.events.emit("application:stopping", {});
40
40
  const deadline = Date.now() + ctx.shutdownTimeout;
41
+ // A startup stage still executing must settle before its components
42
+ // are stopped, otherwise `stop()` overlaps the component's own
43
+ // `start()`. Startup itself refuses to launch further stages once
44
+ // `shutdownPromise` is set, so this wait is bounded by one stage.
45
+ if (ctx.inFlight !== undefined) {
46
+ await raceDeadline(ctx, ctx.inFlight.then(() => undefined, () => undefined), Math.max(deadline - Date.now(), 1));
47
+ }
41
48
  // The shutdown deadline used to be checked only BETWEEN the two
42
49
  // phases, so a single hook that never settled hung shutdown (and the
43
50
  // process) forever. Race the whole phase against the remaining
@@ -91,11 +98,24 @@ async function raceDeadline(ctx, phase, remainingMs) {
91
98
  // rejection once the race has been decided.
92
99
  void phase.catch(() => { });
93
100
  }
94
- /** Executes a single shutdown phase across all registered components. */
101
+ /**
102
+ * Executes a single shutdown phase across all registered components.
103
+ *
104
+ * A hook only runs for components that reached the matching startup
105
+ * phase: `stop()` when `start` ran, `dispose()` when `initialize` ran.
106
+ * Rollback after an early failure — and `shutdown()` on a manager that
107
+ * was never started — used to call `stop()` on components that had
108
+ * never started; a real server's `close()` throws in that situation
109
+ * and the phantom failure was then recorded against the component.
110
+ */
95
111
  async function executeShutdownPhase(ctx, phase) {
96
112
  const plan = buildExecutionPlan(ctx.registry.getAll(), phase);
97
113
  const context = createLifecycleContext(phase, ctx.startTime, ctx.controller.signal);
98
- const failureState = phase === LifecyclePhase.STOP
114
+ const isStop = phase === LifecyclePhase.STOP;
115
+ const prerequisite = isStop
116
+ ? LifecyclePhase.START
117
+ : LifecyclePhase.INITIALIZE;
118
+ const successState = isStop
99
119
  ? LifecycleState.STOPPED
100
120
  : LifecycleState.DISPOSED;
101
121
  for (const stage of plan.stages) {
@@ -104,13 +124,30 @@ async function executeShutdownPhase(ctx, phase) {
104
124
  .filter((r) => r !== undefined);
105
125
  if (stageRegs.length === 0)
106
126
  continue;
127
+ const runnable = [];
107
128
  for (const reg of stageRegs) {
108
- transitionComponent(ctx, reg.id, LifecycleState.STOPPING);
129
+ if (!wasAttempted(ctx, reg.id, prerequisite)) {
130
+ // Never reached the phase this hook undoes. It still ends up
131
+ // DISPOSED so status reflects the teardown.
132
+ if (!isStop) {
133
+ transitionComponent(ctx, reg.id, LifecycleState.DISPOSED);
134
+ }
135
+ continue;
136
+ }
137
+ runnable.push(reg);
138
+ // Only the stop phase moves a component into STOPPING; dispose
139
+ // runs from STOPPED (or FAILED) and transitions straight to
140
+ // DISPOSED.
141
+ if (isStop) {
142
+ transitionComponent(ctx, reg.id, LifecycleState.STOPPING);
143
+ }
109
144
  ctx.events.emit("component:stopping", {
110
145
  component: { componentId: reg.id },
111
146
  });
112
147
  }
113
- const results = await ctx.executor.executeStage(stageRegs, phase, context, ctx.concurrency);
148
+ if (runnable.length === 0)
149
+ continue;
150
+ const results = await ctx.executor.executeStage(runnable, phase, context, ctx.concurrency);
114
151
  // Shutdown results used to be discarded entirely: a component whose
115
152
  // stop() or dispose() threw was still reported as cleanly STOPPED,
116
153
  // its failure never reached getStatus() or the event stream, and
@@ -118,7 +155,7 @@ async function executeShutdownPhase(ctx, phase) {
118
155
  for (const result of results) {
119
156
  recordResult(ctx, result);
120
157
  if (result.success) {
121
- transitionComponent(ctx, result.id, failureState);
158
+ transitionComponent(ctx, result.id, successState);
122
159
  ctx.events.emit("component:stopped", {
123
160
  component: { componentId: result.id, duration: result.duration },
124
161
  });
@@ -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
  }
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@zudojs/lifecycle",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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.0.1",
28
+ "@zudojs/constants": "1.0.1"
25
29
  },
26
30
  "devDependencies": {
27
31
  "@types/node": "^26.4.1",