@zudojs/lifecycle 1.2.3 → 1.3.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 (70) hide show
  1. package/README.md +44 -15
  2. package/dist/index.d.ts +2 -3
  3. package/dist/index.js +7 -1
  4. package/dist/lifecycleComponent/index.d.ts +0 -1
  5. package/dist/lifecycleComponent/index.js +0 -1
  6. package/dist/lifecycleComponent/lifecycleComponent.type.d.ts +27 -8
  7. package/dist/lifecycleComponent/lifecycleComponent.type.js +0 -1
  8. package/dist/lifecycleContext/index.d.ts +0 -1
  9. package/dist/lifecycleContext/index.js +0 -1
  10. package/dist/lifecycleContext/lifecycleContext.type.d.ts +0 -1
  11. package/dist/lifecycleContext/lifecycleContext.type.js +0 -1
  12. package/dist/lifecycleEvents/index.d.ts +0 -1
  13. package/dist/lifecycleEvents/index.js +0 -1
  14. package/dist/lifecycleEvents/lifecycleEvents.core.d.ts +20 -5
  15. package/dist/lifecycleEvents/lifecycleEvents.core.js +0 -1
  16. package/dist/lifecycleExecutor/index.d.ts +3 -3
  17. package/dist/lifecycleExecutor/index.js +2 -2
  18. package/dist/lifecycleExecutor/lifecycleExecutor.abandoned.d.ts +31 -0
  19. package/dist/lifecycleExecutor/lifecycleExecutor.abandoned.js +75 -0
  20. package/dist/lifecycleExecutor/lifecycleExecutor.core.d.ts +18 -24
  21. package/dist/lifecycleExecutor/lifecycleExecutor.core.js +64 -105
  22. package/dist/lifecycleExecutor/lifecycleExecutor.retry.d.ts +30 -0
  23. package/dist/lifecycleExecutor/lifecycleExecutor.retry.js +66 -0
  24. package/dist/lifecycleExecutor/lifecycleExecutor.type.d.ts +50 -0
  25. package/dist/lifecycleExecutor/lifecycleExecutor.type.js +6 -0
  26. package/dist/lifecycleInternal/asyncUtils.core.d.ts +0 -1
  27. package/dist/lifecycleInternal/asyncUtils.core.js +0 -1
  28. package/dist/lifecycleInternal/dependencyGraph.core.d.ts +34 -7
  29. package/dist/lifecycleInternal/dependencyGraph.core.js +55 -38
  30. package/dist/lifecycleInternal/dependencyGraph.cycle.d.ts +20 -0
  31. package/dist/lifecycleInternal/dependencyGraph.cycle.js +57 -0
  32. package/dist/lifecycleInternal/index.d.ts +1 -1
  33. package/dist/lifecycleInternal/index.js +0 -1
  34. package/dist/lifecycleInternal/timeoutBudget.core.d.ts +0 -1
  35. package/dist/lifecycleInternal/timeoutBudget.core.js +0 -1
  36. package/dist/lifecycleInternal/topologicalSort.core.d.ts +12 -2
  37. package/dist/lifecycleInternal/topologicalSort.core.js +41 -22
  38. package/dist/lifecycleManager/index.d.ts +0 -1
  39. package/dist/lifecycleManager/index.js +0 -1
  40. package/dist/lifecycleManager/lifecycleManager.context.d.ts +15 -7
  41. package/dist/lifecycleManager/lifecycleManager.context.js +0 -1
  42. package/dist/lifecycleManager/lifecycleManager.core.d.ts +12 -2
  43. package/dist/lifecycleManager/lifecycleManager.core.js +30 -3
  44. package/dist/lifecycleManager/lifecycleManager.deadline.d.ts +29 -0
  45. package/dist/lifecycleManager/lifecycleManager.deadline.js +79 -0
  46. package/dist/lifecycleManager/lifecycleManager.shutdown.d.ts +0 -1
  47. package/dist/lifecycleManager/lifecycleManager.shutdown.js +60 -69
  48. package/dist/lifecycleManager/lifecycleManager.startup.d.ts +0 -1
  49. package/dist/lifecycleManager/lifecycleManager.startup.js +12 -4
  50. package/dist/lifecyclePhase/index.d.ts +0 -1
  51. package/dist/lifecyclePhase/index.js +0 -1
  52. package/dist/lifecyclePhase/lifecyclePhase.core.d.ts +0 -1
  53. package/dist/lifecyclePhase/lifecyclePhase.core.js +0 -1
  54. package/dist/lifecyclePlan/index.d.ts +0 -1
  55. package/dist/lifecyclePlan/index.js +0 -1
  56. package/dist/lifecyclePlan/lifecyclePlan.core.d.ts +0 -1
  57. package/dist/lifecyclePlan/lifecyclePlan.core.js +0 -2
  58. package/dist/lifecycleRegistry/index.d.ts +0 -1
  59. package/dist/lifecycleRegistry/index.js +0 -1
  60. package/dist/lifecycleRegistry/lifecycleRegistry.core.d.ts +0 -1
  61. package/dist/lifecycleRegistry/lifecycleRegistry.core.js +3 -2
  62. package/dist/lifecycleSignal/index.d.ts +0 -1
  63. package/dist/lifecycleSignal/index.js +0 -1
  64. package/dist/lifecycleSignal/lifecycleSignal.handler.d.ts +0 -1
  65. package/dist/lifecycleSignal/lifecycleSignal.handler.js +0 -1
  66. package/dist/lifecycleState/index.d.ts +0 -1
  67. package/dist/lifecycleState/index.js +0 -1
  68. package/dist/lifecycleState/lifecycleState.machine.d.ts +0 -1
  69. package/dist/lifecycleState/lifecycleState.machine.js +0 -1
  70. package/package.json +3 -3
