@zudojs/lifecycle 0.1.0 → 1.0.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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -12
  3. package/dist/lifecycleExecutor/lifecycleExecutor.core.js +36 -5
  4. package/dist/lifecycleInternal/asyncUtils.core.js +45 -13
  5. package/dist/lifecycleManager/lifecycleManager.context.d.ts +20 -0
  6. package/dist/lifecycleManager/lifecycleManager.context.js +5 -0
  7. package/dist/lifecycleManager/lifecycleManager.core.d.ts +6 -2
  8. package/dist/lifecycleManager/lifecycleManager.core.js +11 -7
  9. package/dist/lifecycleManager/lifecycleManager.shutdown.d.ts +4 -1
  10. package/dist/lifecycleManager/lifecycleManager.shutdown.js +84 -12
  11. package/dist/lifecycleManager/lifecycleManager.startup.d.ts +6 -0
  12. package/dist/lifecycleManager/lifecycleManager.startup.js +80 -33
  13. package/dist/lifecyclePhase/lifecyclePhase.core.d.ts +7 -1
  14. package/dist/lifecyclePhase/lifecyclePhase.core.js +8 -13
  15. package/dist/lifecyclePlan/lifecyclePlan.core.js +7 -3
  16. package/dist/lifecycleSignal/lifecycleSignal.handler.d.ts +2 -2
  17. package/dist/lifecycleSignal/lifecycleSignal.handler.js +6 -3
  18. package/package.json +21 -14
  19. package/dist/index.d.ts.map +0 -1
  20. package/dist/index.js.map +0 -1
  21. package/dist/lifecycleComponent/index.d.ts.map +0 -1
  22. package/dist/lifecycleComponent/index.js.map +0 -1
  23. package/dist/lifecycleComponent/lifecycleComponent.type.d.ts.map +0 -1
  24. package/dist/lifecycleComponent/lifecycleComponent.type.js.map +0 -1
  25. package/dist/lifecycleContext/index.d.ts.map +0 -1
  26. package/dist/lifecycleContext/index.js.map +0 -1
  27. package/dist/lifecycleContext/lifecycleContext.type.d.ts.map +0 -1
  28. package/dist/lifecycleContext/lifecycleContext.type.js.map +0 -1
  29. package/dist/lifecycleEvents/index.d.ts.map +0 -1
  30. package/dist/lifecycleEvents/index.js.map +0 -1
  31. package/dist/lifecycleEvents/lifecycleEvents.core.d.ts.map +0 -1
  32. package/dist/lifecycleEvents/lifecycleEvents.core.js.map +0 -1
  33. package/dist/lifecycleExecutor/index.d.ts.map +0 -1
  34. package/dist/lifecycleExecutor/index.js.map +0 -1
  35. package/dist/lifecycleExecutor/lifecycleExecutor.core.d.ts.map +0 -1
  36. package/dist/lifecycleExecutor/lifecycleExecutor.core.js.map +0 -1
  37. package/dist/lifecycleInternal/asyncUtils.core.d.ts.map +0 -1
  38. package/dist/lifecycleInternal/asyncUtils.core.js.map +0 -1
  39. package/dist/lifecycleInternal/dependencyGraph.core.d.ts.map +0 -1
  40. package/dist/lifecycleInternal/dependencyGraph.core.js.map +0 -1
  41. package/dist/lifecycleInternal/index.d.ts.map +0 -1
  42. package/dist/lifecycleInternal/index.js.map +0 -1
  43. package/dist/lifecycleInternal/topologicalSort.core.d.ts.map +0 -1
  44. package/dist/lifecycleInternal/topologicalSort.core.js.map +0 -1
  45. package/dist/lifecycleManager/index.d.ts.map +0 -1
  46. package/dist/lifecycleManager/index.js.map +0 -1
  47. package/dist/lifecycleManager/lifecycleManager.context.d.ts.map +0 -1
  48. package/dist/lifecycleManager/lifecycleManager.context.js.map +0 -1
  49. package/dist/lifecycleManager/lifecycleManager.core.d.ts.map +0 -1
  50. package/dist/lifecycleManager/lifecycleManager.core.js.map +0 -1
  51. package/dist/lifecycleManager/lifecycleManager.shutdown.d.ts.map +0 -1
  52. package/dist/lifecycleManager/lifecycleManager.shutdown.js.map +0 -1
  53. package/dist/lifecycleManager/lifecycleManager.startup.d.ts.map +0 -1
  54. package/dist/lifecycleManager/lifecycleManager.startup.js.map +0 -1
  55. package/dist/lifecyclePhase/index.d.ts.map +0 -1
  56. package/dist/lifecyclePhase/index.js.map +0 -1
  57. package/dist/lifecyclePhase/lifecyclePhase.core.d.ts.map +0 -1
  58. package/dist/lifecyclePhase/lifecyclePhase.core.js.map +0 -1
  59. package/dist/lifecyclePhase/lifecyclePhase.type.d.ts +0 -16
  60. package/dist/lifecyclePhase/lifecyclePhase.type.d.ts.map +0 -1
  61. package/dist/lifecyclePhase/lifecyclePhase.type.js +0 -17
  62. package/dist/lifecyclePhase/lifecyclePhase.type.js.map +0 -1
  63. package/dist/lifecyclePlan/index.d.ts.map +0 -1
  64. package/dist/lifecyclePlan/index.js.map +0 -1
  65. package/dist/lifecyclePlan/lifecyclePlan.core.d.ts.map +0 -1
  66. package/dist/lifecyclePlan/lifecyclePlan.core.js.map +0 -1
  67. package/dist/lifecycleRegistry/index.d.ts.map +0 -1
  68. package/dist/lifecycleRegistry/index.js.map +0 -1
  69. package/dist/lifecycleRegistry/lifecycleRegistry.core.d.ts.map +0 -1
  70. package/dist/lifecycleRegistry/lifecycleRegistry.core.js.map +0 -1
  71. package/dist/lifecycleSignal/index.d.ts.map +0 -1
  72. package/dist/lifecycleSignal/index.js.map +0 -1
  73. package/dist/lifecycleSignal/lifecycleSignal.handler.d.ts.map +0 -1
  74. package/dist/lifecycleSignal/lifecycleSignal.handler.js.map +0 -1
  75. package/dist/lifecycleState/index.d.ts.map +0 -1
  76. package/dist/lifecycleState/index.js.map +0 -1
  77. package/dist/lifecycleState/lifecycleState.machine.d.ts.map +0 -1
  78. package/dist/lifecycleState/lifecycleState.machine.js.map +0 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zudojs Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # @zudojs/lifecycle
