@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.
- package/README.md +44 -15
- package/dist/index.d.ts +2 -3
- package/dist/index.js +7 -1
- package/dist/lifecycleComponent/index.d.ts +0 -1
- package/dist/lifecycleComponent/index.js +0 -1
- package/dist/lifecycleComponent/lifecycleComponent.type.d.ts +27 -8
- package/dist/lifecycleComponent/lifecycleComponent.type.js +0 -1
- package/dist/lifecycleContext/index.d.ts +0 -1
- package/dist/lifecycleContext/index.js +0 -1
- package/dist/lifecycleContext/lifecycleContext.type.d.ts +0 -1
- package/dist/lifecycleContext/lifecycleContext.type.js +0 -1
- package/dist/lifecycleEvents/index.d.ts +0 -1
- package/dist/lifecycleEvents/index.js +0 -1
- package/dist/lifecycleEvents/lifecycleEvents.core.d.ts +20 -5
- package/dist/lifecycleEvents/lifecycleEvents.core.js +0 -1
- package/dist/lifecycleExecutor/index.d.ts +3 -3
- package/dist/lifecycleExecutor/index.js +2 -2
- package/dist/lifecycleExecutor/lifecycleExecutor.abandoned.d.ts +31 -0
- package/dist/lifecycleExecutor/lifecycleExecutor.abandoned.js +75 -0
- package/dist/lifecycleExecutor/lifecycleExecutor.core.d.ts +18 -24
- package/dist/lifecycleExecutor/lifecycleExecutor.core.js +64 -105
- package/dist/lifecycleExecutor/lifecycleExecutor.retry.d.ts +30 -0
- package/dist/lifecycleExecutor/lifecycleExecutor.retry.js +66 -0
- package/dist/lifecycleExecutor/lifecycleExecutor.type.d.ts +50 -0
- package/dist/lifecycleExecutor/lifecycleExecutor.type.js +6 -0
- package/dist/lifecycleInternal/asyncUtils.core.d.ts +0 -1
- package/dist/lifecycleInternal/asyncUtils.core.js +0 -1
- package/dist/lifecycleInternal/dependencyGraph.core.d.ts +34 -7
- package/dist/lifecycleInternal/dependencyGraph.core.js +55 -38
- package/dist/lifecycleInternal/dependencyGraph.cycle.d.ts +20 -0
- package/dist/lifecycleInternal/dependencyGraph.cycle.js +57 -0
- package/dist/lifecycleInternal/index.d.ts +1 -1
- package/dist/lifecycleInternal/index.js +0 -1
- package/dist/lifecycleInternal/timeoutBudget.core.d.ts +0 -1
- package/dist/lifecycleInternal/timeoutBudget.core.js +0 -1
- package/dist/lifecycleInternal/topologicalSort.core.d.ts +12 -2
- package/dist/lifecycleInternal/topologicalSort.core.js +41 -22
- package/dist/lifecycleManager/index.d.ts +0 -1
- package/dist/lifecycleManager/index.js +0 -1
- package/dist/lifecycleManager/lifecycleManager.context.d.ts +15 -7
- package/dist/lifecycleManager/lifecycleManager.context.js +0 -1
- package/dist/lifecycleManager/lifecycleManager.core.d.ts +12 -2
- package/dist/lifecycleManager/lifecycleManager.core.js +30 -3
- package/dist/lifecycleManager/lifecycleManager.deadline.d.ts +29 -0
- package/dist/lifecycleManager/lifecycleManager.deadline.js +79 -0
- package/dist/lifecycleManager/lifecycleManager.shutdown.d.ts +0 -1
- package/dist/lifecycleManager/lifecycleManager.shutdown.js +60 -69
- package/dist/lifecycleManager/lifecycleManager.startup.d.ts +0 -1
- package/dist/lifecycleManager/lifecycleManager.startup.js +12 -4
- package/dist/lifecyclePhase/index.d.ts +0 -1
- package/dist/lifecyclePhase/index.js +0 -1
- package/dist/lifecyclePhase/lifecyclePhase.core.d.ts +0 -1
- package/dist/lifecyclePhase/lifecyclePhase.core.js +0 -1
- package/dist/lifecyclePlan/index.d.ts +0 -1
- package/dist/lifecyclePlan/index.js +0 -1
- package/dist/lifecyclePlan/lifecyclePlan.core.d.ts +0 -1
- package/dist/lifecyclePlan/lifecyclePlan.core.js +0 -2
- package/dist/lifecycleRegistry/index.d.ts +0 -1
- package/dist/lifecycleRegistry/index.js +0 -1
- package/dist/lifecycleRegistry/lifecycleRegistry.core.d.ts +0 -1
- package/dist/lifecycleRegistry/lifecycleRegistry.core.js +3 -2
- package/dist/lifecycleSignal/index.d.ts +0 -1
- package/dist/lifecycleSignal/index.js +0 -1
- package/dist/lifecycleSignal/lifecycleSignal.handler.d.ts +0 -1
- package/dist/lifecycleSignal/lifecycleSignal.handler.js +0 -1
- package/dist/lifecycleState/index.d.ts +0 -1
- package/dist/lifecycleState/index.js +0 -1
- package/dist/lifecycleState/lifecycleState.machine.d.ts +0 -1
- package/dist/lifecycleState/lifecycleState.machine.js +0 -1
- 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
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
while (
|
|
22
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
@@ -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.
|
|
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
|
|
25
|
+
* Startup phases each component reached.
|
|
26
26
|
*
|
|
27
27
|
* Shutdown consults this so `stop()` is only invoked on components
|
|
28
|
-
* whose `start`
|
|
29
|
-
* `initialize`
|
|
30
|
-
* call `stop()` on components that had never
|
|
31
|
-
* real server throws and was then reported as
|
|
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
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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,
|
|
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
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
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
|
-
|
|
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`
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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(
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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(
|
|
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
|