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