2
2
 
3
- Application and component lifecycle orchestration with state machine, dependency ordering, graceful shutdown, rollback, and signals.
3
+ Application and component lifecycle orchestration with a state machine,
4
+ dependency ordering, graceful shutdown, rollback, and signal handling.
4
5
 
5
6
  ## Installation
6
7
 
@@ -13,22 +14,97 @@ npm install @zudojs/lifecycle
13
14
  ```typescript
14
15
  import { createLifecycleManager } from "@zudojs/lifecycle";
15
16
 
16
- const manager = createLifecycleManager({
17
- components: [database, server, worker],
18
- });
17
+ const manager = createLifecycleManager();
18
+
19
+ manager.register(database, { id: "db" });
20
+ manager.register(queue, { id: "queue", dependsOn: ["db"] });
21
+ manager.register(server, { id: "server", dependsOn: ["queue"] });
19
22
 
20
23
  await manager.start();
21
- await manager.stop();
24
+ // ... application running ...
25
+ await manager.shutdown();
26
+ manager.dispose();
27
+ ```
28
+
29
+ Components are plain objects implementing any subset of the hooks:
30
+
31
+ ```typescript
32
+ const database = {
33
+ name: "database",
34
+ async initialize(context) {},
35
+ async start(context) {},
36
+ async ready(context) {},
37
+ async stop(context) {},
38
+ async dispose(context) {},
39
+ };
40
+ ```
41
+
42
+ ## Startup and rollback
43
+
44
+ Phases run in order: `initialize` → `start` → `ready`. Components with
45
+ no dependency relationship run in parallel (bounded by `concurrency`).
46
+
47
+ If a **critical** component (the default) fails any startup phase,
48
+ `start()` rolls the application back (`stop` → `dispose`) and then
49
+ **rejects** with a `LifecycleStartError`. Register a component with
50
+ `{ critical: false }` when its failure should not abort startup — the
51
+ component is marked `FAILED` and startup continues.
52
+
53
+ ## Shutdown
54
+
55
+ `shutdown()` runs `stop` → `dispose` in reverse dependency order. It is
56
+ single-flight: concurrent callers, including the rollback triggered by a
57
+ failing startup and the process signal handler, all await the same run.
58
+
59
+ `shutdownTimeout` (default 30s) is a real wall-clock deadline for the
60
+ whole sequence. When it expires the lifecycle context's `AbortSignal` is
61
+ aborted so hooks that observe it can unwind, and shutdown completes
62
+ regardless. A component that ignores the signal is abandoned, not
63
+ awaited forever.
64
+
65
+ Failing `stop()`/`dispose()` hooks are recorded: the component is marked
66
+ `FAILED`, a `component:failed` event is emitted, and the result appears
67
+ in `getStatus()`.
68
+
69
+ ## Cancellation
70
+
71
+ Every hook receives a `LifecycleContext` whose `signal` is shared by the
72
+ whole run and is aborted when the shutdown deadline expires. Long-running
73
+ hooks should honour it:
74
+
75
+ ```typescript
76
+ async stop(context) {
77
+ await drain({ signal: context.signal });
78
+ }
22
79
  ```
