@zudojs/lifecycle 0.1.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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -12
  3. package/dist/lifecycleExecutor/lifecycleExecutor.core.js +36 -5
  4. package/dist/lifecycleInternal/asyncUtils.core.js +58 -14
  5. package/dist/lifecycleManager/lifecycleManager.context.d.ts +53 -0
  6. package/dist/lifecycleManager/lifecycleManager.context.js +25 -0
  7. package/dist/lifecycleManager/lifecycleManager.core.d.ts +6 -2
  8. package/dist/lifecycleManager/lifecycleManager.core.js +12 -7
  9. package/dist/lifecycleManager/lifecycleManager.shutdown.d.ts +4 -1
  10. package/dist/lifecycleManager/lifecycleManager.shutdown.js +123 -14
  11. package/dist/lifecycleManager/lifecycleManager.startup.d.ts +6 -0
  12. package/dist/lifecycleManager/lifecycleManager.startup.js +165 -36
  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 +25 -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,109 @@ 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. 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.
64
+
65
+ ## Shutdown
66
+
67
+ `shutdown()` runs `stop` → `dispose` in reverse dependency order. It is
68
+ single-flight: concurrent callers, including the rollback triggered by a
69
+ failing startup and the process signal handler, all await the same run.
70
+
71
+ `shutdownTimeout` (default 30s) is a real wall-clock deadline for the
72
+ whole sequence. When it expires the lifecycle context's `AbortSignal` is
73
+ aborted so hooks that observe it can unwind, and shutdown completes
74
+ regardless. A component that ignores the signal is abandoned, not
75
+ awaited forever.
76
+
77
+ Failing `stop()`/`dispose()` hooks are recorded: the component is marked
78
+ `FAILED`, a `component:failed` event is emitted, and the result appears
79
+ in `getStatus()`.
80
+
81
+ ## Cancellation
82
+
83
+ Every hook receives a `LifecycleContext` whose `signal` is shared by the
84
+ whole run and is aborted when the shutdown deadline expires. Long-running
85
+ hooks should honour it:
86
+
87
+ ```typescript
88
+ async stop(context) {
89
+ await drain({ signal: context.signal });
90
+ }
22
91
  ```
23
92
 
24
- ## Features
93
+ ## Events
94
+
95
+ `manager.events.on(type, listener)` subscribes to:
96
+
97
+ - `component:registered`, `component:initializing`, `component:initialized`,
98
+ `component:starting`, `component:started`, `component:ready`,
99
+ `component:stopping`, `component:stopped`, `component:failed`
100
+ - `application:initializing`, `application:initialized`,
101
+ `application:starting`, `application:ready`, `application:stopping`,
102
+ `application:stopped`, `application:disposed`
103
+
104
+ Listener exceptions are swallowed so observability never breaks the
105
+ lifecycle.
106
+
107
+ ## Options
108
+
109
+ ```typescript
110
+ createLifecycleManager({
111
+ concurrency: 10, // parallel component operations per stage
112
+ shutdownTimeout: 30_000, // global shutdown deadline (ms)
113
+ handleSignals: true, // install process signal handlers
114
+ signals: ["SIGINT", "SIGTERM"], // defaults to DEFAULT_SHUTDOWN_SIGNALS
115
+ });
116
+ ```
25
117
 
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
118
+ Per-component: `id`, `dependsOn`, `priority`, `critical`, `timeout`,
119
+ `retry: { attempts, delay, maxDelay, backoff }`.
32
120
 
33
121
  ## Use Cases
34
122
 
@@ -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
  });
@@ -36,7 +47,19 @@ export async function withAbort(fn, signal) {
36
47
  reject(new Error("Operation aborted"));
37
48
  };
38
49
  signal.addEventListener("abort", onAbort, { once: true });