@@ -4,8 +4,8 @@
4
4
  * Internal utilities: dependency graph, topological sort, async helpers.
5
5
  */
6
6
  export { DependencyGraph } from "./dependencyGraph.core.js";
7
+ export type { DependencyGraphValidationOptions } from "./dependencyGraph.core.js";
7
8
  export { topologicalSort, reverseTopologicalSort, } from "./topologicalSort.core.js";
8
9
  export type { TopologicalStage } from "./topologicalSort.core.js";
9
10
  export { withTimeout, withAbort, withConcurrency } from "./asyncUtils.core.js";
10
11
  export { MAX_TIMER_DELAY, assertTimeoutBudget, isBounded, toTimerDelay, } from "./timeoutBudget.core.js";
11
- //# sourceMappingURL=index.d.ts.map
@@ -7,4 +7,3 @@ 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
9
  export { MAX_TIMER_DELAY, assertTimeoutBudget, isBounded, toTimerDelay, } from "./timeoutBudget.core.js";
10
- //# sourceMappingURL=index.js.map
@@ -27,4 +27,3 @@ export declare function isBounded(value: number): boolean;
27
27
  * Clamps a finite budget to what a timer can represent.
28
28
  */
29
29
  export declare function toTimerDelay(value: number): number;
30
- //# sourceMappingURL=timeoutBudget.core.d.ts.map
@@ -35,4 +35,3 @@ export function isBounded(value) {
35
35
  export function toTimerDelay(value) {
36
36
  return Math.min(Math.max(0, value), MAX_TIMER_DELAY);
37
37
  }
38
- //# sourceMappingURL=timeoutBudget.core.js.map
@@ -9,7 +9,18 @@ export type TopologicalStage = readonly string[];
9
9
  /**
10
10
  * Performs topological sort on a dependency graph,
11
11
  * grouping independent components into parallel stages.
12
- * Components within the same stage are ordered by priority (higher first).
12
+ * Components within the same stage are ordered by priority (higher
13
+ * first) and then by the order they were added to the graph.
14
+ *
15
+ * Runs in O(V + E) — each node is queued once when its last dependency
16
+ * is placed. A rescan of every remaining node per stage made a long
17
+ * chain quadratic (a 20 000-component chain took seconds to order).
18
+ *
19
+ * Throws LifecycleDependencyError, naming the actual loop, when the
20
+ * graph is cyclic. The error used to list every node that was still
21
+ * blocked, which for one three-node loop meant naming the whole
22
+ * application. Undeclared nodes (see `DependencyGraph.validate`) sort
23
+ * as leaves.
13
24
  */
14
25
  export declare function topologicalSort(graph: DependencyGraph, priorities?: ReadonlyMap<string, number>): readonly TopologicalStage[];
15
26
  /**
@@ -23,4 +34,3 @@ export declare function topologicalSort(graph: DependencyGraph, priorities?: Rea
23
34
  * concurrently, but wrong now that priority is a real sub-stage barrier.
24
35
  */
25
36
  export declare function reverseTopologicalSort(graph: DependencyGraph, priorities?: ReadonlyMap<string, number>): readonly TopologicalStage[];
26
- //# sourceMappingURL=topologicalSort.core.d.ts.map
@@ -7,36 +7,56 @@ import { LifecycleDependencyError } from "@zudojs/errors";
7
7
  /**
8
8
  * Performs topological sort on a dependency graph,
9
9
  * grouping independent components into parallel stages.
10
- * Components within the same stage are ordered by priority (higher first).
10
+ * Components within the same stage are ordered by priority (higher
11
+ * first) and then by the order they were added to the graph.
12
+ *
13
+ * Runs in O(V + E) — each node is queued once when its last dependency
14
+ * is placed. A rescan of every remaining node per stage made a long
15
+ * chain quadratic (a 20 000-component chain took seconds to order).
16
+ *
17
+ * Throws LifecycleDependencyError, naming the actual loop, when the
18
+ * graph is cyclic. The error used to list every node that was still
19
+ * blocked, which for one three-node loop meant naming the whole
20
+ * application. Undeclared nodes (see `DependencyGraph.validate`) sort
21
+ * as leaves.
11
22
  */
12
23
  export function topologicalSort(graph, priorities) {
13
24
  const nodes = graph.getNodes();
25
+ const position = new Map();
14
26
  const inDegree = new Map();
15
- for (const node of nodes) {
16
- const deps = graph.getDependencies(node);
17
- inDegree.set(node, deps.length);
18
- }
27
+ let ready = [];
28
+ nodes.forEach((node, index) => {
29
+ position.set(node, index);
30
+ const degree = graph.getDependencies(node).length;
31
+ inDegree.set(node, degree);
32
+ if (degree === 0)
33
+ ready.push(node);
34
+ });
35
+ const byPriorityThenPosition = (a, b) => {
36
+ const pa = priorities?.get(a) ?? 0;
37
+ const pb = priorities?.get(b) ?? 0;
38
+ return pb - pa || position.get(a) - position.get(b);
39
+ };
19
40
  const stages = [];
20
- const remaining = new Set(nodes);
21
- while (remaining.size > 0) {
22
- const ready = [...remaining].filter((node) => inDegree.get(node) === 0);
23
- if (ready.length === 0 && remaining.size > 0) {
24
- throw new LifecycleDependencyError([...remaining]);
25
- }
26
- ready.sort((a, b) => {
27
- const pa = priorities?.get(a) ?? 0;
28
- const pb = priorities?.get(b) ?? 0;
29
- return pb - pa;
30
- });
41
+ let placed = 0;
42
+ while (ready.length > 0) {
43
+ ready.sort(byPriorityThenPosition);
31
44
  stages.push(Object.freeze(ready));
45
+ placed += ready.length;
46
+ const next = [];
32
47
  for (const node of ready) {
33
- remaining.delete(node);
34
- const dependents = graph.getDependents(node);
35
- for (const dep of dependents) {
36
- const current = inDegree.get(dep) ?? 0;
37
- inDegree.set(dep, current - 1);
48
+ for (const dependent of graph.getDependents(node)) {
49
+ const remaining = (inDegree.get(dependent) ?? 0) - 1;
50
+ inDegree.set(dependent, remaining);
51
+ if (remaining === 0)
52
+ next.push(dependent);
38
53
  }
39
54
  }
55
+ ready = next;
56
+ }
57
+ if (placed < nodes.length) {
58
+ const cycle = graph.findCycle();
59
+ throw new LifecycleDependencyError(cycle ?? nodes.filter((node) => (inDegree.get(node) ?? 0) > 0));
40
60
  }
41
61
  return stages;
42
62
  }
@@ -54,4 +74,3 @@ export function reverseTopologicalSort(graph, priorities) {
54
74
  const stages = topologicalSort(graph, priorities);
55
75
  return Object.freeze([...stages].reverse().map((stage) => Object.freeze([...stage].reverse())));
56
76
  }
57
- //# sourceMappingURL=topologicalSort.core.js.map
@@ -5,4 +5,3 @@
5
5
  */
6
6
  export { LifecycleManager, createLifecycleManager, } from "./lifecycleManager.core.js";
7
7
  export type { LifecycleManagerOptions } from "./lifecycleManager.core.js";
8
- //# sourceMappingURL=index.d.ts.map
@@ -4,4 +4,3 @@
4
4
  * Lifecycle manager — the main orchestration entry point.
5
5
  */
6
6
  export { LifecycleManager, createLifecycleManager, } from "./lifecycleManager.core.js";
7
- //# sourceMappingURL=index.js.map
@@ -10,7 +10,7 @@ import type { LifecycleStateMachine } from "../lifecycleState/lifecycleState.mac
10
10
  import type { LifecycleRegistry } from "../lifecycleRegistry/lifecycleRegistry.core.js";
11
11
  import type { LifecycleExecutor } from "../lifecycleExecutor/lifecycleExecutor.core.js";
12
12
  import type { LifecycleEventEmitter } from "../lifecycleEvents/lifecycleEvents.core.js";
13
- import type { ExecutionResult } from "../lifecycleExecutor/lifecycleExecutor.core.js";
13
+ import type { ExecutionResult } from "../lifecycleExecutor/lifecycleExecutor.type.js";
14
14
  /** Internal context passed to startup/shutdown orchestration functions. */
15
15
  export interface LifecycleManagerContext {
16
16
  readonly registry: LifecycleRegistry;
@@ -22,16 +22,25 @@ export interface LifecycleManagerContext {
22
22
  readonly componentStates: Map<string, LifecycleStateMachine>;
23
23
  readonly results: Map<string, ExecutionResult[]>;
24
24
  /**
25
- * Startup phases that were actually run for each component.
25
+ * Startup phases each component reached.
26
26
  *
27
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.
28
+ * whose `start` completed or timed out, and `dispose()` only on
29
+ * components whose `initialize` was invoked. Rollback after an early
30
+ * failure used to call `stop()` on components that had never
31
+ * started, which for a real server throws and was then reported as
32
+ * a component failure.
32
33
  */
33
34
  readonly attempted: Map<string, Set<LifecyclePhase>>;
34
35
  startTime: number;
36
+ /** Whether the shutdown deadline expired during the current teardown. */
37
+ shutdownTimedOut: boolean;
38
+ /**
39
+ * Components whose shutdown hook is executing right now, with the
40
+ * time the stage began. The deadline handler fails exactly these
41
+ * when it expires.
42
+ */
43
+ shutdownInFlight?: Map<string, number>;
35
44
  /**
36
45
  * Cancellation source for the current run.
37
46
  *
@@ -92,4 +101,3 @@ export declare function emitComponentFailed(ctx: LifecycleManagerContext, result
92
101
  error?: unknown;
93
102
  duration: number;
94
103
  }): void;
95
- //# sourceMappingURL=lifecycleManager.context.d.ts.map
@@ -60,4 +60,3 @@ export function emitComponentFailed(ctx, result) {
60
60
  },
61
61
  });
62
62
  }
63
- //# sourceMappingURL=lifecycleManager.context.js.map
@@ -7,7 +7,7 @@
7
7
  import { LifecycleState } from "@zudojs/constants";
8
8
  import type { LifecycleComponent, LifecycleRegistrationOptions } from "../lifecycleComponent/lifecycleComponent.type.js";
9
9
  import { LifecycleRegistry } from "../lifecycleRegistry/lifecycleRegistry.core.js";
10
- import type { ExecutionResult } from "../lifecycleExecutor/lifecycleExecutor.core.js";
10
+ import type { ExecutionResult } from "../lifecycleExecutor/lifecycleExecutor.type.js";
11
11
  import { LifecycleEventEmitter } from "../lifecycleEvents/lifecycleEvents.core.js";
12
12
  /** Options for creating a lifecycle manager. */
13
13
  export interface LifecycleManagerOptions {
@@ -57,11 +57,22 @@ export declare class LifecycleManager {
57
57
  /**
58
58
  * Shuts down the application lifecycle.
59
59
  * Idempotent — returns the same promise if called multiple times.
60
+ *
61
+ * Always resolves, even when `shutdownTimeout` expires: a signal
62
+ * handler awaiting it must not crash the process. Expiry is reported
63
+ * through {@link shutdownTimedOut}, the `application:shutdown-timeout`
64
+ * event, and a FAILED status (with a LifecycleTimeoutError result)
65
+ * for every component whose hook was still running.
60
66
  */
61
67
  shutdown(): Promise<void>;
62
68
  private releaseSignalHandlers;
63
69
  /** Returns the current application state. */
64
70
  get state(): LifecycleState;
71
+ /**
72
+ * Whether the last (or current) shutdown ran out of `shutdownTimeout`
73
+ * before every component was stopped and disposed.
74
+ */
75
+ get shutdownTimedOut(): boolean;
65
76
  /** Returns the event emitter for lifecycle events. */
66
77
  get events(): LifecycleEventEmitter;
67
78
  /** Returns the registry. */
@@ -81,4 +92,3 @@ export declare class LifecycleManager {
81
92
  }
82
93
  /** Creates a new lifecycle manager. */
83
94
  export declare function createLifecycleManager(options?: LifecycleManagerOptions): LifecycleManager;
84
- //# sourceMappingURL=lifecycleManager.core.d.ts.map
@@ -36,17 +36,32 @@ export class LifecycleManager {
36
36
  if (options.shutdownTimeout !== undefined) {
37
37
  assertTimeoutBudget("shutdownTimeout", options.shutdownTimeout);
38
38
  }
39
+ const events = new LifecycleEventEmitter();
39
40
  this._ctx = {
40
41
  registry: new LifecycleRegistry(),
41
42
  state: new LifecycleStateMachine("application"),
42
- executor: new LifecycleExecutor(),
43
- events: new LifecycleEventEmitter(),
43
+ executor: new LifecycleExecutor({
44
+ // Retries used to be invisible: a backoff delay could only be
45
+ // inferred from wall-clock timing.
46
+ onRetry: (notice) => {
47
+ events.emit("component:retrying", {
48
+ component: {
49
+ componentId: notice.id,
50
+ attempt: notice.attempt,
51
+ delay: notice.delay,
52
+ error: notice.error,
53
+ },
54
+ });
55
+ },
56
+ }),
57
+ events,
44
58
  concurrency: options.concurrency ?? LIFECYCLE_DEFAULT_CONCURRENCY,
45
59
  shutdownTimeout: options.shutdownTimeout ?? LIFECYCLE_DEFAULT_SHUTDOWN_TIMEOUT,
46
60
  componentStates: new Map(),
47
61
  results: new Map(),
48
62
  attempted: new Map(),
49
63
  startTime: 0,
64
+ shutdownTimedOut: false,
50
65
  controller: new AbortController(),
51
66
  };
52
67
  // Installing in the constructor disabled Ctrl-C for the whole
@@ -93,6 +108,12 @@ export class LifecycleManager {
93
108
  /**
94
109
  * Shuts down the application lifecycle.
95
110
  * Idempotent — returns the same promise if called multiple times.
111
+ *
112
+ * Always resolves, even when `shutdownTimeout` expires: a signal
113
+ * handler awaiting it must not crash the process. Expiry is reported
114
+ * through {@link shutdownTimedOut}, the `application:shutdown-timeout`
115
+ * event, and a FAILED status (with a LifecycleTimeoutError result)
116
+ * for every component whose hook was still running.
96
117
  */
97
118
  async shutdown() {
98
119
  // performShutdown is itself single-flight, so a shutdown started by
@@ -112,6 +133,13 @@ export class LifecycleManager {
112
133
  get state() {
113
134
  return this._ctx.state.state;
114
135
  }
136
+ /**
137
+ * Whether the last (or current) shutdown ran out of `shutdownTimeout`
138
+ * before every component was stopped and disposed.
139
+ */
140
+ get shutdownTimedOut() {
141
+ return this._ctx.shutdownTimedOut;
142
+ }
115
143
  /** Returns the event emitter for lifecycle events. */
116
144
  get events() {
117
145
  return this._ctx.events;
@@ -146,4 +174,3 @@ export class LifecycleManager {
146
174
  export function createLifecycleManager(options) {
147
175
  return new LifecycleManager(options);
148
176
  }
149
- //# sourceMappingURL=lifecycleManager.core.js.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @zudojs/lifecycle/manager/deadline
3
+ *
4
+ * Global shutdown deadline: races a phase against the remaining budget
5
+ * and reports expiry instead of letting it pass silently.
6
+ */
7
+ import type { LifecyclePhase } from "@zudojs/constants";
8
+ import type { LifecycleManagerContext } from "./lifecycleManager.context.js";
9
+ /**
10
+ * Resolves when the phase finishes or the shutdown budget runs out.
11
+ *
12
+ * On expiry {@link expireShutdown} runs, so the deadline is never
13
+ * silent, and the timer is always cleared so it can never hold the
14
+ * event loop open. The abandoned phase promise never surfaces as an
15
+ * unhandled rejection once the race has been decided.
16
+ */
17
+ export declare function raceDeadline(ctx: LifecycleManagerContext, phase: LifecyclePhase, work: Promise<void>, remainingMs: number): Promise<void>;
18
+ /**
19
+ * Records that the shutdown deadline has expired. Idempotent.
20
+ *
21
+ * The run's AbortController is aborted so in-flight hooks observing
22
+ * `context.signal` unwind; every component whose hook was still
23
+ * running is recorded as FAILED with a LifecycleTimeoutError (its
24
+ * status used to stay STOPPING forever); and one
25
+ * `application:shutdown-timeout` event is emitted. Shutdown itself
26
+ * still resolves — a signal handler awaiting it must not crash the
27
+ * process — and `LifecycleManager.shutdownTimedOut` reports the outcome.
28
+ */
29
+ export declare function expireShutdown(ctx: LifecycleManagerContext, phase: LifecyclePhase): void;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @zudojs/lifecycle/manager/deadline
3
+ *
4
+ * Global shutdown deadline: races a phase against the remaining budget
5
+ * and reports expiry instead of letting it pass silently.
6
+ */
7
+ import { LifecycleTimeoutError } from "@zudojs/errors";
8
+ import { isBounded, toTimerDelay } from "../lifecycleInternal/index.js";
9
+ import { emitComponentFailed, failComponent, recordResult, } from "./lifecycleManager.context.js";
10
+ /**
11
+ * Resolves when the phase finishes or the shutdown budget runs out.
12
+ *
13
+ * On expiry {@link expireShutdown} runs, so the deadline is never
14
+ * silent, and the timer is always cleared so it can never hold the
15
+ * event loop open. The abandoned phase promise never surfaces as an
16
+ * unhandled rejection once the race has been decided.
17
+ */
18
+ export async function raceDeadline(ctx, phase, work, remainingMs) {
19
+ // An unbounded budget (shutdownTimeout: Infinity) waits for the work.
20
+ // Handing Infinity to setTimeout fired after 1 ms and abandoned every
21
+ // stop()/dispose() while reporting the application DISPOSED.
22
+ if (!isBounded(remainingMs)) {
23
+ await work.catch(() => { });
24
+ return;
25
+ }
26
+ let timer;
27
+ const expiry = new Promise((resolve) => {
28
+ timer = setTimeout(() => {
29
+ expireShutdown(ctx, phase);
30
+ resolve();
31
+ }, toTimerDelay(remainingMs));
32
+ });
33
+ try {
34
+ await Promise.race([work, expiry]);
35
+ }
36
+ finally {
37
+ if (timer !== undefined) {
38
+ clearTimeout(timer);
39
+ }
40
+ }
41
+ void work.catch(() => { });
42
+ }
43
+ /**
44
+ * Records that the shutdown deadline has expired. Idempotent.
45
+ *
46
+ * The run's AbortController is aborted so in-flight hooks observing
47
+ * `context.signal` unwind; every component whose hook was still
48
+ * running is recorded as FAILED with a LifecycleTimeoutError (its
49
+ * status used to stay STOPPING forever); and one
50
+ * `application:shutdown-timeout` event is emitted. Shutdown itself
51
+ * still resolves — a signal handler awaiting it must not crash the
52
+ * process — and `LifecycleManager.shutdownTimedOut` reports the outcome.
53
+ */
54
+ export function expireShutdown(ctx, phase) {
55
+ if (ctx.shutdownTimedOut)
56
+ return;
57
+ ctx.shutdownTimedOut = true;
58
+ const error = new LifecycleTimeoutError("application", phase, ctx.shutdownTimeout);
59
+ ctx.controller.abort(error);
60
+ const now = Date.now();
61
+ for (const [id, startedAt] of ctx.shutdownInFlight ?? []) {
62
+ const result = {
63
+ id,
64
+ phase,
65
+ duration: now - startedAt,
66
+ success: false,
67
+ timedOut: true,
68
+ error: new LifecycleTimeoutError(id, phase, ctx.shutdownTimeout),
69
+ };
70
+ recordResult(ctx, result);
71
+ failComponent(ctx, id);
72
+ emitComponentFailed(ctx, result);
73
+ }
74
+ ctx.shutdownInFlight = undefined;
75
+ ctx.events.emit("application:shutdown-timeout", {
76
+ error,
77
+ duration: now - ctx.startTime,
78
+ });
79
+ }
@@ -12,4 +12,3 @@ import type { LifecycleManagerContext } from "./lifecycleManager.context.js";
12
12
  * running overlapping ones.
13
13
  */
14
14
  export declare function performShutdown(ctx: LifecycleManagerContext): Promise<void>;
15
- //# sourceMappingURL=lifecycleManager.shutdown.d.ts.map
@@ -6,10 +6,24 @@
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";
10
9
  import { emitComponentFailed, recordResult, transitionComponent, wasAttempted, } from "./lifecycleManager.context.js";
10
+ import { expireShutdown, raceDeadline } from "./lifecycleManager.deadline.js";
11
11
  /** Shutdown phases in execution order. */
12
12
  const SHUTDOWN_PHASES = [LifecyclePhase.STOP, LifecyclePhase.DISPOSE];
13
+ /**
14
+ * Per-phase component events. Dispose used to reuse the stop pair, so
15
+ * a listener saw every component "stopped" twice.
16
+ */
17
+ const PHASE_EVENTS = {
18
+ [LifecyclePhase.STOP]: {
19
+ begin: "component:stopping",
20
+ end: "component:stopped",
21
+ },
22
+ [LifecyclePhase.DISPOSE]: {
23
+ begin: "component:disposing",
24
+ end: "component:disposed",
25
+ },
26
+ };
13
27
  /**
14
28
  * Performs the full shutdown sequence: stop → dispose.
15
29
  *
@@ -37,43 +51,41 @@ async function runShutdown(ctx) {
37
51
  ctx.state.forceState(LifecycleState.STOPPING);
38
52
  }
39
53
  }
54
+ ctx.shutdownTimedOut = false;
40
55
  ctx.events.emit("application:stopping", {});
41
56
  const deadline = Date.now() + ctx.shutdownTimeout;
42
57
  // A startup stage still executing must settle before its components
43
58
  // are stopped, otherwise `stop()` overlaps the component's own
44
59
  // `start()`. Startup itself refuses to launch further stages once
45
- // `shutdownPromise` is set, so this wait is bounded by one stage.
60
+ // `shutdownPromise` is set, and each hook in the stage is bounded by
61
+ // its component timeout, so this wait is bounded by one stage.
62
+ //
63
+ // Hooks abandoned by a component timeout are NOT waited for here:
64
+ // the executor makes each component's stop()/dispose() wait for that
65
+ // component's own abandoned hook, bounded by its timeout, so one
66
+ // hung start() no longer holds every other component's teardown
67
+ // until the global deadline.
46
68
  if (ctx.inFlight !== undefined) {
47
- await raceDeadline(ctx, ctx.inFlight.then(() => undefined, () => undefined), Math.max(deadline - Date.now(), 1));
69
+ await raceDeadline(ctx, LifecyclePhase.STOP, ctx.inFlight.then(() => undefined, () => undefined), Math.max(deadline - Date.now(), 1));
48
70
  }
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));
52
- // The shutdown deadline used to be checked only BETWEEN the two
53
- // phases, so a single hook that never settled hung shutdown (and the
54
- // process) forever. Race the whole phase against the remaining
55
- // budget and abort the run's signal when it expires, so hooks that
56
- // honour cancellation stop and the rest are abandoned.
71
+ // The whole phase is raced against the remaining budget; expiry is
72
+ // recorded by expireShutdown (components FAILED, event emitted,
73
+ // signal aborted) rather than passing silently.
57
74
  for (const phase of SHUTDOWN_PHASES) {
58
75
  const remaining = deadline - Date.now();
59
- if (remaining <= 0) {
60
- ctx.controller.abort(new Error(`Lifecycle shutdown exceeded its ${ctx.shutdownTimeout}ms deadline.`));
76
+ if (ctx.shutdownTimedOut || remaining <= 0) {
77
+ expireShutdown(ctx, phase);
61
78
  break;
62
79
  }
80
+ if (phase === LifecyclePhase.DISPOSE) {
81
+ ctx.events.emit("application:disposing", {});
82
+ }
63
83
  try {
64
- await raceDeadline(ctx, executeShutdownPhase(ctx, phase), remaining);
84
+ await raceDeadline(ctx, phase, executeShutdownPhase(ctx, phase), remaining);
65
85
  }
66
86
  catch {
67
87
  // Shutdown must continue even if individual components fail.
68
88
  }
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));
77
89
  }
78
90
  ctx.state.forceState(LifecycleState.DISPOSED);
79
91
  ctx.events.emit("application:stopped", {
@@ -83,49 +95,17 @@ async function runShutdown(ctx) {
83
95
  duration: Date.now() - ctx.startTime,
84
96
  });
85
97
  }
86
- /**
87
- * Resolves when the phase finishes or the shutdown budget runs out.
88
- *
89
- * On expiry the run's AbortController is aborted so in-flight hooks
90
- * observing `context.signal` unwind, and the timer is always cleared
91
- * so it can never hold the event loop open.
92
- */
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
- }
101
- let timer;
102
- const expiry = new Promise((resolve) => {
103
- timer = setTimeout(() => {
104
- ctx.controller.abort(new Error(`Lifecycle shutdown exceeded its ${ctx.shutdownTimeout}ms deadline.`));
105
- resolve();
106
- }, toTimerDelay(remainingMs));
107
- });
108
- try {
109
- await Promise.race([phase, expiry]);
110
- }
111
- finally {
112
- if (timer !== undefined) {
113
- clearTimeout(timer);
114
- }
115
- }
116
- // The abandoned phase promise must never surface as an unhandled
117
- // rejection once the race has been decided.
118
- void phase.catch(() => { });
119
- }
120
98
  /**
121
99
  * Executes a single shutdown phase across all registered components.
122
100
  *
123
101
  * A hook only runs for components that reached the matching startup
124
- * phase: `stop()` when `start` ran, `dispose()` when `initialize` ran.
125
- * Rollback after an early failure — and `shutdown()` on a manager that
126
- * was never started — used to call `stop()` on components that had
127
- * never started; a real server's `close()` throws in that situation
128
- * and the phantom failure was then recorded against the component.
102
+ * phase: `stop()` when `start` completed (or timed out, so its outcome
103
+ * is unknown), `dispose()` when `initialize` was invoked at all — a
104
+ * failed initialize may still hold resources. Rollback used to call
105
+ * `stop()` on a component whose own `start()` had just thrown, and on
106
+ * non-critical components that never came up; a real server's
107
+ * `close()` throws in that situation and the phantom failure was then
108
+ * recorded against the component.
129
109
  */
130
110
  async function executeShutdownPhase(ctx, phase) {
131
111
  const plan = buildExecutionPlan(ctx.registry.getAll(), phase);
@@ -137,7 +117,10 @@ async function executeShutdownPhase(ctx, phase) {
137
117
  const successState = isStop
138
118
  ? LifecycleState.STOPPED
139
119
  : LifecycleState.DISPOSED;
120
+ const events = PHASE_EVENTS[phase];
140
121
  for (const stage of plan.stages) {
122
+ if (ctx.shutdownTimedOut)
123
+ return;
141
124
  const stageRegs = stage.components
142
125
  .map((id) => ctx.registry.get(id))
143
126
  .filter((r) => r !== undefined);
@@ -160,22 +143,31 @@ async function executeShutdownPhase(ctx, phase) {
160
143
  if (isStop) {
161
144
  transitionComponent(ctx, reg.id, LifecycleState.STOPPING);
162
145
  }
163
- ctx.events.emit("component:stopping", {
146
+ ctx.events.emit(events.begin, {
164
147
  component: { componentId: reg.id },
165
148
  });
166
149
  }
167
150
  if (runnable.length === 0)
168
151
  continue;
169
- const results = await ctx.executor.executeStage(runnable, phase, context, ctx.concurrency);
170
- // Shutdown results used to be discarded entirely: a component whose
171
- // stop() or dispose() threw was still reported as cleanly STOPPED,
172
- // its failure never reached getStatus() or the event stream, and
173
- // operators had no way to learn a resource had leaked.
152
+ const startedAt = Date.now();
153
+ ctx.shutdownInFlight = new Map(runnable.map((reg) => [reg.id, startedAt]));
154
+ let results;
155
+ try {
156
+ results = await ctx.executor.executeStage(runnable, phase, context, ctx.concurrency);
157
+ }
158
+ finally {
159
+ ctx.shutdownInFlight = undefined;
160
+ }
161
+ // Results arriving after the deadline expired belong to hooks the
162
+ // deadline already reported as timed out. Recording them would emit
163
+ // events after shutdown() has resolved and overwrite that verdict.
164
+ if (ctx.shutdownTimedOut)
165
+ return;
174
166
  for (const result of results) {
175
167
  recordResult(ctx, result);
176
168
  if (result.success) {
177
169
  transitionComponent(ctx, result.id, successState);
178
- ctx.events.emit("component:stopped", {
170
+ ctx.events.emit(events.end, {
179
171
  component: { componentId: result.id, duration: result.duration },
180
172
  });
181
173
  continue;
@@ -185,4 +177,3 @@ async function executeShutdownPhase(ctx, phase) {
185
177
  }
186
178
  }
187
179
  }
188
- //# sourceMappingURL=lifecycleManager.shutdown.js.map
@@ -14,4 +14,3 @@ import type { LifecycleManagerContext } from "./lifecycleManager.context.js";
14
14
  * just been torn down.
15
15
  */
16
16
  export declare function performStartup(ctx: LifecycleManagerContext): Promise<void>;
17
- //# sourceMappingURL=lifecycleManager.startup.d.ts.map