23
80
 
24
- ## Features
81
+ ## Events
82
+
83
+ `manager.events.on(type, listener)` subscribes to:
84
+
85
+ - `component:registered`, `component:initializing`, `component:initialized`,
86
+ `component:starting`, `component:started`, `component:ready`,
87
+ `component:stopping`, `component:stopped`, `component:failed`
88
+ - `application:initializing`, `application:initialized`,
89
+ `application:starting`, `application:ready`, `application:stopping`,
90
+ `application:stopped`, `application:disposed`
91
+
92
+ Listener exceptions are swallowed so observability never breaks the
93
+ lifecycle.
94
+
95
+ ## Options
96
+
97
+ ```typescript
98
+ createLifecycleManager({
99
+ concurrency: 10, // parallel component operations per stage
100
+ shutdownTimeout: 30_000, // global shutdown deadline (ms)
101
+ handleSignals: true, // install process signal handlers
102
+ signals: ["SIGINT", "SIGTERM"], // defaults to DEFAULT_SHUTDOWN_SIGNALS
103
+ });
104
+ ```
25
105
 
26
- - State machine for lifecycle phases
27
- - Dependency ordering between components
28
- - Graceful shutdown with timeouts
29
- - Rollback on startup failure
30
- - Signal handling
31
- - Lifecycle hooks and events
106
+ Per-component: `id`, `dependsOn`, `priority`, `critical`, `timeout`,
107
+ `retry: { attempts, delay, maxDelay, backoff }`.
32
108
 
33
109
  ## Use Cases
34
110
 
