@zudojs/runtime 1.1.0 → 1.2.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 +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 +27 -0
- package/dist/lifecycle/lifecycle.core.js +128 -13
- package/dist/lifecycle/lifecycle.type.d.ts +14 -0
- package/dist/registry/registry.core.js +3 -2
- package/dist/runtime/runtime.core.js +6 -0
- package/dist/runtimeOptions/runtimeOptions.core.js +27 -5
- 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 +7 -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,10 +35,30 @@ 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
|
*/
|
|
34
52
|
initialize(): Promise<LifecycleResult>;
|
|
53
|
+
/**
|
|
54
|
+
* Returns the first declared dependency of `moduleId` that is known to
|
|
55
|
+
* have failed or been skipped, or `undefined` when none has.
|
|
56
|
+
*
|
|
57
|
+
* Only direct dependencies are inspected: `blocked` already contains
|
|
58
|
+
* every module skipped by an earlier group, and groups are visited in
|
|
59
|
+
* dependency order, so the cascade is transitive.
|
|
60
|
+
*/
|
|
61
|
+
private findBlockingDependency;
|
|
35
62
|
/**
|
|
36
63
|
* Initializes a single module, converting a throw into a failure.
|
|
37
64
|
*/
|
|
@@ -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 = [];
|
|
@@ -89,15 +116,47 @@ export class LifecycleManager {
|
|
|
89
116
|
const groups = this.options.parallelInitialization
|
|
90
117
|
? depGraph.parallelGroups
|
|
91
118
|
: depGraph.order.map((moduleId) => [moduleId]);
|
|
119
|
+
// Ids that must not have dependents initialized on top of them:
|
|
120
|
+
// modules whose own hook threw, plus modules already skipped for the
|
|
121
|
+
// same reason, so the skip cascades transitively. `continueOnFailure`
|
|
122
|
+
// used to consult only the failure COUNT, so a dependent of a broken
|
|
123
|
+
// module was initialized, readied, and reported as started — an API
|
|
124
|
+
// serving traffic against a database that never came up.
|
|
125
|
+
const blocked = new Set();
|
|
92
126
|
for (const group of groups) {
|
|
93
|
-
|
|
127
|
+
if (this.cancellation.isCancelled) {
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
const runnable = [];
|
|
131
|
+
for (const moduleId of group) {
|
|
132
|
+
const blocker = this.findBlockingDependency(moduleId, blocked);
|
|
133
|
+
if (blocker === undefined) {
|
|
134
|
+
runnable.push(moduleId);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const failure = {
|
|
138
|
+
moduleId,
|
|
139
|
+
phase: "initialize",
|
|
140
|
+
error: new RuntimeStartError(`Module "${moduleId}" was not initialized because its ` +
|
|
141
|
+
`dependency "${blocker}" failed.`, { phase: "initialize", failedModuleId: blocker }),
|
|
142
|
+
durationMs: 0,
|
|
143
|
+
};
|
|
144
|
+
failed.push(failure);
|
|
145
|
+
blocked.add(moduleId);
|
|
146
|
+
this.logger.error(`Module "${moduleId}" was skipped because its dependency "${blocker}" failed.`);
|
|
147
|
+
this.emitModuleEvent("runtime.module.failed", moduleId, "failed", {
|
|
148
|
+
durationMs: 0,
|
|
149
|
+
error: failure.error,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const results = await Promise.all(runnable.map((moduleId) => this.initializeModule(moduleId)));
|
|
94
153
|
for (const result of results) {
|
|
95
154
|
if (result.failure) {
|
|
96
155
|
failed.push(result.failure);
|
|
156
|
+
blocked.add(result.moduleId);
|
|
97
157
|
}
|
|
98
|
-
else {
|
|
158
|
+
else if (!result.abandoned) {
|
|
99
159
|
succeeded.push(result.moduleId);
|
|
100
|
-
this.initializedModules.push(result.moduleId);
|
|
101
160
|
}
|
|
102
161
|
}
|
|
103
162
|
if (failed.length > 0 && !this.options.continueOnFailure) {
|
|
@@ -111,6 +170,18 @@ export class LifecycleManager {
|
|
|
111
170
|
durationMs: Date.now() - startTime,
|
|
112
171
|
});
|
|
113
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Returns the first declared dependency of `moduleId` that is known to
|
|
175
|
+
* have failed or been skipped, or `undefined` when none has.
|
|
176
|
+
*
|
|
177
|
+
* Only direct dependencies are inspected: `blocked` already contains
|
|
178
|
+
* every module skipped by an earlier group, and groups are visited in
|
|
179
|
+
* dependency order, so the cascade is transitive.
|
|
180
|
+
*/
|
|
181
|
+
findBlockingDependency(moduleId, blocked) {
|
|
182
|
+
const dependencies = this.modules.get(moduleId)?.dependencies ?? [];
|
|
183
|
+
return dependencies.find((dependency) => blocked.has(dependency));
|
|
184
|
+
}
|
|
114
185
|
/**
|
|
115
186
|
* Initializes a single module, converting a throw into a failure.
|
|
116
187
|
*/
|
|
@@ -131,8 +202,16 @@ export class LifecycleManager {
|
|
|
131
202
|
this.emitModuleEvent("runtime.module.initializing", moduleId, "initializing");
|
|
132
203
|
try {
|
|
133
204
|
if (module.onInitialize) {
|
|
134
|
-
await module.onInitialize(this.createModuleContext(module));
|
|
205
|
+
await this.cancellation.track(moduleId, Promise.resolve(module.onInitialize(this.createModuleContext(module))));
|
|
135
206
|
}
|
|
207
|
+
if (this.cancellation.isCancelled) {
|
|
208
|
+
await this.cancellation.release(module, this.createModuleContext(module), false, this.logger);
|
|
209
|
+
return { moduleId, abandoned: true };
|
|
210
|
+
}
|
|
211
|
+
// Recorded as soon as it succeeds, not after its whole depth group:
|
|
212
|
+
// a rollback that runs while a sibling is still initializing must
|
|
213
|
+
// still reach this module.
|
|
214
|
+
this.initializedModules.push(moduleId);
|
|
136
215
|
const durationMs = Date.now() - startedAt;
|
|
137
216
|
this.logger.debug(`Module "${moduleId}" initialized.`, { durationMs });
|
|
138
217
|
this.emitModuleEvent("runtime.module.initialized", moduleId, "initialized", {
|
|
@@ -150,6 +229,12 @@ export class LifecycleManager {
|
|
|
150
229
|
this.logger.error(`Module "${moduleId}" failed during initialization.`, {
|
|
151
230
|
error: failure.error,
|
|
152
231
|
});
|
|
232
|
+
if (this.cancellation.isCancelled) {
|
|
233
|
+
await this.cancellation.release(module, this.createModuleContext(module), false, this.logger);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
this.failedInitializations.push(moduleId);
|
|
237
|
+
}
|
|
153
238
|
this.emitModuleEvent("runtime.module.failed", moduleId, "failed", {
|
|
154
239
|
durationMs: failure.durationMs,
|
|
155
240
|
error: failure.error,
|
|
@@ -165,8 +250,10 @@ export class LifecycleManager {
|
|
|
165
250
|
this.startedModules = [];
|
|
166
251
|
const succeeded = [];
|
|
167
252
|
const failed = [];
|
|
168
|
-
for (const moduleId of this.initializedModules) {
|
|
253
|
+
for (const moduleId of [...this.initializedModules]) {
|
|
169
254
|
const module = this.modules.get(moduleId);
|
|
255
|
+
if (this.cancellation.isCancelled)
|
|
256
|
+
break;
|
|
170
257
|
if (!module)
|
|
171
258
|
continue;
|
|
172
259
|
const moduleStartTime = Date.now();
|
|
@@ -174,7 +261,18 @@ export class LifecycleManager {
|
|
|
174
261
|
try {
|
|
175
262
|
if (module.onReady) {
|
|
176
263
|
const context = this.createModuleContext(module);
|
|
177
|
-
await
|
|
264
|
+
await this.cancellation
|
|
265
|
+
.track(moduleId, Promise.resolve(module.onReady(context)))
|
|
266
|
+
.catch(async (error) => {
|
|
267
|
+
if (this.cancellation.isCancelled) {
|
|
268
|
+
await this.cancellation.release(module, context, false, this.logger);
|
|
269
|
+
}
|
|
270
|
+
throw error;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (this.cancellation.isCancelled) {
|
|
274
|
+
await this.cancellation.release(module, this.createModuleContext(module), true, this.logger);
|
|
275
|
+
break;
|
|
178
276
|
}
|
|
179
277
|
succeeded.push(moduleId);
|
|
180
278
|
this.startedModules.push(moduleId);
|
|
@@ -218,11 +316,15 @@ export class LifecycleManager {
|
|
|
218
316
|
const startTime = Date.now();
|
|
219
317
|
const succeeded = [];
|
|
220
318
|
const failed = [];
|
|
319
|
+
await this.cancellation.settle();
|
|
221
320
|
const reversedModules = [...this.startedModules].reverse();
|
|
222
321
|
for (const moduleId of reversedModules) {
|
|
223
322
|
const module = this.modules.get(moduleId);
|
|
224
323
|
if (!module)
|
|
225
324
|
continue;
|
|
325
|
+
// Removed before its hook runs, so a second stop() (for example
|
|
326
|
+
// after a shutdown timeout) never calls onShutdown on it again.
|
|
327
|
+
this.startedModules = this.startedModules.filter((id) => id !== moduleId);
|
|
226
328
|
const moduleStartTime = Date.now();
|
|
227
329
|
this.emitModuleEvent("runtime.module.stopping", moduleId, "stopping");
|
|
228
330
|
try {
|
|
@@ -268,7 +370,12 @@ export class LifecycleManager {
|
|
|
268
370
|
const startTime = Date.now();
|
|
269
371
|
const succeeded = [];
|
|
270
372
|
const failed = [];
|
|
271
|
-
|
|
373
|
+
await this.cancellation.settle();
|
|
374
|
+
const reversedModules = [
|
|
375
|
+
...this.startedModules,
|
|
376
|
+
...this.initializedModules,
|
|
377
|
+
...this.failedInitializations,
|
|
378
|
+
]
|
|
272
379
|
.filter((id, index, arr) => arr.indexOf(id) === index)
|
|
273
380
|
.reverse();
|
|
274
381
|
for (const moduleId of reversedModules) {
|
|
@@ -303,6 +410,7 @@ export class LifecycleManager {
|
|
|
303
410
|
// rebuilds the lists from scratch.
|
|
304
411
|
this.startedModules = [];
|
|
305
412
|
this.initializedModules = [];
|
|
413
|
+
this.failedInitializations = [];
|
|
306
414
|
this.contexts.clear();
|
|
307
415
|
return Object.freeze({
|
|
308
416
|
phase: "destroy",
|
|
@@ -322,6 +430,7 @@ export class LifecycleManager {
|
|
|
322
430
|
*/
|
|
323
431
|
async rollback() {
|
|
324
432
|
const failures = [];
|
|
433
|
+
this.cancellation.cancel();
|
|
325
434
|
for (const moduleId of [...this.startedModules].reverse()) {
|
|
326
435
|
const module = this.modules.get(moduleId);
|
|
327
436
|
if (!module?.onShutdown)
|
|
@@ -343,9 +452,14 @@ export class LifecycleManager {
|
|
|
343
452
|
});
|
|
344
453
|
}
|
|
345
454
|
}
|
|
346
|
-
for (const moduleId of [
|
|
455
|
+
for (const moduleId of [
|
|
456
|
+
...this.initializedModules,
|
|
457
|
+
...this.failedInitializations,
|
|
458
|
+
].reverse()) {
|
|
347
459
|
const module = this.modules.get(moduleId);
|
|
348
|
-
|
|
460
|
+
// A module whose hook is still running is torn down by the
|
|
461
|
+
// cancellation once that hook settles, never concurrently with it.
|
|
462
|
+
if (!module?.onDestroy || this.cancellation.isRunning(moduleId))
|
|
349
463
|
continue;
|
|
350
464
|
const startedAt = Date.now();
|
|
351
465
|
try {
|
|
@@ -368,6 +482,7 @@ export class LifecycleManager {
|
|
|
368
482
|
// later shutdown does not stop modules a second time.
|
|
369
483
|
this.startedModules = [];
|
|
370
484
|
this.initializedModules = [];
|
|
485
|
+
this.failedInitializations = [];
|
|
371
486
|
this.contexts.clear();
|
|
372
487
|
return failures;
|
|
373
488
|
}
|
|
@@ -72,7 +72,21 @@ 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;
|
|
82
|
+
/**
|
|
83
|
+
* Keeps initializing and starting after a module fails.
|
|
84
|
+
*
|
|
85
|
+
* Only modules INDEPENDENT of the failure continue: a module that
|
|
86
|
+
* declares a failed (or already skipped) module in `dependencies` is
|
|
87
|
+
* never initialized or readied, and is reported in `failed` with the
|
|
88
|
+
* blocking dependency named. Defaults to false.
|
|
89
|
+
*/
|
|
76
90
|
readonly continueOnFailure?: boolean;
|
|
77
91
|
/**
|
|
78
92
|
* Receives one event per module per lifecycle phase.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Manages multiple runtime instances for scenarios where Zudojs
|
|
5
5
|
* runs multiple applications or workers in a single process.
|
|
6
6
|
*/
|
|
7
|
+
import { RuntimeStateError } from "../runtimeError/index.js";
|
|
7
8
|
/**
|
|
8
9
|
* Runtime registry for managing multiple runtime instances.
|
|
9
10
|
*
|
|
@@ -19,7 +20,7 @@ export class RuntimeRegistry {
|
|
|
19
20
|
*/
|
|
20
21
|
register(id, runtime) {
|
|
21
22
|
if (this.runtimes.has(id)) {
|
|
22
|
-
throw new
|
|
23
|
+
throw new RuntimeStateError(`Runtime "${id}" is already registered.`);
|
|
23
24
|
}
|
|
24
25
|
this.runtimes.set(id, runtime);
|
|
25
26
|
}
|
|
@@ -56,7 +57,7 @@ export class RuntimeRegistry {
|
|
|
56
57
|
require(id) {
|
|
57
58
|
const runtime = this.runtimes.get(id);
|
|
58
59
|
if (!runtime) {
|
|
59
|
-
throw new
|
|
60
|
+
throw new RuntimeStateError(`Runtime "${id}" not found.`);
|
|
60
61
|
}
|
|
61
62
|
return runtime;
|
|
62
63
|
}
|
|
@@ -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,
|
|
@@ -298,6 +301,9 @@ export class DefaultRuntime {
|
|
|
298
301
|
});
|
|
299
302
|
}
|
|
300
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();
|
|
301
307
|
throw runtimeError;
|
|
302
308
|
}
|
|
303
309
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_RUNTIME_OPTIONS } from "./runtimeOptions.type.js";
|
|
2
2
|
import { createRuntimeId } from "../runtimeContext/runtimeContext.factory.js";
|
|
3
|
+
import { RuntimeError } from "@zudojs/errors";
|
|
3
4
|
/**
|
|
4
5
|
* Resolves runtime options with defaults applied.
|
|
5
6
|
*
|
|
@@ -24,19 +25,40 @@ export function resolveRuntimeOptions(options) {
|
|
|
24
25
|
*/
|
|
25
26
|
export function validateRuntimeOptions(options) {
|
|
26
27
|
if (!options.environment) {
|
|
27
|
-
throw new
|
|
28
|
+
throw new RuntimeError("Runtime environment is required.", {
|
|
29
|
+
metadata: { option: "environment" },
|
|
30
|
+
});
|
|
28
31
|
}
|
|
29
32
|
if (!options.applicationName) {
|
|
30
|
-
throw new
|
|
33
|
+
throw new RuntimeError("Application name is required.", {
|
|
34
|
+
metadata: { option: "applicationName" },
|
|
35
|
+
});
|
|
31
36
|
}
|
|
32
37
|
if (options.shutdownTimeout <= 0) {
|
|
33
|
-
throw new
|
|
38
|
+
throw new RuntimeError("Shutdown timeout must be positive.", {
|
|
39
|
+
metadata: { option: "shutdownTimeout", value: options.shutdownTimeout },
|
|
40
|
+
});
|
|
34
41
|
}
|
|
35
42
|
if (options.startupTimeout <= 0) {
|
|
36
|
-
throw new
|
|
43
|
+
throw new RuntimeError("Startup timeout must be positive.", {
|
|
44
|
+
metadata: { option: "startupTimeout", value: options.startupTimeout },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (!Number.isFinite(options.fatalExitTimeout) || options.fatalExitTimeout < 0) {
|
|
48
|
+
throw new RuntimeError(`Fatal exit timeout must be a finite, non-negative number, got ${options.fatalExitTimeout}.`, {
|
|
49
|
+
metadata: {
|
|
50
|
+
option: "fatalExitTimeout",
|
|
51
|
+
value: options.fatalExitTimeout,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
37
54
|
}
|
|
38
55
|
if (options.readinessCheckTimeout < 0) {
|
|
39
|
-
throw new
|
|
56
|
+
throw new RuntimeError(`Readiness check timeout must be zero or positive, got ${options.readinessCheckTimeout}. Use 0 to run checks without a bound.`, {
|
|
57
|
+
metadata: {
|
|
58
|
+
option: "readinessCheckTimeout",
|
|
59
|
+
value: options.readinessCheckTimeout,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
40
62
|
}
|
|
41
63
|
}
|
|
42
64
|
/**
|
|
@@ -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.1
|
|
3
|
+
"version": "1.2.1",
|
|
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,13 +27,12 @@
|
|
|
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.1.0"
|
|
30
|
+
"@zudojs/constants": "1.1.1",
|
|
31
|
+
"@zudojs/container": "1.1.2",
|
|
32
|
+
"@zudojs/core": "1.2.1",
|
|
33
|
+
"@zudojs/errors": "1.2.0",
|
|
34
|
+
"@zudojs/events": "1.2.0",
|
|
35
|
+
"@zudojs/logger": "1.3.0"
|
|
37
36
|
},
|
|
38
37
|
"license": "MIT",
|
|
39
38
|
"author": {
|