@zudojs/runtime 1.0.0 → 1.2.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 +20 -0
- package/dist/lifecycle/lifecycle.cancellation.d.ts +41 -0
- package/dist/lifecycle/lifecycle.cancellation.js +92 -0
- package/dist/lifecycle/lifecycle.core.d.ts +18 -0
- package/dist/lifecycle/lifecycle.core.js +85 -12
- package/dist/lifecycle/lifecycle.type.d.ts +6 -0
- package/dist/readiness/readiness.core.js +15 -2
- package/dist/runtime/runtime.core.js +17 -0
- package/dist/runtimeOptions/runtimeOptions.core.d.ts +5 -0
- package/dist/runtimeOptions/runtimeOptions.core.js +10 -0
- package/dist/runtimeOptions/runtimeOptions.type.d.ts +25 -0
- package/dist/runtimeOptions/runtimeOptions.type.js +3 -0
- package/dist/shutdown/shutdown.core.js +22 -1
- package/dist/startup/startup.core.js +15 -3
- package/package.json +11 -8
package/README.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Application lifecycle orchestrator with dependency ordering, rollback, signals, and readiness checks.
|
|
4
4
|
|
|
5
|
+
<!-- zudo-docs:start -->
|
|
6
|
+
|
|
7
|
+
**Documentation:** [zudojs.oyinlola.site/docs/packages-runtime](https://zudojs.oyinlola.site/docs/packages-runtime) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-runtime.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
|
|
8
|
+
|
|
9
|
+
<!-- zudo-docs:end -->
|
|
10
|
+
|
|
5
11
|
## Installation
|
|
6
12
|
|
|
7
13
|
```bash
|
|
@@ -126,6 +132,9 @@ createRuntime(dependencies, {
|
|
|
126
132
|
runtimeId: "rt_custom", // generated when omitted
|
|
127
133
|
handleSignals: true, // SIGTERM/SIGINT trigger a graceful stop
|
|
128
134
|
handleFatalErrors: true, // uncaughtException/unhandledRejection
|
|
135
|
+
exitOnFatalError: true, // exit(1) once a fatal-error shutdown finishes
|
|
136
|
+
fatalExitTimeout: 10_000, // exit(1) anyway if that shutdown hangs
|
|
137
|
+
forceExitOnSecondSignal: true, // a second SIGTERM/SIGINT exits at once
|
|
129
138
|
startupTimeout: 60_000,
|
|
130
139
|
shutdownTimeout: 30_000,
|
|
131
140
|
emitEvents: true,
|
|
@@ -137,6 +146,17 @@ createRuntime(dependencies, {
|
|
|
137
146
|
});
|
|
138
147
|
```
|
|
139
148
|
|
|
149
|
+
`startupTimeout` abandons the startup: no further module hook starts,
|
|
150
|
+
and a module whose `onInitialize` or `onReady` finishes after the timeout
|
|
151
|
+
is shut down and destroyed as soon as it settles. `stop()` waits for that
|
|
152
|
+
teardown (bounded by `shutdownTimeout`), so a stopped runtime never has a
|
|
153
|
+
live module behind it. A second `stop()` after a shutdown timeout joins
|
|
154
|
+
the teardown still running rather than calling `onShutdown` again.
|
|
155
|
+
|
|
156
|
+
A configuration manager that fails to load fails startup with a
|
|
157
|
+
`RuntimeStartError` (`phase: "initialize"`) instead of letting modules
|
|
158
|
+
initialize against partial configuration.
|
|
159
|
+
|
|
140
160
|
`parallelInitialization` initializes modules that share a dependency depth
|
|
141
161
|
concurrently. They do not depend on one another by construction, but
|
|
142
162
|
enabling it surfaces any ordering a module assumed without declaring, so
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Logger } from "@zudojs/logger";
|
|
2
|
+
import type { Module, ModuleContext } from "@zudojs/core";
|
|
3
|
+
/**
|
|
4
|
+
* Tracks module hooks that are still running after their startup was
|
|
5
|
+
* abandoned (by a startup timeout or a rollback).
|
|
6
|
+
*
|
|
7
|
+
* `Promise.race` cannot cancel the losing side, so a slow `onInitialize`
|
|
8
|
+
* or `onReady` keeps running after the runtime has already reported a
|
|
9
|
+
* failed start. Without this bookkeeping the late module came up with no
|
|
10
|
+
* owner left to stop it: a timed-out boot reported `failed` and then
|
|
11
|
+
* `stopped` while the module's server was listening.
|
|
12
|
+
*/
|
|
13
|
+
export declare class LifecycleCancellation {
|
|
14
|
+
private cancelled;
|
|
15
|
+
private readonly running;
|
|
16
|
+
private readonly releases;
|
|
17
|
+
/** Whether the current startup has been abandoned. */
|
|
18
|
+
get isCancelled(): boolean;
|
|
19
|
+
/** Abandons the current startup. Idempotent. */
|
|
20
|
+
cancel(): void;
|
|
21
|
+
/** Clears the cancelled flag for a new startup. */
|
|
22
|
+
reset(): void;
|
|
23
|
+
/** Whether a hook for `moduleId` is currently running. */
|
|
24
|
+
isRunning(moduleId: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Records a running hook for `moduleId` until it settles.
|
|
27
|
+
*/
|
|
28
|
+
track<T>(moduleId: string, hook: Promise<T>): Promise<T>;
|
|
29
|
+
/**
|
|
30
|
+
* Tears down a module whose hook completed after the startup was
|
|
31
|
+
* abandoned: `onShutdown` when it had reached `onReady`, then
|
|
32
|
+
* `onDestroy`. The teardown is tracked so {@link settle} waits for it.
|
|
33
|
+
*/
|
|
34
|
+
release(module: Module, context: ModuleContext, started: boolean, logger: Logger): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Waits until every abandoned hook has settled and every late module
|
|
37
|
+
* has been torn down.
|
|
38
|
+
*/
|
|
39
|
+
settle(): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=lifecycle.cancellation.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks module hooks that are still running after their startup was
|
|
3
|
+
* abandoned (by a startup timeout or a rollback).
|
|
4
|
+
*
|
|
5
|
+
* `Promise.race` cannot cancel the losing side, so a slow `onInitialize`
|
|
6
|
+
* or `onReady` keeps running after the runtime has already reported a
|
|
7
|
+
* failed start. Without this bookkeeping the late module came up with no
|
|
8
|
+
* owner left to stop it: a timed-out boot reported `failed` and then
|
|
9
|
+
* `stopped` while the module's server was listening.
|
|
10
|
+
*/
|
|
11
|
+
export class LifecycleCancellation {
|
|
12
|
+
cancelled = false;
|
|
13
|
+
running = new Map();
|
|
14
|
+
releases = new Set();
|
|
15
|
+
/** Whether the current startup has been abandoned. */
|
|
16
|
+
get isCancelled() {
|
|
17
|
+
return this.cancelled;
|
|
18
|
+
}
|
|
19
|
+
/** Abandons the current startup. Idempotent. */
|
|
20
|
+
cancel() {
|
|
21
|
+
this.cancelled = true;
|
|
22
|
+
}
|
|
23
|
+
/** Clears the cancelled flag for a new startup. */
|
|
24
|
+
reset() {
|
|
25
|
+
this.cancelled = false;
|
|
26
|
+
}
|
|
27
|
+
/** Whether a hook for `moduleId` is currently running. */
|
|
28
|
+
isRunning(moduleId) {
|
|
29
|
+
return this.running.has(moduleId);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Records a running hook for `moduleId` until it settles.
|
|
33
|
+
*/
|
|
34
|
+
track(moduleId, hook) {
|
|
35
|
+
this.running.set(moduleId, hook);
|
|
36
|
+
const clear = () => {
|
|
37
|
+
if (this.running.get(moduleId) === hook) {
|
|
38
|
+
this.running.delete(moduleId);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
hook.then(clear, clear);
|
|
42
|
+
return hook;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Tears down a module whose hook completed after the startup was
|
|
46
|
+
* abandoned: `onShutdown` when it had reached `onReady`, then
|
|
47
|
+
* `onDestroy`. The teardown is tracked so {@link settle} waits for it.
|
|
48
|
+
*/
|
|
49
|
+
release(module, context, started, logger) {
|
|
50
|
+
const release = releaseAbandonedModule(module, context, started, logger);
|
|
51
|
+
this.releases.add(release);
|
|
52
|
+
void release.finally(() => this.releases.delete(release));
|
|
53
|
+
return release;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Waits until every abandoned hook has settled and every late module
|
|
57
|
+
* has been torn down.
|
|
58
|
+
*/
|
|
59
|
+
async settle() {
|
|
60
|
+
while (this.running.size > 0 || this.releases.size > 0) {
|
|
61
|
+
await Promise.allSettled([...this.running.values(), ...this.releases]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Runs `onShutdown` (when the module had started) and `onDestroy` for a
|
|
67
|
+
* module that finished a hook after its startup was abandoned.
|
|
68
|
+
*/
|
|
69
|
+
async function releaseAbandonedModule(module, context, started, logger) {
|
|
70
|
+
logger.warn(`Module "${module.id}" finished a lifecycle hook after startup was abandoned; tearing it down.`);
|
|
71
|
+
if (started && module.onShutdown) {
|
|
72
|
+
try {
|
|
73
|
+
await module.onShutdown(context);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
logger.error(`Abandoned module "${module.id}" failed to shut down.`, {
|
|
77
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (module.onDestroy) {
|
|
82
|
+
try {
|
|
83
|
+
await module.onDestroy(context);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
logger.error(`Abandoned module "${module.id}" failed to destroy.`, {
|
|
87
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=lifecycle.cancellation.js.map
|
|
@@ -11,10 +11,17 @@ export declare class LifecycleManager {
|
|
|
11
11
|
private readonly onModuleEvent;
|
|
12
12
|
private readonly runtimeId;
|
|
13
13
|
private initializedModules;
|
|
14
|
+
/**
|
|
15
|
+
* Modules whose `onInitialize` threw. They may have acquired resources
|
|
16
|
+
* before failing, so teardown still runs their `onDestroy`, matching
|
|
17
|
+
* `@zudojs/lifecycle` and `@zudojs/core`.
|
|
18
|
+
*/
|
|
19
|
+
private failedInitializations;
|
|
14
20
|
private startedModules;
|
|
15
21
|
private readonly configuration;
|
|
16
22
|
private readonly application;
|
|
17
23
|
private readonly contexts;
|
|
24
|
+
private readonly cancellation;
|
|
18
25
|
constructor(modules: ReadonlyMap<string, Module>, logger: Logger, options?: LifecycleManagerOptions, services?: ModuleContextServices);
|
|
19
26
|
/**
|
|
20
27
|
* Publishes a per-module lifecycle event.
|
|
@@ -28,6 +35,17 @@ export declare class LifecycleManager {
|
|
|
28
35
|
* so that `context.getConfiguration()` is usable from the first hook.
|
|
29
36
|
*/
|
|
30
37
|
private ensureConfigurationReady;
|
|
38
|
+
/**
|
|
39
|
+
* Abandons an in-flight startup.
|
|
40
|
+
*
|
|
41
|
+
* No further module hook is started, and any module whose hook is
|
|
42
|
+
* still running when it settles is shut down and destroyed rather than
|
|
43
|
+
* left running with no owner. Called on a startup timeout and by
|
|
44
|
+
* {@link rollback}.
|
|
45
|
+
*/
|
|
46
|
+
cancel(): void;
|
|
47
|
+
/** Whether the current startup has been abandoned by {@link cancel}. */
|
|
48
|
+
get cancelled(): boolean;
|
|
31
49
|
/**
|
|
32
50
|
* Initializes all modules in dependency order.
|
|
33
51
|
*/
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createConfigurationManager } from "@zudojs/core";
|
|
2
2
|
import { createModuleEventPayload } from "../runtimeEvents/runtimeEvents.core.js";
|
|
3
3
|
import { resolveDependencies } from "../dependencyGraph/index.js";
|
|
4
|
-
import {
|
|
4
|
+
import { LifecycleCancellation } from "./lifecycle.cancellation.js";
|
|
5
|
+
import { RuntimeDependencyError, RuntimeStartError, RuntimeStateError, } from "../runtimeError/index.js";
|
|
5
6
|
/**
|
|
6
7
|
* Manages the lifecycle of runtime modules.
|
|
7
8
|
*/
|
|
@@ -12,10 +13,17 @@ export class LifecycleManager {
|
|
|
12
13
|
onModuleEvent;
|
|
13
14
|
runtimeId;
|
|
14
15
|
initializedModules = [];
|
|
16
|
+
/**
|
|
17
|
+
* Modules whose `onInitialize` threw. They may have acquired resources
|
|
18
|
+
* before failing, so teardown still runs their `onDestroy`, matching
|
|
19
|
+
* `@zudojs/lifecycle` and `@zudojs/core`.
|
|
20
|
+
*/
|
|
21
|
+
failedInitializations = [];
|
|
15
22
|
startedModules = [];
|
|
16
23
|
configuration;
|
|
17
24
|
application;
|
|
18
25
|
contexts = new Map();
|
|
26
|
+
cancellation = new LifecycleCancellation();
|
|
19
27
|
constructor(modules, logger, options = {}, services = {}) {
|
|
20
28
|
this.modules = modules;
|
|
21
29
|
this.logger = logger;
|
|
@@ -24,7 +32,6 @@ export class LifecycleManager {
|
|
|
24
32
|
this.onModuleEvent = options.onModuleEvent;
|
|
25
33
|
this.runtimeId = options.runtimeId ?? "";
|
|
26
34
|
this.options = {
|
|
27
|
-
shutdownTimeout: options.shutdownTimeout ?? 30_000,
|
|
28
35
|
continueOnFailure: options.continueOnFailure ?? false,
|
|
29
36
|
// Defaults to sequential: initializing a whole depth group at once
|
|
30
37
|
// is a real behaviour change for modules that assume ordering
|
|
@@ -62,15 +69,33 @@ export class LifecycleManager {
|
|
|
62
69
|
if (this.configuration.isReady()) {
|
|
63
70
|
return;
|
|
64
71
|
}
|
|
72
|
+
// Fail closed: a configuration that did not load must not let
|
|
73
|
+
// modules initialize against partial or unvalidated state.
|
|
65
74
|
try {
|
|
66
75
|
await this.configuration.initialize();
|
|
67
76
|
}
|
|
68
77
|
catch (error) {
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
throw new RuntimeStartError("Configuration failed to load.", {
|
|
79
|
+
phase: "initialize",
|
|
80
|
+
...(error instanceof Error && { cause: error }),
|
|
71
81
|
});
|
|
72
82
|
}
|
|
73
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Abandons an in-flight startup.
|
|
86
|
+
*
|
|
87
|
+
* No further module hook is started, and any module whose hook is
|
|
88
|
+
* still running when it settles is shut down and destroyed rather than
|
|
89
|
+
* left running with no owner. Called on a startup timeout and by
|
|
90
|
+
* {@link rollback}.
|
|
91
|
+
*/
|
|
92
|
+
cancel() {
|
|
93
|
+
this.cancellation.cancel();
|
|
94
|
+
}
|
|
95
|
+
/** Whether the current startup has been abandoned by {@link cancel}. */
|
|
96
|
+
get cancelled() {
|
|
97
|
+
return this.cancellation.isCancelled;
|
|
98
|
+
}
|
|
74
99
|
/**
|
|
75
100
|
* Initializes all modules in dependency order.
|
|
76
101
|
*/
|
|
@@ -79,7 +104,9 @@ export class LifecycleManager {
|
|
|
79
104
|
// Reset the per-run bookkeeping. Appending across runs would make a
|
|
80
105
|
// stop-then-start cycle initialize and stop every module twice.
|
|
81
106
|
this.initializedModules = [];
|
|
107
|
+
this.failedInitializations = [];
|
|
82
108
|
this.startedModules = [];
|
|
109
|
+
this.cancellation.reset();
|
|
83
110
|
await this.ensureConfigurationReady();
|
|
84
111
|
const succeeded = [];
|
|
85
112
|
const failed = [];
|
|
@@ -90,14 +117,16 @@ export class LifecycleManager {
|
|
|
90
117
|
? depGraph.parallelGroups
|
|
91
118
|
: depGraph.order.map((moduleId) => [moduleId]);
|
|
92
119
|
for (const group of groups) {
|
|
120
|
+
if (this.cancellation.isCancelled) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
93
123
|
const results = await Promise.all(group.map((moduleId) => this.initializeModule(moduleId)));
|
|
94
124
|
for (const result of results) {
|
|
95
125
|
if (result.failure) {
|
|
96
126
|
failed.push(result.failure);
|
|
97
127
|
}
|
|
98
|
-
else {
|
|
128
|
+
else if (!result.abandoned) {
|
|
99
129
|
succeeded.push(result.moduleId);
|
|
100
|
-
this.initializedModules.push(result.moduleId);
|
|
101
130
|
}
|
|
102
131
|
}
|
|
103
132
|
if (failed.length > 0 && !this.options.continueOnFailure) {
|
|
@@ -131,8 +160,16 @@ export class LifecycleManager {
|
|
|
131
160
|
this.emitModuleEvent("runtime.module.initializing", moduleId, "initializing");
|
|
132
161
|
try {
|
|
133
162
|
if (module.onInitialize) {
|
|
134
|
-
await module.onInitialize(this.createModuleContext(module));
|
|
163
|
+
await this.cancellation.track(moduleId, Promise.resolve(module.onInitialize(this.createModuleContext(module))));
|
|
135
164
|
}
|
|
165
|
+
if (this.cancellation.isCancelled) {
|
|
166
|
+
await this.cancellation.release(module, this.createModuleContext(module), false, this.logger);
|
|
167
|
+
return { moduleId, abandoned: true };
|
|
168
|
+
}
|
|
169
|
+
// Recorded as soon as it succeeds, not after its whole depth group:
|
|
170
|
+
// a rollback that runs while a sibling is still initializing must
|
|
171
|
+
// still reach this module.
|
|
172
|
+
this.initializedModules.push(moduleId);
|
|
136
173
|
const durationMs = Date.now() - startedAt;
|
|
137
174
|
this.logger.debug(`Module "${moduleId}" initialized.`, { durationMs });
|
|
138
175
|
this.emitModuleEvent("runtime.module.initialized", moduleId, "initialized", {
|
|
@@ -150,6 +187,12 @@ export class LifecycleManager {
|
|
|
150
187
|
this.logger.error(`Module "${moduleId}" failed during initialization.`, {
|
|
151
188
|
error: failure.error,
|
|
152
189
|
});
|
|
190
|
+
if (this.cancellation.isCancelled) {
|
|
191
|
+
await this.cancellation.release(module, this.createModuleContext(module), false, this.logger);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
this.failedInitializations.push(moduleId);
|
|
195
|
+
}
|
|
153
196
|
this.emitModuleEvent("runtime.module.failed", moduleId, "failed", {
|
|
154
197
|
durationMs: failure.durationMs,
|
|
155
198
|
error: failure.error,
|
|
@@ -165,8 +208,10 @@ export class LifecycleManager {
|
|
|
165
208
|
this.startedModules = [];
|
|
166
209
|
const succeeded = [];
|
|
167
210
|
const failed = [];
|
|
168
|
-
for (const moduleId of this.initializedModules) {
|
|
211
|
+
for (const moduleId of [...this.initializedModules]) {
|
|
169
212
|
const module = this.modules.get(moduleId);
|
|
213
|
+
if (this.cancellation.isCancelled)
|
|
214
|
+
break;
|
|
170
215
|
if (!module)
|
|
171
216
|
continue;
|
|
172
217
|
const moduleStartTime = Date.now();
|
|
@@ -174,7 +219,18 @@ export class LifecycleManager {
|
|
|
174
219
|
try {
|
|
175
220
|
if (module.onReady) {
|
|
176
221
|
const context = this.createModuleContext(module);
|
|
177
|
-
await
|
|
222
|
+
await this.cancellation
|
|
223
|
+
.track(moduleId, Promise.resolve(module.onReady(context)))
|
|
224
|
+
.catch(async (error) => {
|
|
225
|
+
if (this.cancellation.isCancelled) {
|
|
226
|
+
await this.cancellation.release(module, context, false, this.logger);
|
|
227
|
+
}
|
|
228
|
+
throw error;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (this.cancellation.isCancelled) {
|
|
232
|
+
await this.cancellation.release(module, this.createModuleContext(module), true, this.logger);
|
|
233
|
+
break;
|
|
178
234
|
}
|
|
179
235
|
succeeded.push(moduleId);
|
|
180
236
|
this.startedModules.push(moduleId);
|
|
@@ -218,11 +274,15 @@ export class LifecycleManager {
|
|
|
218
274
|
const startTime = Date.now();
|
|
219
275
|
const succeeded = [];
|
|
220
276
|
const failed = [];
|
|
277
|
+
await this.cancellation.settle();
|
|
221
278
|
const reversedModules = [...this.startedModules].reverse();
|
|
222
279
|
for (const moduleId of reversedModules) {
|
|
223
280
|
const module = this.modules.get(moduleId);
|
|
224
281
|
if (!module)
|
|
225
282
|
continue;
|
|
283
|
+
// Removed before its hook runs, so a second stop() (for example
|
|
284
|
+
// after a shutdown timeout) never calls onShutdown on it again.
|
|
285
|
+
this.startedModules = this.startedModules.filter((id) => id !== moduleId);
|
|
226
286
|
const moduleStartTime = Date.now();
|
|
227
287
|
this.emitModuleEvent("runtime.module.stopping", moduleId, "stopping");
|
|
228
288
|
try {
|
|
@@ -268,7 +328,12 @@ export class LifecycleManager {
|
|
|
268
328
|
const startTime = Date.now();
|
|
269
329
|
const succeeded = [];
|
|
270
330
|
const failed = [];
|
|
271
|
-
|
|
331
|
+
await this.cancellation.settle();
|
|
332
|
+
const reversedModules = [
|
|
333
|
+
...this.startedModules,
|
|
334
|
+
...this.initializedModules,
|
|
335
|
+
...this.failedInitializations,
|
|
336
|
+
]
|
|
272
337
|
.filter((id, index, arr) => arr.indexOf(id) === index)
|
|
273
338
|
.reverse();
|
|
274
339
|
for (const moduleId of reversedModules) {
|
|
@@ -303,6 +368,7 @@ export class LifecycleManager {
|
|
|
303
368
|
// rebuilds the lists from scratch.
|
|
304
369
|
this.startedModules = [];
|
|
305
370
|
this.initializedModules = [];
|
|
371
|
+
this.failedInitializations = [];
|
|
306
372
|
this.contexts.clear();
|
|
307
373
|
return Object.freeze({
|
|
308
374
|
phase: "destroy",
|
|
@@ -322,6 +388,7 @@ export class LifecycleManager {
|
|
|
322
388
|
*/
|
|
323
389
|
async rollback() {
|
|
324
390
|
const failures = [];
|
|
391
|
+
this.cancellation.cancel();
|
|
325
392
|
for (const moduleId of [...this.startedModules].reverse()) {
|
|
326
393
|
const module = this.modules.get(moduleId);
|
|
327
394
|
if (!module?.onShutdown)
|
|
@@ -343,9 +410,14 @@ export class LifecycleManager {
|
|
|
343
410
|
});
|
|
344
411
|
}
|
|
345
412
|
}
|
|
346
|
-
for (const moduleId of [
|
|
413
|
+
for (const moduleId of [
|
|
414
|
+
...this.initializedModules,
|
|
415
|
+
...this.failedInitializations,
|
|
416
|
+
].reverse()) {
|
|
347
417
|
const module = this.modules.get(moduleId);
|
|
348
|
-
|
|
418
|
+
// A module whose hook is still running is torn down by the
|
|
419
|
+
// cancellation once that hook settles, never concurrently with it.
|
|
420
|
+
if (!module?.onDestroy || this.cancellation.isRunning(moduleId))
|
|
349
421
|
continue;
|
|
350
422
|
const startedAt = Date.now();
|
|
351
423
|
try {
|
|
@@ -368,6 +440,7 @@ export class LifecycleManager {
|
|
|
368
440
|
// later shutdown does not stop modules a second time.
|
|
369
441
|
this.startedModules = [];
|
|
370
442
|
this.initializedModules = [];
|
|
443
|
+
this.failedInitializations = [];
|
|
371
444
|
this.contexts.clear();
|
|
372
445
|
return failures;
|
|
373
446
|
}
|
|
@@ -72,6 +72,12 @@ export type ModuleEventListener = (type: RuntimeModuleEventType, payload: Runtim
|
|
|
72
72
|
* Options for lifecycle management.
|
|
73
73
|
*/
|
|
74
74
|
export interface LifecycleManagerOptions {
|
|
75
|
+
/**
|
|
76
|
+
* @deprecated Has no effect. Shutdown is bounded as a whole by
|
|
77
|
+
* `RuntimeOptions.shutdownTimeout`; bounding each module's hook here
|
|
78
|
+
* would let `onDestroy` run while a timed-out `onShutdown` is still
|
|
79
|
+
* closing the same resources.
|
|
80
|
+
*/
|
|
75
81
|
readonly shutdownTimeout?: number;
|
|
76
82
|
readonly continueOnFailure?: boolean;
|
|
77
83
|
/**
|
|
@@ -53,10 +53,23 @@ export class ReadinessTracker {
|
|
|
53
53
|
removeCheck(name) {
|
|
54
54
|
const existed = this.checks.delete(name);
|
|
55
55
|
this.checkFns.delete(name);
|
|
56
|
-
if (existed) {
|
|
56
|
+
if (!existed) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
if (this.autoMarkReady &&
|
|
60
|
+
this.checks.size === 0 &&
|
|
61
|
+
this.state === "degraded") {
|
|
62
|
+
// `degraded` is only ever entered from `ready` because a check
|
|
63
|
+
// failed. Removing the last check leaves nothing failing, so the
|
|
64
|
+
// tracker returns to ready — otherwise `evaluateReadiness()` (which
|
|
65
|
+
// only acts when checks exist) left it stuck at `ready: false` while
|
|
66
|
+
// the derived health, seeing no checks, reported `healthy`.
|
|
67
|
+
this.markReady("All readiness checks removed.");
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
57
70
|
this.evaluateReadiness();
|
|
58
71
|
}
|
|
59
|
-
return
|
|
72
|
+
return true;
|
|
60
73
|
}
|
|
61
74
|
/**
|
|
62
75
|
* Evaluates a readiness check and records its result.
|
|
@@ -65,6 +65,9 @@ export class DefaultRuntime {
|
|
|
65
65
|
this.signalHandler = new SignalHandler(this.logger, {
|
|
66
66
|
handleSignals: this.options.handleSignals,
|
|
67
67
|
handleFatalErrors: this.options.handleFatalErrors,
|
|
68
|
+
forceExitOnSecondSignal: this.options.forceExitOnSecondSignal,
|
|
69
|
+
exitOnFatalError: this.options.exitOnFatalError,
|
|
70
|
+
fatalExitTimeout: this.options.fatalExitTimeout,
|
|
68
71
|
});
|
|
69
72
|
this.readinessTracker = new ReadinessTracker({
|
|
70
73
|
autoMarkReady: this.options.trackReadiness,
|
|
@@ -206,6 +209,17 @@ export class DefaultRuntime {
|
|
|
206
209
|
if (this.stopPromise) {
|
|
207
210
|
return this.stopPromise;
|
|
208
211
|
}
|
|
212
|
+
if (this.startPromise) {
|
|
213
|
+
// A stop requested while startup is in flight — typically a SIGTERM
|
|
214
|
+
// arriving while modules are still coming up — waits for startup to
|
|
215
|
+
// settle and then shuts down whatever it produced. Previously this
|
|
216
|
+
// threw `RuntimeStateError` ("cannot stop a runtime in state
|
|
217
|
+
// initializing"), so the signal handler logged "Shutdown failed" and
|
|
218
|
+
// the runtime carried on to `running` as if nothing had happened.
|
|
219
|
+
// Startup is bounded by `startupTimeout`, so this wait is too.
|
|
220
|
+
await this.startPromise.catch(() => undefined);
|
|
221
|
+
return this.stop();
|
|
222
|
+
}
|
|
209
223
|
if (this._state === "created") {
|
|
210
224
|
this._state = "stopped";
|
|
211
225
|
this._stoppedAt = new Date();
|
|
@@ -287,6 +301,9 @@ export class DefaultRuntime {
|
|
|
287
301
|
});
|
|
288
302
|
}
|
|
289
303
|
this.transitionTo("failed");
|
|
304
|
+
// A rolled-back runtime owns nothing, so it must not keep owning the
|
|
305
|
+
// process's signals and fatal-error handlers either.
|
|
306
|
+
this.signalHandler.unregister();
|
|
290
307
|
throw runtimeError;
|
|
291
308
|
}
|
|
292
309
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { ResolvedRuntimeOptions } from "./runtimeOptions.type.js";
|
|
2
2
|
/**
|
|
3
3
|
* Resolves runtime options with defaults applied.
|
|
4
|
+
*
|
|
5
|
+
* `runtimeId` is documented as "auto-generated if not provided", but until
|
|
6
|
+
* this generated one nothing did: `DEFAULT_RUNTIME_OPTIONS` has no entry
|
|
7
|
+
* for it, so an omitted id reached every event payload, log line and
|
|
8
|
+
* `runtime.context.runtimeId` as `undefined`.
|
|
4
9
|
*/
|
|
5
10
|
export declare function resolveRuntimeOptions(options: ResolvedRuntimeOptions): ResolvedRuntimeOptions;
|
|
6
11
|
/**
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { DEFAULT_RUNTIME_OPTIONS } from "./runtimeOptions.type.js";
|
|
2
|
+
import { createRuntimeId } from "../runtimeContext/runtimeContext.factory.js";
|
|
2
3
|
/**
|
|
3
4
|
* Resolves runtime options with defaults applied.
|
|
5
|
+
*
|
|
6
|
+
* `runtimeId` is documented as "auto-generated if not provided", but until
|
|
7
|
+
* this generated one nothing did: `DEFAULT_RUNTIME_OPTIONS` has no entry
|
|
8
|
+
* for it, so an omitted id reached every event payload, log line and
|
|
9
|
+
* `runtime.context.runtimeId` as `undefined`.
|
|
4
10
|
*/
|
|
5
11
|
export function resolveRuntimeOptions(options) {
|
|
6
12
|
// Spreading `options` wholesale lets an explicitly-undefined key erase
|
|
@@ -9,6 +15,7 @@ export function resolveRuntimeOptions(options) {
|
|
|
9
15
|
const provided = Object.fromEntries(Object.entries(options ?? {}).filter(([, value]) => value !== undefined));
|
|
10
16
|
return Object.freeze({
|
|
11
17
|
...DEFAULT_RUNTIME_OPTIONS,
|
|
18
|
+
runtimeId: createRuntimeId(),
|
|
12
19
|
...provided,
|
|
13
20
|
});
|
|
14
21
|
}
|
|
@@ -28,6 +35,9 @@ export function validateRuntimeOptions(options) {
|
|
|
28
35
|
if (options.startupTimeout <= 0) {
|
|
29
36
|
throw new Error("Startup timeout must be positive.");
|
|
30
37
|
}
|
|
38
|
+
if (!Number.isFinite(options.fatalExitTimeout) || options.fatalExitTimeout < 0) {
|
|
39
|
+
throw new Error(`Fatal exit timeout must be a finite, non-negative number, got ${options.fatalExitTimeout}.`);
|
|
40
|
+
}
|
|
31
41
|
if (options.readinessCheckTimeout < 0) {
|
|
32
42
|
throw new Error(`Readiness check timeout must be zero or positive, got ${options.readinessCheckTimeout}. Use 0 to run checks without a bound.`);
|
|
33
43
|
}
|
|
@@ -31,6 +31,25 @@ export interface RuntimeOptions {
|
|
|
31
31
|
* @default true
|
|
32
32
|
*/
|
|
33
33
|
readonly handleFatalErrors?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Whether a second SIGTERM/SIGINT during shutdown exits immediately
|
|
36
|
+
* with code 1.
|
|
37
|
+
* @default true
|
|
38
|
+
*/
|
|
39
|
+
readonly forceExitOnSecondSignal?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Whether an uncaught exception or unhandled rejection exits the
|
|
42
|
+
* process with code 1 once shutdown finishes. Set to `false` to stop
|
|
43
|
+
* the runtime but leave the process running.
|
|
44
|
+
* @default true
|
|
45
|
+
*/
|
|
46
|
+
readonly exitOnFatalError?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* How long a fatal-error shutdown may take before the process exits
|
|
49
|
+
* anyway, in milliseconds.
|
|
50
|
+
* @default 10000
|
|
51
|
+
*/
|
|
52
|
+
readonly fatalExitTimeout?: number;
|
|
34
53
|
/**
|
|
35
54
|
* Graceful shutdown timeout in milliseconds.
|
|
36
55
|
* @default 30000
|
|
@@ -94,6 +113,9 @@ export interface ResolvedRuntimeOptions {
|
|
|
94
113
|
readonly applicationVersion: string;
|
|
95
114
|
readonly handleSignals: boolean;
|
|
96
115
|
readonly handleFatalErrors: boolean;
|
|
116
|
+
readonly forceExitOnSecondSignal: boolean;
|
|
117
|
+
readonly exitOnFatalError: boolean;
|
|
118
|
+
readonly fatalExitTimeout: number;
|
|
97
119
|
readonly shutdownTimeout: number;
|
|
98
120
|
readonly startupTimeout: number;
|
|
99
121
|
readonly emitEvents: boolean;
|
|
@@ -109,6 +131,9 @@ export interface ResolvedRuntimeOptions {
|
|
|
109
131
|
export declare const DEFAULT_RUNTIME_OPTIONS: Readonly<{
|
|
110
132
|
readonly handleSignals: true;
|
|
111
133
|
readonly handleFatalErrors: true;
|
|
134
|
+
readonly forceExitOnSecondSignal: true;
|
|
135
|
+
readonly exitOnFatalError: true;
|
|
136
|
+
readonly fatalExitTimeout: 10000;
|
|
112
137
|
readonly shutdownTimeout: 30000;
|
|
113
138
|
readonly startupTimeout: 60000;
|
|
114
139
|
readonly emitEvents: true;
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
export const DEFAULT_RUNTIME_OPTIONS = Object.freeze({
|
|
5
5
|
handleSignals: true,
|
|
6
6
|
handleFatalErrors: true,
|
|
7
|
+
forceExitOnSecondSignal: true,
|
|
8
|
+
exitOnFatalError: true,
|
|
9
|
+
fatalExitTimeout: 10_000,
|
|
7
10
|
shutdownTimeout: 30_000,
|
|
8
11
|
startupTimeout: 60_000,
|
|
9
12
|
emitEvents: true,
|
|
@@ -4,6 +4,27 @@ import { LifecycleManager } from "../lifecycle/index.js";
|
|
|
4
4
|
import { RuntimeStopError, RuntimeTimeoutError, } from "../runtimeError/index.js";
|
|
5
5
|
/** Largest delay a timer can represent. */
|
|
6
6
|
const MAX_TIMER_DELAY = 2_147_483_647;
|
|
7
|
+
/**
|
|
8
|
+
* Teardown still running for a lifecycle, including one abandoned by a
|
|
9
|
+
* shutdown timeout. A later stop() joins it instead of calling every
|
|
10
|
+
* module's `onShutdown` a second time while the first is still closing.
|
|
11
|
+
*/
|
|
12
|
+
const inProgress = new WeakMap();
|
|
13
|
+
/**
|
|
14
|
+
* Starts the teardown for `lifecycle`, or joins the one already running.
|
|
15
|
+
*/
|
|
16
|
+
function shutdownOnce(lifecycle, logger) {
|
|
17
|
+
const existing = inProgress.get(lifecycle);
|
|
18
|
+
if (existing !== undefined) {
|
|
19
|
+
logger.warn("A previous shutdown is still running; waiting for it.");
|
|
20
|
+
return existing;
|
|
21
|
+
}
|
|
22
|
+
const teardown = performShutdown(lifecycle, logger).finally(() => {
|
|
23
|
+
inProgress.delete(lifecycle);
|
|
24
|
+
});
|
|
25
|
+
inProgress.set(lifecycle, teardown);
|
|
26
|
+
return teardown;
|
|
27
|
+
}
|
|
7
28
|
/**
|
|
8
29
|
* Executes the shutdown sequence with timeout.
|
|
9
30
|
*
|
|
@@ -24,7 +45,7 @@ export async function executeShutdown(lifecycle, runtimeId, eventBus, logger, sh
|
|
|
24
45
|
}));
|
|
25
46
|
}
|
|
26
47
|
logger.info("Initiating graceful shutdown.", { timeoutMs: shutdownTimeout });
|
|
27
|
-
const stopPromise =
|
|
48
|
+
const stopPromise = shutdownOnce(lifecycle, logger);
|
|
28
49
|
// The shutdown promise keeps running if the timeout wins; attach a
|
|
29
50
|
// handler now so its eventual rejection is never unhandled.
|
|
30
51
|
stopPromise.catch(() => { });
|
|
@@ -11,7 +11,7 @@ const MAX_TIMER_DELAY = 2_147_483_647;
|
|
|
11
11
|
* a module whose `onInitialize` never settles hung the boot forever with
|
|
12
12
|
* no diagnostic. The timer is cleared whichever side wins.
|
|
13
13
|
*/
|
|
14
|
-
async function withStartupTimeout(operation, timeoutMs) {
|
|
14
|
+
async function withStartupTimeout(operation, timeoutMs, onTimeout) {
|
|
15
15
|
if (timeoutMs <= 0) {
|
|
16
16
|
return operation;
|
|
17
17
|
}
|
|
@@ -20,7 +20,13 @@ async function withStartupTimeout(operation, timeoutMs) {
|
|
|
20
20
|
operation.catch(() => { });
|
|
21
21
|
let timer;
|
|
22
22
|
const timeout = new Promise((_, reject) => {
|
|
23
|
-
timer = setTimeout(() =>
|
|
23
|
+
timer = setTimeout(() => {
|
|
24
|
+
// Abandon the startup before reporting the timeout, so no further
|
|
25
|
+
// module hook starts and a hook still running tears its module
|
|
26
|
+
// down when it settles instead of leaving it live.
|
|
27
|
+
onTimeout();
|
|
28
|
+
reject(new RuntimeTimeoutError("startup", timeoutMs));
|
|
29
|
+
}, Math.min(timeoutMs, MAX_TIMER_DELAY));
|
|
24
30
|
timer.unref?.();
|
|
25
31
|
});
|
|
26
32
|
try {
|
|
@@ -36,7 +42,7 @@ async function withStartupTimeout(operation, timeoutMs) {
|
|
|
36
42
|
* Executes the startup sequence.
|
|
37
43
|
*/
|
|
38
44
|
export async function executeStartup(lifecycle, runtimeId, eventBus, logger, emitEvents, startupTimeout = 0) {
|
|
39
|
-
return withStartupTimeout(runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents), startupTimeout);
|
|
45
|
+
return withStartupTimeout(runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents), startupTimeout, () => lifecycle.cancel());
|
|
40
46
|
}
|
|
41
47
|
/**
|
|
42
48
|
* Runs the startup sequence.
|
|
@@ -45,6 +51,9 @@ async function runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents) {
|
|
|
45
51
|
// Per-module `runtime.module.*` events are emitted by the lifecycle
|
|
46
52
|
// manager, which is the only layer that knows which module is running.
|
|
47
53
|
const initResult = await lifecycle.initialize();
|
|
54
|
+
if (lifecycle.cancelled) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
48
57
|
if (initResult.failed.length > 0) {
|
|
49
58
|
const failure = initResult.failed[0];
|
|
50
59
|
if (emitEvents && eventBus) {
|
|
@@ -64,6 +73,9 @@ async function runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents) {
|
|
|
64
73
|
durationMs: initResult.durationMs,
|
|
65
74
|
});
|
|
66
75
|
const startResult = await lifecycle.start();
|
|
76
|
+
if (lifecycle.cancelled) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
67
79
|
if (startResult.failed.length > 0) {
|
|
68
80
|
const failure = startResult.failed[0];
|
|
69
81
|
if (emitEvents && eventBus) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Application lifecycle orchestrator with dependency ordering, rollback, signals, and readiness checks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,15 +27,18 @@
|
|
|
27
27
|
"vitest": "^4.1.11"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@zudojs/
|
|
31
|
-
"@zudojs/
|
|
32
|
-
"@zudojs/
|
|
33
|
-
"@zudojs/
|
|
34
|
-
"@zudojs/
|
|
35
|
-
"@zudojs/
|
|
36
|
-
"@zudojs/core": "1.0.0"
|
|
30
|
+
"@zudojs/constants": "1.1.0",
|
|
31
|
+
"@zudojs/container": "1.1.1",
|
|
32
|
+
"@zudojs/core": "1.2.0",
|
|
33
|
+
"@zudojs/errors": "1.1.0",
|
|
34
|
+
"@zudojs/events": "1.1.0",
|
|
35
|
+
"@zudojs/logger": "1.2.0"
|
|
37
36
|
},
|
|
38
37
|
"license": "MIT",
|
|
38
|
+
"author": {
|
|
39
|
+
"name": "Oluwayemi Oyinlola",
|
|
40
|
+
"url": "https://github.com/oyinlola-tech"
|
|
41
|
+
},
|
|
39
42
|
"publishConfig": {
|
|
40
43
|
"access": "public"
|
|
41
44
|
},
|