39
- 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
40
63
  .then((result) => {
41
64
  signal.removeEventListener("abort", onAbort);
42
65
  resolve(result);
@@ -51,19 +74,40 @@ export async function withAbort(fn, signal) {
51
74
  * Executes async operations with a concurrency limit.
52
75
  */
53
76
  export async function withConcurrency(items, concurrency, fn) {
77
+ const limit = Number.isFinite(concurrency) && concurrency > 0
78
+ ? Math.floor(concurrency)
79
+ : 1;
54
80
  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);
81
+ // Every scheduled task is settled before this function returns, and
82
+ // the FIRST failure is rethrown afterwards. The previous version
83
+ // removed a task from `executing` only on success, so one rejection
84
+ // left a permanently-rejected promise in the race set, abandoned the
85
+ // remaining items, and produced an unhandled rejection for every
86
+ // task still in flight.
87
+ const settled = [];
88
+ let firstError;
89
+ let failed = false;
90
+ // `entries()` yields the element typed as T, so no non-null
91
+ // assertion is needed to satisfy noUncheckedIndexedAccess.
92
+ for (const [index, item] of items.entries()) {
93
+ const task = fn(item, index).catch((error) => {
94
+ if (!failed) {
95
+ failed = true;
96
+ firstError = error;
97
+ }
98
+ });
99
+ const tracked = task.then(() => {
100
+ executing.delete(tracked);
60
101
  });
61
- executing.add(p);
62
- results.push(p);
63
- if (executing.size >= concurrency) {
102
+ executing.add(tracked);
103
+ settled.push(tracked);
104
+ if (executing.size >= limit) {
64
105
  await Promise.race(executing);
65
106
  }
66
107
  }
67
- await Promise.all(results);
108
+ await Promise.all(settled);
109
+ if (failed) {
110
+ throw firstError;
111
+ }
68
112
  }
69
113
  //# sourceMappingURL=asyncUtils.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,13 +21,65 @@ 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;
35
+ /**
36
+ * Cancellation source for the current run.
37
+ *
38
+ * Its signal is the one handed to every component hook through
39
+ * LifecycleContext. It is aborted when the global shutdown deadline
40
+ * expires, so a hook that honours the signal can bail out instead of
41
+ * hanging the process.
42
+ */
43
+ readonly controller: AbortController;
44
+ /**
45
+ * The in-flight shutdown, shared by every caller.
46
+ *
47
+ * Startup rollback and LifecycleManager.shutdown() both go through
48
+ * this field, so a signal arriving during a failing startup joins the
49
+ * rollback already running instead of starting a second, overlapping
50
+ * teardown.
51
+ */
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>;
24
63
  }
64
+ /** Records an execution result against its component. */
65
+ export declare function recordResult(ctx: LifecycleManagerContext, result: ExecutionResult): void;
25
66
  /**
26
67
  * Safely transitions a component state machine to the target state.
27
68
  * No-op if the transition is not valid from the current state.
28
69
  */
29
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;
30
83
  /**
31
84
  * Transitions multiple component state machines to the same target state.
32
85
  */
@@ -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.
@@ -15,6 +20,26 @@ export function transitionComponent(ctx, id, targetState) {
15
20
  sm.transition(targetState);
16
21
  }
17
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
+ }
18
43
  /**
19
44
  * Transitions multiple component state machines to the same target state.
20
45
  */
@@ -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 = {
@@ -40,7 +39,9 @@ export class LifecycleManager {
40
39
  shutdownTimeout: options.shutdownTimeout ?? LIFECYCLE_DEFAULT_SHUTDOWN_TIMEOUT,
41
40
  componentStates: new Map(),
42
41
  results: new Map(),
42
+ attempted: new Map(),
43
43
  startTime: 0,
44
+ controller: new AbortController(),
44
45
  };
45
46
  if (options.handleSignals !== false) {
46
47
  this._removeSignalHandlers = installSignalHandlers({
@@ -77,11 +78,9 @@ export class LifecycleManager {
77
78
  * Idempotent — returns the same promise if called multiple times.
78
79
  */
79
80
  async shutdown() {
80
- if (this._shutdownPromise) {
81
- return this._shutdownPromise;
82
- }
83
- this._shutdownPromise = performShutdown(this._ctx);
84
- return this._shutdownPromise;
81
+ // performShutdown is itself single-flight, so a shutdown started by
82
+ // startup rollback and one started here are the SAME run.
83
+ return performShutdown(this._ctx);
85
84
  }
86
85
  /** Returns the current application state. */
87
86
  get state() {
@@ -106,9 +105,15 @@ export class LifecycleManager {
106
105
  }
107
106
  return status;
108
107
  }
109
- /** Disposes the lifecycle manager and cleans up resources. */
108
+ /**
109
+ * Disposes the lifecycle manager and cleans up resources.
110
+ *
111
+ * This removes signal handlers and listeners only; it does NOT run
112
+ * component teardown — call `shutdown()` first for that.
113
+ */
110
114
  dispose() {
111
115
  this._removeSignalHandlers?.();
116
+ this._removeSignalHandlers = undefined;
112
117
  this._ctx.events.clear();
113
118
  }
114
119
  }
@@ -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