@zudojs/lifecycle 1.0.0 → 1.1.1
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 +33 -3
- package/dist/lifecycleComponent/lifecycleComponent.type.d.ts +4 -1
- package/dist/lifecycleExecutor/lifecycleExecutor.core.d.ts +9 -0
- package/dist/lifecycleExecutor/lifecycleExecutor.core.js +30 -6
- package/dist/lifecycleInternal/asyncUtils.core.d.ts +4 -0
- package/dist/lifecycleInternal/asyncUtils.core.js +23 -2
- package/dist/lifecycleInternal/index.d.ts +1 -0
- package/dist/lifecycleInternal/index.js +1 -0
- package/dist/lifecycleInternal/timeoutBudget.core.d.ts +30 -0
- package/dist/lifecycleInternal/timeoutBudget.core.js +38 -0
- package/dist/lifecycleManager/lifecycleManager.context.d.ts +33 -0
- package/dist/lifecycleManager/lifecycleManager.context.js +20 -0
- package/dist/lifecycleManager/lifecycleManager.core.d.ts +12 -2
- package/dist/lifecycleManager/lifecycleManager.core.js +38 -12
- package/dist/lifecycleManager/lifecycleManager.shutdown.js +55 -7
- package/dist/lifecycleManager/lifecycleManager.startup.js +89 -7
- package/dist/lifecycleRegistry/lifecycleRegistry.core.js +4 -1
- package/dist/lifecycleSignal/lifecycleSignal.handler.d.ts +9 -0
- package/dist/lifecycleSignal/lifecycleSignal.handler.js +9 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
Application and component lifecycle orchestration with a state machine,
|
|
4
4
|
dependency ordering, graceful shutdown, rollback, and signal handling.
|
|
5
5
|
|
|
6
|
+
<!-- zudo-docs:start -->
|
|
7
|
+
|
|
8
|
+
**Documentation:** [zudojs.oyinlola.site/docs/packages-lifecycle](https://zudojs.oyinlola.site/docs/packages-lifecycle) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-lifecycle.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
|
|
9
|
+
|
|
10
|
+
<!-- zudo-docs:end -->
|
|
11
|
+
|
|
6
12
|
## Installation
|
|
7
13
|
|
|
8
14
|
```bash
|
|
@@ -48,7 +54,19 @@ If a **critical** component (the default) fails any startup phase,
|
|
|
48
54
|
`start()` rolls the application back (`stop` → `dispose`) and then
|
|
49
55
|
**rejects** with a `LifecycleStartError`. Register a component with
|
|
50
56
|
`{ critical: false }` when its failure should not abort startup — the
|
|
51
|
-
component is marked `FAILED` and startup continues.
|
|
57
|
+
component is marked `FAILED` and startup continues. A failed component
|
|
58
|
+
takes no further part in startup (its later hooks are not invoked), and
|
|
59
|
+
components that `dependsOn` it are not started either: they are marked
|
|
60
|
+
`FAILED` with a `LifecycleComponentError` naming the failed dependency,
|
|
61
|
+
and their own `critical` flag decides whether startup aborts.
|
|
62
|
+
|
|
63
|
+
Rollback only undoes phases that ran: `stop()` is called on components
|
|
64
|
+
whose `start` phase ran, and `dispose()` on components whose
|
|
65
|
+
`initialize` phase ran.
|
|
66
|
+
|
|
67
|
+
Calling `shutdown()` while `start()` is in flight waits for the
|
|
68
|
+
executing stage to settle, tears down, and makes `start()` reject with a
|
|
69
|
+
`LifecycleStartError` — later stages are never launched.
|
|
52
70
|
|
|
53
71
|
## Shutdown
|
|
54
72
|
|
|
@@ -97,8 +115,8 @@ lifecycle.
|
|
|
97
115
|
```typescript
|
|
98
116
|
createLifecycleManager({
|
|
99
117
|
concurrency: 10, // parallel component operations per stage
|
|
100
|
-
shutdownTimeout: 30_000, // global shutdown deadline (ms)
|
|
101
|
-
handleSignals: true, // install
|
|
118
|
+
shutdownTimeout: 30_000, // global shutdown deadline (ms); Infinity = none
|
|
119
|
+
handleSignals: true, // install signal handlers on start()
|
|
102
120
|
signals: ["SIGINT", "SIGTERM"], // defaults to DEFAULT_SHUTDOWN_SIGNALS
|
|
103
121
|
});
|
|
104
122
|
```
|
|
@@ -106,6 +124,18 @@ createLifecycleManager({
|
|
|
106
124
|
Per-component: `id`, `dependsOn`, `priority`, `critical`, `timeout`,
|
|
107
125
|
`retry: { attempts, delay, maxDelay, backoff }`.
|
|
108
126
|
|
|
127
|
+
`timeout` and `shutdownTimeout` accept `Infinity` for "no bound"; NaN and
|
|
128
|
+
negative values throw a `RangeError` when registered or constructed, and
|
|
129
|
+
finite values above 2^31-1 ms are clamped to the largest timer delay.
|
|
130
|
+
`retry` covers hooks that fail; a hook that times out is not retried,
|
|
131
|
+
because it is still running and a second call would overlap it.
|
|
132
|
+
`shutdown()` waits (within its deadline) for such an abandoned hook to
|
|
133
|
+
settle before calling `stop()`.
|
|
134
|
+
|
|
135
|
+
With `handleSignals`, SIGINT/SIGTERM listeners are installed by `start()`,
|
|
136
|
+
not by the constructor, and removed once shutdown finishes. A second
|
|
137
|
+
signal while shutdown is running exits the process with code 1.
|
|
138
|
+
|
|
109
139
|
## Use Cases
|
|
110
140
|
|
|
111
141
|
- Coordinating service startup and shutdown
|
|
@@ -32,7 +32,10 @@ export interface LifecycleRegistrationOptions {
|
|
|
32
32
|
readonly priority?: number;
|
|
33
33
|
/** If true, application startup fails when this component fails. Defaults to true. */
|
|
34
34
|
readonly critical?: boolean;
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Timeout in ms for individual component operations. `Infinity` means
|
|
37
|
+
* no bound; NaN and negative values are rejected at registration.
|
|
38
|
+
*/
|
|
36
39
|
readonly timeout?: number;
|
|
37
40
|
/** Retry configuration for failed operations. */
|
|
38
41
|
readonly retry?: LifecycleRetryOptions;
|
|
@@ -23,6 +23,15 @@ export interface ExecutionResult {
|
|
|
23
23
|
* Executes lifecycle component hooks with timeout, retry, and concurrency support.
|
|
24
24
|
*/
|
|
25
25
|
export declare class LifecycleExecutor {
|
|
26
|
+
/** Hook invocations still running after their timeout fired. */
|
|
27
|
+
private readonly abandoned;
|
|
28
|
+
/**
|
|
29
|
+
* Resolves once every hook abandoned by a timeout has settled.
|
|
30
|
+
*
|
|
31
|
+
* Shutdown waits on this before stopping components, so `stop()`
|
|
32
|
+
* never overlaps a `start()` that is still running.
|
|
33
|
+
*/
|
|
34
|
+
settleAbandoned(): Promise<void>;
|
|
26
35
|
/**
|
|
27
36
|
* Executes a single component hook.
|
|
28
37
|
*/
|
|
@@ -5,11 +5,24 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { withTimeout, withConcurrency } from "../lifecycleInternal/index.js";
|
|
7
7
|
import { getComponentMethod } from "../lifecyclePhase/index.js";
|
|
8
|
-
import { LifecycleComponentError } from "@zudojs/errors";
|
|
8
|
+
import { LifecycleComponentError, LifecycleTimeoutError, } from "@zudojs/errors";
|
|
9
9
|
/**
|
|
10
10
|
* Executes lifecycle component hooks with timeout, retry, and concurrency support.
|
|
11
11
|
*/
|
|
12
12
|
export class LifecycleExecutor {
|
|
13
|
+
/** Hook invocations still running after their timeout fired. */
|
|
14
|
+
abandoned = new Set();
|
|
15
|
+
/**
|
|
16
|
+
* Resolves once every hook abandoned by a timeout has settled.
|
|
17
|
+
*
|
|
18
|
+
* Shutdown waits on this before stopping components, so `stop()`
|
|
19
|
+
* never overlaps a `start()` that is still running.
|
|
20
|
+
*/
|
|
21
|
+
async settleAbandoned() {
|
|
22
|
+
while (this.abandoned.size > 0) {
|
|
23
|
+
await Promise.allSettled([...this.abandoned]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
13
26
|
/**
|
|
14
27
|
* Executes a single component hook.
|
|
15
28
|
*/
|
|
@@ -36,12 +49,13 @@ export class LifecycleExecutor {
|
|
|
36
49
|
lastError ??= new LifecycleComponentError(registration.id, phase, context.signal.reason);
|
|
37
50
|
break;
|
|
38
51
|
}
|
|
52
|
+
let invocation;
|
|
39
53
|
try {
|
|
40
|
-
await withTimeout(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
await withTimeout(() => {
|
|
55
|
+
invocation = (async () => {
|
|
56
|
+
await hook.call(registration.component, context);
|
|
57
|
+
})();
|
|
58
|
+
return invocation;
|
|
45
59
|
}, registration.timeout, registration.id, phase);
|
|
46
60
|
return {
|
|
47
61
|
id: registration.id,
|
|
@@ -52,6 +66,16 @@ export class LifecycleExecutor {
|
|
|
52
66
|
}
|
|
53
67
|
catch (error) {
|
|
54
68
|
lastError = error;
|
|
69
|
+
// A timed-out hook is still running; withTimeout cannot cancel
|
|
70
|
+
// it. Retrying would run the same start() concurrently (three
|
|
71
|
+
// listen() calls on one port), so a timeout is final and the
|
|
72
|
+
// abandoned invocation is tracked for shutdown to wait on.
|
|
73
|
+
if (error instanceof LifecycleTimeoutError && invocation) {
|
|
74
|
+
const abandoned = invocation.catch(() => undefined);
|
|
75
|
+
this.abandoned.add(abandoned);
|
|
76
|
+
void abandoned.finally(() => this.abandoned.delete(abandoned));
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
55
79
|
if (attempt < maxAttempts - 1) {
|
|
56
80
|
const delay = calculateDelay(retryConfig, attempt);
|
|
57
81
|
await sleep(delay, context.signal);
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
/**
|
|
7
7
|
* Executes an async operation with a timeout.
|
|
8
8
|
* Throws LifecycleTimeoutError if the timeout is exceeded.
|
|
9
|
+
*
|
|
10
|
+
* `Infinity` runs the operation unbounded; values above the largest
|
|
11
|
+
* timer delay are clamped to it. NaN or a negative value rejects with a
|
|
12
|
+
* RangeError instead of arming a 1 ms timer.
|
|
9
13
|
*/
|
|
10
14
|
export declare function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, componentId: string, phase: string): Promise<T>;
|
|
11
15
|
/**
|
|
@@ -4,15 +4,24 @@
|
|
|
4
4
|
* Async utilities for timeout, abort, and concurrency control.
|
|
5
5
|
*/
|
|
6
6
|
import { LifecycleTimeoutError } from "@zudojs/errors";
|
|
7
|
+
import { assertTimeoutBudget, isBounded, toTimerDelay, } from "./timeoutBudget.core.js";
|
|
7
8
|
/**
|
|
8
9
|
* Executes an async operation with a timeout.
|
|
9
10
|
* Throws LifecycleTimeoutError if the timeout is exceeded.
|
|
11
|
+
*
|
|
12
|
+
* `Infinity` runs the operation unbounded; values above the largest
|
|
13
|
+
* timer delay are clamped to it. NaN or a negative value rejects with a
|
|
14
|
+
* RangeError instead of arming a 1 ms timer.
|
|
10
15
|
*/
|
|
11
16
|
export async function withTimeout(fn, timeoutMs, componentId, phase) {
|
|
17
|
+
assertTimeoutBudget("timeout", timeoutMs);
|
|
18
|
+
if (!isBounded(timeoutMs)) {
|
|
19
|
+
return fn();
|
|
20
|
+
}
|
|
12
21
|
return new Promise((resolve, reject) => {
|
|
13
22
|
const timer = setTimeout(() => {
|
|
14
23
|
reject(new LifecycleTimeoutError(componentId, phase, timeoutMs));
|
|
15
|
-
}, timeoutMs);
|
|
24
|
+
}, toTimerDelay(timeoutMs));
|
|
16
25
|
// The timer must be cleared on EVERY exit path. A synchronously
|
|
17
26
|
// throwing `fn` used to escape before `.catch` was attached,
|
|
18
27
|
// leaving an armed timer that kept the event loop alive for the
|
|
@@ -47,7 +56,19 @@ export async function withAbort(fn, signal) {
|
|
|
47
56
|
reject(new Error("Operation aborted"));
|
|
48
57
|
};
|
|
49
58
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
50
|
-
fn
|
|
59
|
+
// A synchronously throwing `fn` rejected via the Promise executor
|
|
60
|
+
// but skipped both `.then` branches, so its abort listener was
|
|
61
|
+
// never removed and accumulated on a long-lived signal.
|
|
62
|
+
let operation;
|
|
63
|
+
try {
|
|
64
|
+
operation = fn(signal);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
signal.removeEventListener("abort", onAbort);
|
|
68
|
+
reject(error);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
operation
|
|
51
72
|
.then((result) => {
|
|
52
73
|
signal.removeEventListener("abort", onAbort);
|
|
53
74
|
resolve(result);
|
|
@@ -7,4 +7,5 @@ export { DependencyGraph } from "./dependencyGraph.core.js";
|
|
|
7
7
|
export { topologicalSort, reverseTopologicalSort, } from "./topologicalSort.core.js";
|
|
8
8
|
export type { TopologicalStage } from "./topologicalSort.core.js";
|
|
9
9
|
export { withTimeout, withAbort, withConcurrency } from "./asyncUtils.core.js";
|
|
10
|
+
export { MAX_TIMER_DELAY, assertTimeoutBudget, isBounded, toTimerDelay, } from "./timeoutBudget.core.js";
|
|
10
11
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -6,4 +6,5 @@
|
|
|
6
6
|
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
|
+
export { MAX_TIMER_DELAY, assertTimeoutBudget, isBounded, toTimerDelay, } from "./timeoutBudget.core.js";
|
|
9
10
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/lifecycle/internal/timeout-budget
|
|
3
|
+
*
|
|
4
|
+
* Validation and normalisation for millisecond budgets handed to timers.
|
|
5
|
+
*/
|
|
6
|
+
/** Largest delay `setTimeout` can represent (2^31 - 1 ms). */
|
|
7
|
+
export declare const MAX_TIMER_DELAY = 2147483647;
|
|
8
|
+
/**
|
|
9
|
+
* Validates a millisecond budget.
|
|
10
|
+
*
|
|
11
|
+
* `Infinity` is accepted and means "no bound". NaN, negative values and
|
|
12
|
+
* non-numbers are rejected up front: `setTimeout` silently turns them
|
|
13
|
+
* (and `Infinity`) into a 1 ms timer, and `LifecycleTimeoutError`'s
|
|
14
|
+
* constructor then threw inside that timer callback, which crashed the
|
|
15
|
+
* process instead of failing the component.
|
|
16
|
+
*
|
|
17
|
+
* @param name - Option name, used in the error message.
|
|
18
|
+
* @param value - The budget to check.
|
|
19
|
+
* @throws RangeError when the value is not a non-negative number.
|
|
20
|
+
*/
|
|
21
|
+
export declare function assertTimeoutBudget(name: string, value: unknown): void;
|
|
22
|
+
/**
|
|
23
|
+
* Whether a validated budget actually bounds anything.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isBounded(value: number): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Clamps a finite budget to what a timer can represent.
|
|
28
|
+
*/
|
|
29
|
+
export declare function toTimerDelay(value: number): number;
|
|
30
|
+
//# sourceMappingURL=timeoutBudget.core.d.ts.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/lifecycle/internal/timeout-budget
|
|
3
|
+
*
|
|
4
|
+
* Validation and normalisation for millisecond budgets handed to timers.
|
|
5
|
+
*/
|
|
6
|
+
/** Largest delay `setTimeout` can represent (2^31 - 1 ms). */
|
|
7
|
+
export const MAX_TIMER_DELAY = 2_147_483_647;
|
|
8
|
+
/**
|
|
9
|
+
* Validates a millisecond budget.
|
|
10
|
+
*
|
|
11
|
+
* `Infinity` is accepted and means "no bound". NaN, negative values and
|
|
12
|
+
* non-numbers are rejected up front: `setTimeout` silently turns them
|
|
13
|
+
* (and `Infinity`) into a 1 ms timer, and `LifecycleTimeoutError`'s
|
|
14
|
+
* constructor then threw inside that timer callback, which crashed the
|
|
15
|
+
* process instead of failing the component.
|
|
16
|
+
*
|
|
17
|
+
* @param name - Option name, used in the error message.
|
|
18
|
+
* @param value - The budget to check.
|
|
19
|
+
* @throws RangeError when the value is not a non-negative number.
|
|
20
|
+
*/
|
|
21
|
+
export function assertTimeoutBudget(name, value) {
|
|
22
|
+
if (typeof value !== "number" || Number.isNaN(value) || value < 0) {
|
|
23
|
+
throw new RangeError(`${name} must be a non-negative number of milliseconds (Infinity for no bound), got ${String(value)}.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Whether a validated budget actually bounds anything.
|
|
28
|
+
*/
|
|
29
|
+
export function isBounded(value) {
|
|
30
|
+
return Number.isFinite(value);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Clamps a finite budget to what a timer can represent.
|
|
34
|
+
*/
|
|
35
|
+
export function toTimerDelay(value) {
|
|
36
|
+
return Math.min(Math.max(0, value), MAX_TIMER_DELAY);
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=timeoutBudget.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,6 +21,16 @@ export interface LifecycleManagerContext {
|
|
|
20
21
|
readonly shutdownTimeout: number;
|
|
21
22
|
readonly componentStates: Map<string, LifecycleStateMachine>;
|
|
22
23
|
readonly results: Map<string, ExecutionResult[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Startup phases that were actually run for each component.
|
|
26
|
+
*
|
|
27
|
+
* Shutdown consults this so `stop()` is only invoked on components
|
|
28
|
+
* whose `start` phase ran and `dispose()` only on components whose
|
|
29
|
+
* `initialize` phase ran. Rollback after an early failure used to
|
|
30
|
+
* call `stop()` on components that had never started, which for a
|
|
31
|
+
* real server throws and was then reported as a component failure.
|
|
32
|
+
*/
|
|
33
|
+
readonly attempted: Map<string, Set<LifecyclePhase>>;
|
|
23
34
|
startTime: number;
|
|
24
35
|
/**
|
|
25
36
|
* Cancellation source for the current run.
|
|
@@ -39,6 +50,16 @@ export interface LifecycleManagerContext {
|
|
|
39
50
|
* teardown.
|
|
40
51
|
*/
|
|
41
52
|
shutdownPromise?: Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* The startup stage currently executing, if any.
|
|
55
|
+
*
|
|
56
|
+
* A shutdown requested mid-startup waits for this to settle before
|
|
57
|
+
* running `stop()`, so a component is never stopped while its own
|
|
58
|
+
* `start()` is still in flight, and startup checks for a requested
|
|
59
|
+
* shutdown before launching each further stage so no hook runs
|
|
60
|
+
* after teardown has completed.
|
|
61
|
+
*/
|
|
62
|
+
inFlight?: Promise<unknown>;
|
|
42
63
|
}
|
|
43
64
|
/** Records an execution result against its component. */
|
|
44
65
|
export declare function recordResult(ctx: LifecycleManagerContext, result: ExecutionResult): void;
|
|
@@ -47,6 +68,18 @@ export declare function recordResult(ctx: LifecycleManagerContext, result: Execu
|
|
|
47
68
|
* No-op if the transition is not valid from the current state.
|
|
48
69
|
*/
|
|
49
70
|
export declare function transitionComponent(ctx: LifecycleManagerContext, id: string, targetState: LifecycleState): void;
|
|
71
|
+
/**
|
|
72
|
+
* Marks a component as FAILED regardless of its current state.
|
|
73
|
+
*
|
|
74
|
+
* Used for components that are skipped because a dependency failed:
|
|
75
|
+
* they have not entered the phase, so no validated transition leads to
|
|
76
|
+
* FAILED from their current (IDLE / INITIALIZED / STARTED) state.
|
|
77
|
+
*/
|
|
78
|
+
export declare function failComponent(ctx: LifecycleManagerContext, id: string): void;
|
|
79
|
+
/** Records that a startup phase ran for a component. */
|
|
80
|
+
export declare function markAttempted(ctx: LifecycleManagerContext, id: string, phase: LifecyclePhase): void;
|
|
81
|
+
/** Returns whether a startup phase ran for a component. */
|
|
82
|
+
export declare function wasAttempted(ctx: LifecycleManagerContext, id: string, phase: LifecyclePhase): boolean;
|
|
50
83
|
/**
|
|
51
84
|
* Transitions multiple component state machines to the same target state.
|
|
52
85
|
*/
|
|
@@ -20,6 +20,26 @@ export function transitionComponent(ctx, id, targetState) {
|
|
|
20
20
|
sm.transition(targetState);
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Marks a component as FAILED regardless of its current state.
|
|
25
|
+
*
|
|
26
|
+
* Used for components that are skipped because a dependency failed:
|
|
27
|
+
* they have not entered the phase, so no validated transition leads to
|
|
28
|
+
* FAILED from their current (IDLE / INITIALIZED / STARTED) state.
|
|
29
|
+
*/
|
|
30
|
+
export function failComponent(ctx, id) {
|
|
31
|
+
ctx.componentStates.get(id)?.forceState(LifecycleState.FAILED);
|
|
32
|
+
}
|
|
33
|
+
/** Records that a startup phase ran for a component. */
|
|
34
|
+
export function markAttempted(ctx, id, phase) {
|
|
35
|
+
const phases = ctx.attempted.get(id) ?? new Set();
|
|
36
|
+
phases.add(phase);
|
|
37
|
+
ctx.attempted.set(id, phases);
|
|
38
|
+
}
|
|
39
|
+
/** Returns whether a startup phase ran for a component. */
|
|
40
|
+
export function wasAttempted(ctx, id, phase) {
|
|
41
|
+
return ctx.attempted.get(id)?.has(phase) ?? false;
|
|
42
|
+
}
|
|
23
43
|
/**
|
|
24
44
|
* Transitions multiple component state machines to the same target state.
|
|
25
45
|
*/
|
|
@@ -13,9 +13,16 @@ import { LifecycleEventEmitter } from "../lifecycleEvents/lifecycleEvents.core.j
|
|
|
13
13
|
export interface LifecycleManagerOptions {
|
|
14
14
|
/** Maximum concurrent component operations. */
|
|
15
15
|
readonly concurrency?: number;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Global shutdown timeout in ms. `Infinity` means no deadline; NaN
|
|
18
|
+
* and negative values are rejected by the constructor.
|
|
19
|
+
*/
|
|
17
20
|
readonly shutdownTimeout?: number;
|
|
18
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Whether to install SIGINT/SIGTERM handlers. They are installed by
|
|
23
|
+
* `start()` (not the constructor) and removed once shutdown finishes,
|
|
24
|
+
* and a second signal during shutdown exits with code 1.
|
|
25
|
+
*/
|
|
19
26
|
readonly handleSignals?: boolean;
|
|
20
27
|
/** Signals to listen for. */
|
|
21
28
|
readonly signals?: readonly NodeJS.Signals[];
|
|
@@ -37,6 +44,8 @@ export declare class LifecycleManager {
|
|
|
37
44
|
private readonly _ctx;
|
|
38
45
|
private _startPromise?;
|
|
39
46
|
private _removeSignalHandlers?;
|
|
47
|
+
private readonly _handleSignals;
|
|
48
|
+
private readonly _signals;
|
|
40
49
|
constructor(options?: LifecycleManagerOptions);
|
|
41
50
|
/** Registers a component with the lifecycle manager. */
|
|
42
51
|
register(component: LifecycleComponent, options?: LifecycleRegistrationOptions): void;
|
|
@@ -50,6 +59,7 @@ export declare class LifecycleManager {
|
|
|
50
59
|
* Idempotent — returns the same promise if called multiple times.
|
|
51
60
|
*/
|
|
52
61
|
shutdown(): Promise<void>;
|
|
62
|
+
private releaseSignalHandlers;
|
|
53
63
|
/** Returns the current application state. */
|
|
54
64
|
get state(): LifecycleState;
|
|
55
65
|
/** Returns the event emitter for lifecycle events. */
|
|
@@ -12,6 +12,7 @@ import { LifecycleEventEmitter } from "../lifecycleEvents/lifecycleEvents.core.j
|
|
|
12
12
|
import { installSignalHandlers } from "../lifecycleSignal/lifecycleSignal.handler.js";
|
|
13
13
|
import { performStartup } from "./lifecycleManager.startup.js";
|
|
14
14
|
import { performShutdown } from "./lifecycleManager.shutdown.js";
|
|
15
|
+
import { assertTimeoutBudget } from "../lifecycleInternal/index.js";
|
|
15
16
|
/**
|
|
16
17
|
* Orchestrates application and component lifecycle.
|
|
17
18
|
*
|
|
@@ -29,7 +30,12 @@ export class LifecycleManager {
|
|
|
29
30
|
_ctx;
|
|
30
31
|
_startPromise;
|
|
31
32
|
_removeSignalHandlers;
|
|
33
|
+
_handleSignals;
|
|
34
|
+
_signals;
|
|
32
35
|
constructor(options = {}) {
|
|
36
|
+
if (options.shutdownTimeout !== undefined) {
|
|
37
|
+
assertTimeoutBudget("shutdownTimeout", options.shutdownTimeout);
|
|
38
|
+
}
|
|
33
39
|
this._ctx = {
|
|
34
40
|
registry: new LifecycleRegistry(),
|
|
35
41
|
state: new LifecycleStateMachine("application"),
|
|
@@ -39,17 +45,16 @@ export class LifecycleManager {
|
|
|
39
45
|
shutdownTimeout: options.shutdownTimeout ?? LIFECYCLE_DEFAULT_SHUTDOWN_TIMEOUT,
|
|
40
46
|
componentStates: new Map(),
|
|
41
47
|
results: new Map(),
|
|
48
|
+
attempted: new Map(),
|
|
42
49
|
startTime: 0,
|
|
43
50
|
controller: new AbortController(),
|
|
44
51
|
};
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
});
|
|
52
|
-
}
|
|
52
|
+
// Installing in the constructor disabled Ctrl-C for the whole
|
|
53
|
+
// process as soon as a manager existed (tests, libraries), and the
|
|
54
|
+
// listener outlived shutdown, so a process with a leaked handle
|
|
55
|
+
// could no longer be interrupted.
|
|
56
|
+
this._handleSignals = options.handleSignals !== false;
|
|
57
|
+
this._signals = options.signals;
|
|
53
58
|
}
|
|
54
59
|
/** Registers a component with the lifecycle manager. */
|
|
55
60
|
register(component, options = {}) {
|
|
@@ -69,7 +74,20 @@ export class LifecycleManager {
|
|
|
69
74
|
if (this._startPromise) {
|
|
70
75
|
return this._startPromise;
|
|
71
76
|
}
|
|
72
|
-
this.
|
|
77
|
+
if (this._handleSignals && this._removeSignalHandlers === undefined) {
|
|
78
|
+
this._removeSignalHandlers = installSignalHandlers({
|
|
79
|
+
signals: this._signals,
|
|
80
|
+
handler: () => {
|
|
81
|
+
void this.shutdown();
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
this._startPromise = performStartup(this._ctx).catch(async (error) => {
|
|
86
|
+
// A failed startup rolls back through the shared shutdown.
|
|
87
|
+
await this._ctx.shutdownPromise?.catch(() => undefined);
|
|
88
|
+
this.releaseSignalHandlers();
|
|
89
|
+
throw error;
|
|
90
|
+
});
|
|
73
91
|
return this._startPromise;
|
|
74
92
|
}
|
|
75
93
|
/**
|
|
@@ -79,7 +97,16 @@ export class LifecycleManager {
|
|
|
79
97
|
async shutdown() {
|
|
80
98
|
// performShutdown is itself single-flight, so a shutdown started by
|
|
81
99
|
// startup rollback and one started here are the SAME run.
|
|
82
|
-
|
|
100
|
+
try {
|
|
101
|
+
await performShutdown(this._ctx);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
this.releaseSignalHandlers();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
releaseSignalHandlers() {
|
|
108
|
+
this._removeSignalHandlers?.();
|
|
109
|
+
this._removeSignalHandlers = undefined;
|
|
83
110
|
}
|
|
84
111
|
/** Returns the current application state. */
|
|
85
112
|
get state() {
|
|
@@ -111,8 +138,7 @@ export class LifecycleManager {
|
|
|
111
138
|
* component teardown — call `shutdown()` first for that.
|
|
112
139
|
*/
|
|
113
140
|
dispose() {
|
|
114
|
-
this.
|
|
115
|
-
this._removeSignalHandlers = undefined;
|
|
141
|
+
this.releaseSignalHandlers();
|
|
116
142
|
this._ctx.events.clear();
|
|
117
143
|
}
|
|
118
144
|
}
|
|
@@ -6,7 +6,8 @@
|
|
|
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 {
|
|
9
|
+
import { isBounded, toTimerDelay } from "../lifecycleInternal/index.js";
|
|
10
|
+
import { emitComponentFailed, recordResult, transitionComponent, wasAttempted, } from "./lifecycleManager.context.js";
|
|
10
11
|
/** Shutdown phases in execution order. */
|
|
11
12
|
const SHUTDOWN_PHASES = [LifecyclePhase.STOP, LifecyclePhase.DISPOSE];
|
|
12
13
|
/**
|
|
@@ -38,6 +39,16 @@ async function runShutdown(ctx) {
|
|
|
38
39
|
}
|
|
39
40
|
ctx.events.emit("application:stopping", {});
|
|
40
41
|
const deadline = Date.now() + ctx.shutdownTimeout;
|
|
42
|
+
// A startup stage still executing must settle before its components
|
|
43
|
+
// are stopped, otherwise `stop()` overlaps the component's own
|
|
44
|
+
// `start()`. Startup itself refuses to launch further stages once
|
|
45
|
+
// `shutdownPromise` is set, so this wait is bounded by one stage.
|
|
46
|
+
if (ctx.inFlight !== undefined) {
|
|
47
|
+
await raceDeadline(ctx, ctx.inFlight.then(() => undefined, () => undefined), Math.max(deadline - Date.now(), 1));
|
|
48
|
+
}
|
|
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));
|
|
41
52
|
// The shutdown deadline used to be checked only BETWEEN the two
|
|
42
53
|
// phases, so a single hook that never settled hung shutdown (and the
|
|
43
54
|
// process) forever. Race the whole phase against the remaining
|
|
@@ -72,12 +83,19 @@ async function runShutdown(ctx) {
|
|
|
72
83
|
* so it can never hold the event loop open.
|
|
73
84
|
*/
|
|
74
85
|
async function raceDeadline(ctx, phase, remainingMs) {
|
|
86
|
+
// An unbounded budget (shutdownTimeout: Infinity) waits for the phase.
|
|
87
|
+
// Handing Infinity to setTimeout fired after 1 ms and abandoned every
|
|
88
|
+
// stop()/dispose() while reporting the application DISPOSED.
|
|
89
|
+
if (!isBounded(remainingMs)) {
|
|
90
|
+
await phase.catch(() => { });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
75
93
|
let timer;
|
|
76
94
|
const expiry = new Promise((resolve) => {
|
|
77
95
|
timer = setTimeout(() => {
|
|
78
96
|
ctx.controller.abort(new Error(`Lifecycle shutdown exceeded its ${ctx.shutdownTimeout}ms deadline.`));
|
|
79
97
|
resolve();
|
|
80
|
-
}, remainingMs);
|
|
98
|
+
}, toTimerDelay(remainingMs));
|
|
81
99
|
});
|
|
82
100
|
try {
|
|
83
101
|
await Promise.race([phase, expiry]);
|
|
@@ -91,11 +109,24 @@ async function raceDeadline(ctx, phase, remainingMs) {
|
|
|
91
109
|
// rejection once the race has been decided.
|
|
92
110
|
void phase.catch(() => { });
|
|
93
111
|
}
|
|
94
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Executes a single shutdown phase across all registered components.
|
|
114
|
+
*
|
|
115
|
+
* A hook only runs for components that reached the matching startup
|
|
116
|
+
* phase: `stop()` when `start` ran, `dispose()` when `initialize` ran.
|
|
117
|
+
* Rollback after an early failure — and `shutdown()` on a manager that
|
|
118
|
+
* was never started — used to call `stop()` on components that had
|
|
119
|
+
* never started; a real server's `close()` throws in that situation
|
|
120
|
+
* and the phantom failure was then recorded against the component.
|
|
121
|
+
*/
|
|
95
122
|
async function executeShutdownPhase(ctx, phase) {
|
|
96
123
|
const plan = buildExecutionPlan(ctx.registry.getAll(), phase);
|
|
97
124
|
const context = createLifecycleContext(phase, ctx.startTime, ctx.controller.signal);
|
|
98
|
-
const
|
|
125
|
+
const isStop = phase === LifecyclePhase.STOP;
|
|
126
|
+
const prerequisite = isStop
|
|
127
|
+
? LifecyclePhase.START
|
|
128
|
+
: LifecyclePhase.INITIALIZE;
|
|
129
|
+
const successState = isStop
|
|
99
130
|
? LifecycleState.STOPPED
|
|
100
131
|
: LifecycleState.DISPOSED;
|
|
101
132
|
for (const stage of plan.stages) {
|
|
@@ -104,13 +135,30 @@ async function executeShutdownPhase(ctx, phase) {
|
|
|
104
135
|
.filter((r) => r !== undefined);
|
|
105
136
|
if (stageRegs.length === 0)
|
|
106
137
|
continue;
|
|
138
|
+
const runnable = [];
|
|
107
139
|
for (const reg of stageRegs) {
|
|
108
|
-
|
|
140
|
+
if (!wasAttempted(ctx, reg.id, prerequisite)) {
|
|
141
|
+
// Never reached the phase this hook undoes. It still ends up
|
|
142
|
+
// DISPOSED so status reflects the teardown.
|
|
143
|
+
if (!isStop) {
|
|
144
|
+
transitionComponent(ctx, reg.id, LifecycleState.DISPOSED);
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
runnable.push(reg);
|
|
149
|
+
// Only the stop phase moves a component into STOPPING; dispose
|
|
150
|
+
// runs from STOPPED (or FAILED) and transitions straight to
|
|
151
|
+
// DISPOSED.
|
|
152
|
+
if (isStop) {
|
|
153
|
+
transitionComponent(ctx, reg.id, LifecycleState.STOPPING);
|
|
154
|
+
}
|
|
109
155
|
ctx.events.emit("component:stopping", {
|
|
110
156
|
component: { componentId: reg.id },
|
|
111
157
|
});
|
|
112
158
|
}
|
|
113
|
-
|
|
159
|
+
if (runnable.length === 0)
|
|
160
|
+
continue;
|
|
161
|
+
const results = await ctx.executor.executeStage(runnable, phase, context, ctx.concurrency);
|
|
114
162
|
// Shutdown results used to be discarded entirely: a component whose
|
|
115
163
|
// stop() or dispose() threw was still reported as cleanly STOPPED,
|
|
116
164
|
// its failure never reached getStatus() or the event stream, and
|
|
@@ -118,7 +166,7 @@ async function executeShutdownPhase(ctx, phase) {
|
|
|
118
166
|
for (const result of results) {
|
|
119
167
|
recordResult(ctx, result);
|
|
120
168
|
if (result.success) {
|
|
121
|
-
transitionComponent(ctx, result.id,
|
|
169
|
+
transitionComponent(ctx, result.id, successState);
|
|
122
170
|
ctx.events.emit("component:stopped", {
|
|
123
171
|
component: { componentId: result.id, duration: result.duration },
|
|
124
172
|
});
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* Startup orchestration — initializes, starts, and readies components.
|
|
5
5
|
*/
|
|
6
6
|
import { LifecyclePhase, LifecycleState } from "@zudojs/constants";
|
|
7
|
-
import { LifecycleStartError } from "@zudojs/errors";
|
|
7
|
+
import { LifecycleComponentError, LifecycleStartError } from "@zudojs/errors";
|
|
8
8
|
import { buildExecutionPlan } from "../lifecyclePlan/lifecyclePlan.core.js";
|
|
9
9
|
import { createLifecycleContext } from "../lifecycleContext/lifecycleContext.type.js";
|
|
10
|
-
import { transitionComponent, transitionComponentBatch, emitComponentFailed, recordResult, } from "./lifecycleManager.context.js";
|
|
10
|
+
import { transitionComponent, transitionComponentBatch, emitComponentFailed, failComponent, markAttempted, recordResult, } from "./lifecycleManager.context.js";
|
|
11
11
|
import { performShutdown } from "./lifecycleManager.shutdown.js";
|
|
12
12
|
/** Maps startup phases to their target component state after success. */
|
|
13
13
|
const SUCCESS_STATE = {
|
|
@@ -68,6 +68,7 @@ export async function performStartup(ctx) {
|
|
|
68
68
|
if (failedInit) {
|
|
69
69
|
throw new LifecycleStartError(failedInit.id, failedInit.error);
|
|
70
70
|
}
|
|
71
|
+
assertNotShuttingDown(ctx, LifecyclePhase.INITIALIZE);
|
|
71
72
|
ctx.state.transition(LifecycleState.INITIALIZED);
|
|
72
73
|
ctx.events.emit("application:initialized", {
|
|
73
74
|
duration: Date.now() - ctx.startTime,
|
|
@@ -77,6 +78,7 @@ export async function performStartup(ctx) {
|
|
|
77
78
|
if (failedStart) {
|
|
78
79
|
throw new LifecycleStartError(failedStart.id, failedStart.error);
|
|
79
80
|
}
|
|
81
|
+
assertNotShuttingDown(ctx, LifecyclePhase.START);
|
|
80
82
|
ctx.state.transition(LifecycleState.STARTED);
|
|
81
83
|
// A failing `ready` hook on a critical component used to be
|
|
82
84
|
// ignored completely: no state change, no rollback, and start()
|
|
@@ -85,6 +87,7 @@ export async function performStartup(ctx) {
|
|
|
85
87
|
if (failedReady) {
|
|
86
88
|
throw new LifecycleStartError(failedReady.id, failedReady.error);
|
|
87
89
|
}
|
|
90
|
+
assertNotShuttingDown(ctx, LifecyclePhase.READY);
|
|
88
91
|
ctx.state.transition(LifecycleState.READY);
|
|
89
92
|
ctx.events.emit("application:ready", {
|
|
90
93
|
duration: Date.now() - ctx.startTime,
|
|
@@ -94,7 +97,10 @@ export async function performStartup(ctx) {
|
|
|
94
97
|
// Rollback happens on exactly one path, so a completed teardown is
|
|
95
98
|
// never re-entered and its DISPOSED state is never overwritten
|
|
96
99
|
// with FAILED.
|
|
97
|
-
|
|
100
|
+
// When the failure IS a requested shutdown, the teardown already
|
|
101
|
+
// owns the application state.
|
|
102
|
+
if (ctx.shutdownPromise === undefined &&
|
|
103
|
+
ctx.state.state !== LifecycleState.FAILED &&
|
|
98
104
|
ctx.state.state !== LifecycleState.DISPOSED) {
|
|
99
105
|
ctx.state.forceState(LifecycleState.FAILED);
|
|
100
106
|
}
|
|
@@ -102,9 +108,40 @@ export async function performStartup(ctx) {
|
|
|
102
108
|
throw error;
|
|
103
109
|
}
|
|
104
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Throws when a shutdown has been requested while startup is running.
|
|
113
|
+
*
|
|
114
|
+
* Startup used to keep launching later stages after `shutdown()` had
|
|
115
|
+
* already torn everything down: a component started that way was
|
|
116
|
+
* never stopped, and the eventual failure was an opaque
|
|
117
|
+
* LifecycleStateError from the DISPOSED → INITIALIZED transition.
|
|
118
|
+
*/
|
|
119
|
+
function assertNotShuttingDown(ctx, phase) {
|
|
120
|
+
if (ctx.shutdownPromise !== undefined) {
|
|
121
|
+
throw new LifecycleStartError("application", new LifecycleComponentError("application", phase, new Error("Startup was cancelled because shutdown was requested while the application was starting.")));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
105
124
|
/**
|
|
106
125
|
* Executes a single startup phase across all registered components.
|
|
107
126
|
* Returns the failure of the first critical component, or undefined.
|
|
127
|
+
*
|
|
128
|
+
* Two bookkeeping rules apply to every stage:
|
|
129
|
+
*
|
|
130
|
+
* - A component that FAILED an earlier phase, or whose dependency has
|
|
131
|
+
* failed, does not enter this phase. It used to have `start()` and
|
|
132
|
+
* `ready()` invoked after its own `initialize()` had thrown, and its
|
|
133
|
+
* dependents were started as if the dependency were healthy —
|
|
134
|
+
* silently voiding the `dependsOn` contract. A skipped dependent is
|
|
135
|
+
* recorded as FAILED with a LifecycleComponentError naming the
|
|
136
|
+
* failed dependency, and its own `critical` flag decides whether
|
|
137
|
+
* startup aborts.
|
|
138
|
+
*
|
|
139
|
+
* - Every result of a stage is recorded, transitioned and announced
|
|
140
|
+
* before a critical failure aborts the phase. Returning on the first
|
|
141
|
+
* failed result dropped the results of siblings in the same stage,
|
|
142
|
+
* which were then left in INITIALIZING / STARTING forever (no
|
|
143
|
+
* transition leads out of those states except to their success or
|
|
144
|
+
* FAILED) even after rollback had disposed them.
|
|
108
145
|
*/
|
|
109
146
|
async function executePhase(ctx, phase) {
|
|
110
147
|
const plan = buildExecutionPlan(ctx.registry.getAll(), phase);
|
|
@@ -117,21 +154,62 @@ async function executePhase(ctx, phase) {
|
|
|
117
154
|
.filter((r) => r !== undefined);
|
|
118
155
|
if (stageRegs.length === 0)
|
|
119
156
|
continue;
|
|
120
|
-
|
|
157
|
+
assertNotShuttingDown(ctx, phase);
|
|
158
|
+
const runnable = [];
|
|
159
|
+
let criticalFailure;
|
|
121
160
|
for (const reg of stageRegs) {
|
|
161
|
+
if (ctx.componentStates.get(reg.id)?.state === LifecycleState.FAILED) {
|
|
162
|
+
// Already failed in an earlier phase; nothing more to run.
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const failedDependency = reg.dependsOn.find((dep) => ctx.componentStates.get(dep)?.state === LifecycleState.FAILED);
|
|
166
|
+
if (failedDependency === undefined) {
|
|
167
|
+
runnable.push(reg);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const skipped = {
|
|
171
|
+
id: reg.id,
|
|
172
|
+
phase,
|
|
173
|
+
duration: 0,
|
|
174
|
+
success: false,
|
|
175
|
+
error: new LifecycleComponentError(reg.id, phase, new Error(`Component "${reg.id}" was not started because its dependency "${failedDependency}" failed.`)),
|
|
176
|
+
};
|
|
177
|
+
recordResult(ctx, skipped);
|
|
178
|
+
failComponent(ctx, reg.id);
|
|
179
|
+
emitComponentFailed(ctx, skipped);
|
|
180
|
+
if (reg.critical) {
|
|
181
|
+
criticalFailure ??= { id: reg.id, error: skipped.error };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (criticalFailure) {
|
|
185
|
+
ctx.state.forceState(LifecycleState.FAILED);
|
|
186
|
+
return criticalFailure;
|
|
187
|
+
}
|
|
188
|
+
if (runnable.length === 0)
|
|
189
|
+
continue;
|
|
190
|
+
transitionComponentBatch(ctx, runnable.map((r) => r.id), EXECUTING_STATE[phase]);
|
|
191
|
+
for (const reg of runnable) {
|
|
192
|
+
markAttempted(ctx, reg.id, phase);
|
|
122
193
|
ctx.events.emit(events.begin, {
|
|
123
194
|
component: { componentId: reg.id },
|
|
124
195
|
});
|
|
125
196
|
}
|
|
126
|
-
const
|
|
197
|
+
const pending = ctx.executor.executeStage(runnable, phase, context, ctx.concurrency);
|
|
198
|
+
ctx.inFlight = pending;
|
|
199
|
+
let results;
|
|
200
|
+
try {
|
|
201
|
+
results = await pending;
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
ctx.inFlight = undefined;
|
|
205
|
+
}
|
|
127
206
|
for (const result of results) {
|
|
128
207
|
recordResult(ctx, result);
|
|
129
208
|
if (!result.success) {
|
|
130
209
|
transitionComponent(ctx, result.id, LifecycleState.FAILED);
|
|
131
210
|
emitComponentFailed(ctx, result);
|
|
132
211
|
if (ctx.registry.get(result.id)?.critical) {
|
|
133
|
-
|
|
134
|
-
return { id: result.id, error: result.error };
|
|
212
|
+
criticalFailure ??= { id: result.id, error: result.error };
|
|
135
213
|
}
|
|
136
214
|
}
|
|
137
215
|
else {
|
|
@@ -141,6 +219,10 @@ async function executePhase(ctx, phase) {
|
|
|
141
219
|
});
|
|
142
220
|
}
|
|
143
221
|
}
|
|
222
|
+
if (criticalFailure) {
|
|
223
|
+
ctx.state.forceState(LifecycleState.FAILED);
|
|
224
|
+
return criticalFailure;
|
|
225
|
+
}
|
|
144
226
|
}
|
|
145
227
|
return undefined;
|
|
146
228
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Lifecycle registry — manages component registration, validation, and lookup.
|
|
5
5
|
*/
|
|
6
6
|
import { LIFECYCLE_DEFAULT_TIMEOUT } from "@zudojs/constants";
|
|
7
|
-
import { DependencyGraph } from "../lifecycleInternal/index.js";
|
|
7
|
+
import { DependencyGraph, assertTimeoutBudget, } from "../lifecycleInternal/index.js";
|
|
8
8
|
/**
|
|
9
9
|
* Registry for lifecycle components.
|
|
10
10
|
* Validates registration, builds dependency graph, and freezes on demand.
|
|
@@ -19,6 +19,9 @@ export class LifecycleRegistry {
|
|
|
19
19
|
throw new Error("Cannot register components after registry is frozen");
|
|
20
20
|
}
|
|
21
21
|
const id = options.id ?? component.name;
|
|
22
|
+
if (options.timeout !== undefined) {
|
|
23
|
+
assertTimeoutBudget(`Component "${id}" timeout`, options.timeout);
|
|
24
|
+
}
|
|
22
25
|
if (this._registrations.has(id)) {
|
|
23
26
|
throw new Error(`Component "${id}" is already registered`);
|
|
24
27
|
}
|
|
@@ -9,6 +9,15 @@ export interface SignalHandlerOptions {
|
|
|
9
9
|
readonly signals?: readonly NodeJS.Signals[];
|
|
10
10
|
/** Function to call when a signal is received. */
|
|
11
11
|
readonly handler: () => void;
|
|
12
|
+
/**
|
|
13
|
+
* Whether a second signal, received while the first is still being
|
|
14
|
+
* handled, exits the process with code 1. Defaults to `true`: the
|
|
15
|
+
* installed listener replaces Node's default exit, so without this a
|
|
16
|
+
* wedged shutdown could not be interrupted short of SIGKILL.
|
|
17
|
+
*/
|
|
18
|
+
readonly forceExitOnSecondSignal?: boolean;
|
|
19
|
+
/** Exit hook, injected for testing. Defaults to `process.exit`. */
|
|
20
|
+
readonly exit?: (code: number) => void;
|
|
12
21
|
}
|
|
13
22
|
/** Default signal configuration for graceful shutdown. */
|
|
14
23
|
export declare const DEFAULT_SHUTDOWN_SIGNALS: readonly NodeJS.Signals[];
|
|
@@ -15,9 +15,18 @@ export function installSignalHandlers(options) {
|
|
|
15
15
|
// changing the constant had no effect on the actual default.
|
|
16
16
|
const signals = options.signals ?? DEFAULT_SHUTDOWN_SIGNALS;
|
|
17
17
|
const handler = options.handler;
|
|
18
|
+
const forceExit = options.forceExitOnSecondSignal ?? true;
|
|
19
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
20
|
+
let received = false;
|
|
18
21
|
const installed = [];
|
|
19
22
|
for (const signal of signals) {
|
|
20
23
|
const listener = () => {
|
|
24
|
+
if (received) {
|
|
25
|
+
if (forceExit)
|
|
26
|
+
exit(1);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
received = true;
|
|
21
30
|
handler();
|
|
22
31
|
};
|
|
23
32
|
process.on(signal, listener);
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/lifecycle",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Application and component lifecycle orchestration with state machine, dependency ordering, graceful shutdown, rollback, and signals.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Oluwayemi Oyinlola",
|
|
8
|
+
"url": "https://github.com/oyinlola-tech"
|
|
9
|
+
},
|
|
6
10
|
"type": "module",
|
|
7
11
|
"main": "./dist/index.js",
|
|
8
12
|
"module": "./dist/index.js",
|
|
@@ -20,8 +24,8 @@
|
|
|
20
24
|
"!dist/.tsbuildinfo"
|
|
21
25
|
],
|
|
22
26
|
"dependencies": {
|
|
23
|
-
"@zudojs/errors": "1.
|
|
24
|
-
"@zudojs/constants": "1.
|
|
27
|
+
"@zudojs/errors": "1.1.0",
|
|
28
|
+
"@zudojs/constants": "1.1.0"
|
|
25
29
|
},
|
|
26
30
|
"devDependencies": {
|
|
27
31
|
"@types/node": "^26.4.1",
|