@@ -29,6 +29,13 @@ export class LifecycleExecutor {
29
29
  const retryConfig = registration.retry;
30
30
  const maxAttempts = 1 + (retryConfig.attempts ?? 0);
31
31
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
32
+ // A hook must not be retried (or even started) once the run has
33
+ // been cancelled — the context signal is aborted by the shutdown
34
+ // deadline and by startup rollback.
35
+ if (context.signal.aborted) {
36
+ lastError ??= new LifecycleComponentError(registration.id, phase, context.signal.reason);
37
+ break;
38
+ }
32
39
  try {
33
40
  await withTimeout(async () => {
34
41
  const result = hook.call(registration.component, context);
@@ -47,7 +54,7 @@ export class LifecycleExecutor {
47
54
  lastError = error;
48
55
  if (attempt < maxAttempts - 1) {
49
56
  const delay = calculateDelay(retryConfig, attempt);
50
- await sleep(delay);
57
+ await sleep(delay, context.signal);
51
58
  }
52
59
  }
53
60
  }
@@ -55,7 +62,12 @@ export class LifecycleExecutor {
55
62
  id: registration.id,
56
63
  phase,
57
64
  duration: Date.now() - startTime,
58
- error: lastError,
65
+ // LifecycleComponentError was imported but never constructed, so
66
+ // callers received a bare hook error with no indication of which
67
+ // component or phase produced it.
68
+ error: lastError instanceof LifecycleComponentError
69
+ ? lastError
70
+ : new LifecycleComponentError(registration.id, phase, lastError),
59
71
  success: false,
60
72
  };
61
73
  }
@@ -81,8 +93,27 @@ function calculateDelay(config, attempt) {
81
93
  }
82
94
  return base;
83
95
  }
84
- /** Sleeps for the given duration. */
85
- function sleep(ms) {
86
- return new Promise((resolve) => setTimeout(resolve, ms));
96
+ /**
97
+ * Sleeps for the given duration, waking early when the run is aborted.
98
+ *
99
+ * An unconditional timer would keep the process alive for a full retry
100
+ * backoff after shutdown had already been requested.
101
+ */
102
+ function sleep(ms, signal) {
103
+ return new Promise((resolve) => {
104
+ if (signal.aborted) {
105
+ resolve();
106
+ return;
107
+ }
108
+ const timer = setTimeout(() => {
109
+ signal.removeEventListener("abort", onAbort);
110
+ resolve();
111
+ }, ms);
112
+ function onAbort() {
113
+ clearTimeout(timer);
114
+ resolve();
115
+ }
116
+ signal.addEventListener("abort", onAbort, { once: true });
117
+ });
87
118
  }
88
119
  //# sourceMappingURL=lifecycleExecutor.core.js.map
@@ -13,12 +13,23 @@ export async function withTimeout(fn, timeoutMs, componentId, phase) {
13
13
  const timer = setTimeout(() => {
14
14
  reject(new LifecycleTimeoutError(componentId, phase, timeoutMs));
15
15
  }, timeoutMs);
16
- fn()
17
- .then((result) => {
16
+ // The timer must be cleared on EVERY exit path. A synchronously
17
+ // throwing `fn` used to escape before `.catch` was attached,
18
+ // leaving an armed timer that kept the event loop alive for the
19
+ // whole timeout after the operation had already failed.
20
+ let operation;
21
+ try {
22
+ operation = fn();
23
+ }
24
+ catch (error) {
25
+ clearTimeout(timer);
26
+ reject(error);
27
+ return;
28
+ }
29
+ operation.then((result) => {
18
30
  clearTimeout(timer);
19
31
  resolve(result);
20
- })
21
- .catch((error) => {
32
+ }, (error) => {
22
33
  clearTimeout(timer);
23
34
  reject(error);
24
35
  });
@@ -51,19 +62,40 @@ export async function withAbort(fn, signal) {
51
62
  * Executes async operations with a concurrency limit.
52
63
  */
53
64
  export async function withConcurrency(items, concurrency, fn) {
65
+ const limit = Number.isFinite(concurrency) && concurrency > 0
66
+ ? Math.floor(concurrency)
67
+ : 1;
54
68
  const executing = new Set();
55
- const results = [];
56
- for (let i = 0; i < items.length; i++) {
57
- const item = items[i];
58
- const p = fn(item, i).then(() => {
59
- executing.delete(p);
69
+ // Every scheduled task is settled before this function returns, and
70
+ // the FIRST failure is rethrown afterwards. The previous version
71
+ // removed a task from `executing` only on success, so one rejection
72
+ // left a permanently-rejected promise in the race set, abandoned the
73
+ // remaining items, and produced an unhandled rejection for every
74
+ // task still in flight.
75
+ const settled = [];
76
+ let firstError;
77
+ let failed = false;
78
+ // `entries()` yields the element typed as T, so no non-null
79
+ // assertion is needed to satisfy noUncheckedIndexedAccess.
80
+ for (const [index, item] of items.entries()) {
81
+ const task = fn(item, index).catch((error) => {
82
+ if (!failed) {
83
+ failed = true;
84
+ firstError = error;
85
+ }
60
86
  });
61
- executing.add(p);
62
- results.push(p);
63
- if (executing.size >= concurrency) {
87
+ const tracked = task.then(() => {
88
+ executing.delete(tracked);
89
+ });
90
+ executing.add(tracked);
91
+ settled.push(tracked);
92
+ if (executing.size >= limit) {
64
93
  await Promise.race(executing);
65
94
  }
66
95
  }
67
- await Promise.all(results);
96
+ await Promise.all(settled);
97
+ if (failed) {
98
+ throw firstError;
99
+ }
68
100
  }
69
101
  //# sourceMappingURL=asyncUtils.core.js.map
@@ -21,7 +21,27 @@ export interface LifecycleManagerContext {
21
21
  readonly componentStates: Map<string, LifecycleStateMachine>;
22
22
  readonly results: Map<string, ExecutionResult[]>;
23
23
  startTime: number;
24
+ /**
25
+ * Cancellation source for the current run.
26
+ *
27
+ * Its signal is the one handed to every component hook through
28
+ * LifecycleContext. It is aborted when the global shutdown deadline
29
+ * expires, so a hook that honours the signal can bail out instead of
30
+ * hanging the process.
31
+ */
32
+ readonly controller: AbortController;
33
+ /**
34
+ * The in-flight shutdown, shared by every caller.
35
+ *
36
+ * Startup rollback and LifecycleManager.shutdown() both go through
37
+ * this field, so a signal arriving during a failing startup joins the
38
+ * rollback already running instead of starting a second, overlapping
39
+ * teardown.
40
+ */
41
+ shutdownPromise?: Promise<void>;
24
42
  }
43
+ /** Records an execution result against its component. */
44
+ export declare function recordResult(ctx: LifecycleManagerContext, result: ExecutionResult): void;
25
45
  /**
26
46
  * Safely transitions a component state machine to the target state.
27
47
  * No-op if the transition is not valid from the current state.
@@ -5,6 +5,11 @@
5
5
  * plus helper functions for component state transitions and event emission.
6
6
  */
7
7
  import { LifecycleState } from "@zudojs/constants";
8
+ /** Records an execution result against its component. */
9
+ export function recordResult(ctx, result) {
10
+ const existing = ctx.results.get(result.id) ?? [];
11
+ ctx.results.set(result.id, [...existing, result]);
12
+ }
8
13
  /**
9
14
  * Safely transitions a component state machine to the target state.
10
15
  * No-op if the transition is not valid from the current state.
@@ -36,7 +36,6 @@ export interface LifecycleManagerOptions {
36
36
  export declare class LifecycleManager {
37
37
  private readonly _ctx;
38
38
  private _startPromise?;
39
- private _shutdownPromise?;
40
39
  private _removeSignalHandlers?;
41
40
  constructor(options?: LifecycleManagerOptions);
42
41
  /** Registers a component with the lifecycle manager. */
@@ -62,7 +61,12 @@ export declare class LifecycleManager {
62
61
  state: LifecycleState;
63
62
  results: readonly ExecutionResult[];
64
63
  }>;
65
- /** Disposes the lifecycle manager and cleans up resources. */
64
+ /**
65
+ * Disposes the lifecycle manager and cleans up resources.
66
+ *
67
+ * This removes signal handlers and listeners only; it does NOT run
68
+ * component teardown — call `shutdown()` first for that.
69
+ */
66
70
  dispose(): void;
67
71
  }
68
72
  /** Creates a new lifecycle manager. */
@@ -28,7 +28,6 @@ import { performShutdown } from "./lifecycleManager.shutdown.js";
28
28
  export class LifecycleManager {
29
29
  _ctx;
30
30
  _startPromise;
31
- _shutdownPromise;
32
31
  _removeSignalHandlers;
33
32
  constructor(options = {}) {
34
33
  this._ctx = {
@@ -41,6 +40,7 @@ export class LifecycleManager {
41
40
  componentStates: new Map(),
42
41
  results: new Map(),
43
42
  startTime: 0,
43
+ controller: new AbortController(),
44
44
  };
45
45
  if (options.handleSignals !== false) {
46
46
  this._removeSignalHandlers = installSignalHandlers({
@@ -77,11 +77,9 @@ export class LifecycleManager {
77
77
  * Idempotent — returns the same promise if called multiple times.
78
78
  */
79
79
  async shutdown() {
80
- if (this._shutdownPromise) {
81
- return this._shutdownPromise;
82
- }
83
- this._shutdownPromise = performShutdown(this._ctx);
84
- return this._shutdownPromise;
80
+ // performShutdown is itself single-flight, so a shutdown started by
81
+ // startup rollback and one started here are the SAME run.
82
+ return performShutdown(this._ctx);
85
83
  }
86
84
  /** Returns the current application state. */
87
85
  get state() {
@@ -106,9 +104,15 @@ export class LifecycleManager {
106
104
  }
107
105
  return status;
108
106
  }
109
- /** Disposes the lifecycle manager and cleans up resources. */
107
+ /**
108
+ * Disposes the lifecycle manager and cleans up resources.
109
+ *
110
+ * This removes signal handlers and listeners only; it does NOT run
111
+ * component teardown — call `shutdown()` first for that.
112
+ */
110
113
  dispose() {
111
114
  this._removeSignalHandlers?.();
115
+ this._removeSignalHandlers = undefined;
112
116
  this._ctx.events.clear();
113
117
  }
114
118
  }
@@ -6,7 +6,10 @@
6
6
  import type { LifecycleManagerContext } from "./lifecycleManager.context.js";
7
7
  /**
8
8
  * Performs the full shutdown sequence: stop → dispose.
9
- * Idempotent — safe to call multiple times.
9
+ *
10
+ * Idempotent and single-flight: concurrent callers (a signal handler
11
+ * and a failing startup, say) all await the SAME teardown rather than
12
+ * running overlapping ones.
10
13
  */
11
14
  export declare function performShutdown(ctx: LifecycleManagerContext): Promise<void>;
12
15
  //# sourceMappingURL=lifecycleManager.shutdown.d.ts.map
@@ -6,27 +6,51 @@
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 { transitionComponent } from "./lifecycleManager.context.js";
9
+ import { emitComponentFailed, recordResult, transitionComponent, } from "./lifecycleManager.context.js";
10
10
  /** Shutdown phases in execution order. */
11
- const SHUTDOWN_PHASES = ["stop", "dispose"];
11
+ const SHUTDOWN_PHASES = [LifecyclePhase.STOP, LifecyclePhase.DISPOSE];
12
12
  /**
13
13
  * Performs the full shutdown sequence: stop → dispose.
14
- * Idempotent — safe to call multiple times.
14
+ *
15
+ * Idempotent and single-flight: concurrent callers (a signal handler
16
+ * and a failing startup, say) all await the SAME teardown rather than
17
+ * running overlapping ones.
15
18
  */
16
- export async function performShutdown(ctx) {
19
+ export function performShutdown(ctx) {
20
+ ctx.shutdownPromise ??= runShutdown(ctx);
21
+ return ctx.shutdownPromise;
22
+ }
23
+ async function runShutdown(ctx) {
17
24
  if (ctx.state.state === LifecycleState.DISPOSED)
18
25
  return;
26
+ // Shutdown must always be possible, including mid-startup. A plain
27
+ // transition() threw LifecycleStateError when shutdown() was called
28
+ // while the application was still INITIALIZING or STARTING, so a
29
+ // signal arriving during startup crashed instead of tearing down.
19
30
  if (ctx.state.state !== LifecycleState.STOPPING &&
20
31
  ctx.state.state !== LifecycleState.FAILED) {
21
- ctx.state.transition(LifecycleState.STOPPING);
32
+ if (ctx.state.canTransition(LifecycleState.STOPPING)) {
33
+ ctx.state.transition(LifecycleState.STOPPING);
34
+ }
35
+ else {
36
+ ctx.state.forceState(LifecycleState.STOPPING);
37
+ }
22
38
  }
23
39
  ctx.events.emit("application:stopping", {});
24
40
  const deadline = Date.now() + ctx.shutdownTimeout;
41
+ // The shutdown deadline used to be checked only BETWEEN the two
42
+ // phases, so a single hook that never settled hung shutdown (and the
43
+ // process) forever. Race the whole phase against the remaining
44
+ // budget and abort the run's signal when it expires, so hooks that
45
+ // honour cancellation stop and the rest are abandoned.
25
46
  for (const phase of SHUTDOWN_PHASES) {
26
- if (deadline - Date.now() <= 0)
47
+ const remaining = deadline - Date.now();
48
+ if (remaining <= 0) {
49
+ ctx.controller.abort(new Error(`Lifecycle shutdown exceeded its ${ctx.shutdownTimeout}ms deadline.`));
27
50
  break;
51
+ }
28
52
  try {
29
- await executeShutdownPhase(ctx, phase);
53
+ await raceDeadline(ctx, executeShutdownPhase(ctx, phase), remaining);
30
54
  }
31
55
  catch {
32
56
  // Shutdown must continue even if individual components fail.
@@ -36,11 +60,44 @@ export async function performShutdown(ctx) {
36
60
  ctx.events.emit("application:stopped", {
37
61
  duration: Date.now() - ctx.startTime,
38
62
  });
63
+ ctx.events.emit("application:disposed", {
64
+ duration: Date.now() - ctx.startTime,
65
+ });
66
+ }
67
+ /**
68
+ * Resolves when the phase finishes or the shutdown budget runs out.
69
+ *
70
+ * On expiry the run's AbortController is aborted so in-flight hooks
71
+ * observing `context.signal` unwind, and the timer is always cleared
72
+ * so it can never hold the event loop open.
73
+ */
74
+ async function raceDeadline(ctx, phase, remainingMs) {
75
+ let timer;
76
+ const expiry = new Promise((resolve) => {
77
+ timer = setTimeout(() => {
78
+ ctx.controller.abort(new Error(`Lifecycle shutdown exceeded its ${ctx.shutdownTimeout}ms deadline.`));
79
+ resolve();
80
+ }, remainingMs);
81
+ });
82
+ try {
83
+ await Promise.race([phase, expiry]);
84
+ }
85
+ finally {
86
+ if (timer !== undefined) {
87
+ clearTimeout(timer);
88
+ }
89
+ }
90
+ // The abandoned phase promise must never surface as an unhandled
91
+ // rejection once the race has been decided.
92
+ void phase.catch(() => { });
39
93
  }
40
94
  /** Executes a single shutdown phase across all registered components. */
41
95
  async function executeShutdownPhase(ctx, phase) {
42
96
  const plan = buildExecutionPlan(ctx.registry.getAll(), phase);
43
- const context = createLifecycleContext(phase, ctx.startTime);
97
+ const context = createLifecycleContext(phase, ctx.startTime, ctx.controller.signal);
98
+ const failureState = phase === LifecyclePhase.STOP
99
+ ? LifecycleState.STOPPED
100
+ : LifecycleState.DISPOSED;
44
101
  for (const stage of plan.stages) {
45
102
  const stageRegs = stage.components
46
103
  .map((id) => ctx.registry.get(id))
@@ -49,11 +106,26 @@ async function executeShutdownPhase(ctx, phase) {
49
106
  continue;
50
107
  for (const reg of stageRegs) {
51
108
  transitionComponent(ctx, reg.id, LifecycleState.STOPPING);
109
+ ctx.events.emit("component:stopping", {
110
+ component: { componentId: reg.id },
111
+ });
52
112
  }
53
- await ctx.executor.executeStage(stageRegs, phase, context, ctx.concurrency);
54
- const targetState = phase === "stop" ? LifecycleState.STOPPED : LifecycleState.DISPOSED;
55
- for (const reg of stageRegs) {
56
- transitionComponent(ctx, reg.id, targetState);
113
+ const results = await ctx.executor.executeStage(stageRegs, phase, context, ctx.concurrency);
114
+ // Shutdown results used to be discarded entirely: a component whose
115
+ // stop() or dispose() threw was still reported as cleanly STOPPED,
116
+ // its failure never reached getStatus() or the event stream, and
117
+ // operators had no way to learn a resource had leaked.
118
+ for (const result of results) {
119
+ recordResult(ctx, result);
120
+ if (result.success) {
121
+ transitionComponent(ctx, result.id, failureState);
122
+ ctx.events.emit("component:stopped", {
123
+ component: { componentId: result.id, duration: result.duration },
124
+ });
125
+ continue;
126
+ }
127
+ transitionComponent(ctx, result.id, LifecycleState.FAILED);
128
+ emitComponentFailed(ctx, result);
57
129
  }
58
130
  }
59
131
  }
@@ -6,6 +6,12 @@
6
6
  import type { LifecycleManagerContext } from "./lifecycleManager.context.js";
7
7
  /**
8
8
  * Performs the full startup sequence: initialize → start → ready.
9
+ *
10
+ * When a CRITICAL component fails, the application is rolled back
11
+ * (stop → dispose) and this function REJECTS with a LifecycleStartError.
12
+ * It previously resolved normally after rolling everything back, so
13
+ * `await manager.start()` reported success for an application that had
14
+ * just been torn down.
9
15
  */
10
16
  export declare function performStartup(ctx: LifecycleManagerContext): Promise<void>;
11
17
  //# sourceMappingURL=lifecycleManager.startup.d.ts.map