@zudojs/runtime 1.2.1 → 1.3.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 +112 -1
- package/dist/lifecycle/lifecycle.core.js +2 -3
- package/dist/runtime/runtime.core.d.ts +14 -0
- package/dist/runtime/runtime.core.js +82 -18
- package/dist/runtimeError/runtimeError.base.d.ts +28 -6
- package/dist/runtimeError/runtimeError.base.js +49 -17
- package/dist/runtimeEvents/runtimeEvents.type.d.ts +10 -7
- package/dist/runtimeOptions/runtimeOptions.type.d.ts +16 -0
- package/dist/runtimeOptions/runtimeOptions.type.js +1 -0
- package/dist/runtimeState/runtimeState.core.d.ts +10 -2
- package/dist/runtimeState/runtimeState.core.js +16 -5
- package/dist/shutdown/index.d.ts +1 -0
- package/dist/shutdown/index.js +1 -0
- package/dist/shutdown/shutdown.container.d.ts +22 -0
- package/dist/shutdown/shutdown.container.js +37 -0
- package/dist/signalHandler/signalHandler.core.d.ts +15 -0
- package/dist/signalHandler/signalHandler.core.js +30 -4
- package/dist/signalHandler/signalHandler.keepAlive.d.ts +30 -0
- package/dist/signalHandler/signalHandler.keepAlive.js +45 -0
- package/dist/startup/startup.core.d.ts +4 -1
- package/dist/startup/startup.core.js +12 -6
- package/dist/testRuntime/testRuntime.core.d.ts +3 -0
- package/dist/testRuntime/testRuntime.core.js +5 -0
- package/package.json +10 -10
package/README.md
CHANGED
|
@@ -44,6 +44,116 @@ await runtime.stop();
|
|
|
44
44
|
|
|
45
45
|
Modules start in dependency order and stop in reverse.
|
|
46
46
|
|
|
47
|
+
## Lifecycle states
|
|
48
|
+
|
|
49
|
+
`start()` walks the state machine in order:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
created -> initializing -> initialized -> starting -> running -> stopping -> stopped
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Every module's `onInitialize` runs while `runtime.state` is
|
|
56
|
+
`initializing`; once all have initialized the runtime passes through
|
|
57
|
+
`initialized` to `starting`, and every `onReady` runs under `starting`.
|
|
58
|
+
`start()` resolves in `running`. A failure in any phase ends in `failed`,
|
|
59
|
+
which is still stoppable. `created` may go straight to `stopped` (a
|
|
60
|
+
`stop()` before `start()`). Only `stopped` is terminal:
|
|
61
|
+
`isTerminalState("failed")` is `false`, because `stop()` moves a failed
|
|
62
|
+
runtime on to `stopped`.
|
|
63
|
+
|
|
64
|
+
## Startup failures
|
|
65
|
+
|
|
66
|
+
If any module throws, the runtime rolls back (modules that already started
|
|
67
|
+
are stopped and destroyed in reverse) and the state becomes `failed`.
|
|
68
|
+
`start()` then rejects with a `RuntimeStartError` that wraps the error your
|
|
69
|
+
module threw rather than re-throwing it: the original is `error.cause`,
|
|
70
|
+
`error.phase` is `"initialize"` (an `onInitialize` threw) or `"start"` (an
|
|
71
|
+
`onReady` threw), and `error.failedModuleId` names the module.
|
|
72
|
+
|
|
73
|
+
Two subclasses of `RuntimeStartError` narrow it down:
|
|
74
|
+
|
|
75
|
+
- `RuntimeInitializationError` — the failure was in the initialize phase
|
|
76
|
+
(an `onInitialize` threw, or the configuration manager failed to load).
|
|
77
|
+
- `RuntimeRollbackError` — startup failed AND the rollback that followed
|
|
78
|
+
failed, so a module may still hold resources. `phase`,
|
|
79
|
+
`failedModuleId` and `cause` still describe the startup failure;
|
|
80
|
+
`originalError` is the error `start()` would otherwise have thrown, and
|
|
81
|
+
`rollbackError` is what failed during rollback (an `AggregateError`
|
|
82
|
+
when several modules failed). Call `stop()` to retry the release.
|
|
83
|
+
|
|
84
|
+
A shutdown triggered by `SIGTERM`, `SIGINT` or a fatal error has no caller
|
|
85
|
+
to reject; if it fails, the runtime logs a `RuntimeSignalError` (with the
|
|
86
|
+
failure as `cause`) as the `error` field of its "Shutdown handler
|
|
87
|
+
failed." entry.
|
|
88
|
+
|
|
89
|
+
## Signals and exit codes
|
|
90
|
+
|
|
91
|
+
With `handleSignals: true`, a `SIGTERM` or `SIGINT` runs a graceful
|
|
92
|
+
`stop()`, and the runtime holds the process open until that shutdown has
|
|
93
|
+
finished — every `onShutdown` and `onDestroy` hook runs, even when nothing
|
|
94
|
+
else is keeping the event loop alive or a hook closes the last open socket
|
|
95
|
+
before its async work is done. (Node's signal listeners do not keep the
|
|
96
|
+
process alive by themselves; before this was fixed, a process with nothing
|
|
97
|
+
else pending exited with code 0 mid-shutdown, still `running` or
|
|
98
|
+
`stopping`.)
|
|
99
|
+
|
|
100
|
+
When the shutdown is over the runtime lets the process exit on its own
|
|
101
|
+
rather than calling `process.exit()`, so your own `SIGTERM` listeners and
|
|
102
|
+
pending writes still finish:
|
|
103
|
+
|
|
104
|
+
- clean shutdown: the exit code is left alone (0 unless you set one);
|
|
105
|
+
- failed shutdown — `stop()` rejected (including an `onShutdown` that
|
|
106
|
+
outlives `shutdownTimeout`, which leaves the runtime `failed`), or a
|
|
107
|
+
module's hook failed (`status.shutdownFailures` is non-empty):
|
|
108
|
+
`process.exitCode` is set to `1` and the failure is logged as a
|
|
109
|
+
`RuntimeSignalError`. A failed stop publishes `runtime.failed`
|
|
110
|
+
(`phase: "stop"`) once;
|
|
111
|
+
- fatal error (`exitOnFatalError`): `process.exit(1)` once shutdown ends;
|
|
112
|
+
- second `SIGTERM`/`SIGINT` during shutdown (`forceExitOnSecondSignal`):
|
|
113
|
+
`process.exit(1)` at once.
|
|
114
|
+
|
|
115
|
+
If something outside the runtime still holds a handle (a server you did not
|
|
116
|
+
close in a module), the process stays up after the runtime has stopped, as
|
|
117
|
+
it would without the runtime.
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
import { RuntimeStartError } from "@zudojs/runtime";
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await runtime.start();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error instanceof RuntimeStartError) {
|
|
126
|
+
const original = error.cause; // what the module threw
|
|
127
|
+
logger.error(`Module ${error.failedModuleId} failed in ${error.phase}`, {
|
|
128
|
+
original,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
await runtime.stop(); // release anything rollback did not reach
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
A startup that outlives `startupTimeout` rejects with `RuntimeTimeoutError`,
|
|
136
|
+
which has no cause.
|
|
137
|
+
|
|
138
|
+
## Container ownership
|
|
139
|
+
|
|
140
|
+
The runtime does not create the container; you pass it in, so by default
|
|
141
|
+
you own it and `stop()` leaves it alone. Dispose it yourself after
|
|
142
|
+
`stop()`:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
await runtime.stop();
|
|
146
|
+
await container.dispose();
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
or hand ownership to the runtime with `disposeContainerOnStop: true`, and
|
|
150
|
+
`stop()` disposes it after every module has shut down and been destroyed.
|
|
151
|
+
A disposal failure does not fail `stop()`; it is logged and recorded in
|
|
152
|
+
`runtime.status.shutdownFailures` under the id `"(container)"`
|
|
153
|
+
(exported as `CONTAINER_SHUTDOWN_ID`).
|
|
154
|
+
`createTestRuntime` (from `@zudojs/runtime/testing`) creates its own
|
|
155
|
+
container and sets `disposeContainerOnStop: true`.
|
|
156
|
+
|
|
47
157
|
## Readiness and health
|
|
48
158
|
|
|
49
159
|
Readiness checks are registered on the runtime and re-evaluated on demand.
|
|
@@ -104,7 +214,7 @@ Subscribe with `bus.on(type, handler)`; the payload types are in
|
|
|
104
214
|
|
|
105
215
|
| Event | Payload |
|
|
106
216
|
| --- | --- |
|
|
107
|
-
| `runtime.initializing`, `runtime.running`, `runtime.stopping`, `runtime.stopped` | `RuntimeEventPayload` |
|
|
217
|
+
| `runtime.initializing`, `runtime.initialized`, `runtime.starting`, `runtime.running`, `runtime.stopping`, `runtime.stopped` | `RuntimeEventPayload` |
|
|
108
218
|
| `runtime.failed` | `RuntimeFailureEventPayload` |
|
|
109
219
|
| `runtime.module.initializing` / `initialized` / `starting` / `started` / `stopping` / `stopped` / `failed` | `RuntimeModuleEventPayload` |
|
|
110
220
|
| `runtime.shutdown.drain`, `runtime.shutdown.complete` | `RuntimeEventPayload` |
|
|
@@ -142,6 +252,7 @@ createRuntime(dependencies, {
|
|
|
142
252
|
trackHealth: true,
|
|
143
253
|
readinessCheckTimeout: 5_000, // 0 removes the bound
|
|
144
254
|
parallelInitialization: false, // initialize each depth group at once
|
|
255
|
+
disposeContainerOnStop: false, // true: stop() disposes the container
|
|
145
256
|
metadata: { region: "eu-west-1" },
|
|
146
257
|
});
|
|
147
258
|
```
|
|
@@ -2,7 +2,7 @@ import { createConfigurationManager } from "@zudojs/core";
|
|
|
2
2
|
import { createModuleEventPayload } from "../runtimeEvents/runtimeEvents.core.js";
|
|
3
3
|
import { resolveDependencies } from "../dependencyGraph/index.js";
|
|
4
4
|
import { LifecycleCancellation } from "./lifecycle.cancellation.js";
|
|
5
|
-
import { RuntimeDependencyError, RuntimeStartError, RuntimeStateError, } from "../runtimeError/index.js";
|
|
5
|
+
import { RuntimeDependencyError, RuntimeInitializationError, RuntimeStartError, RuntimeStateError, } from "../runtimeError/index.js";
|
|
6
6
|
/**
|
|
7
7
|
* Manages the lifecycle of runtime modules.
|
|
8
8
|
*/
|
|
@@ -75,8 +75,7 @@ export class LifecycleManager {
|
|
|
75
75
|
await this.configuration.initialize();
|
|
76
76
|
}
|
|
77
77
|
catch (error) {
|
|
78
|
-
throw new
|
|
79
|
-
phase: "initialize",
|
|
78
|
+
throw new RuntimeInitializationError("Configuration failed to load.", {
|
|
80
79
|
...(error instanceof Error && { cause: error }),
|
|
81
80
|
});
|
|
82
81
|
}
|
|
@@ -137,12 +137,26 @@ export declare class DefaultRuntime implements Runtime {
|
|
|
137
137
|
* Performs runtime startup.
|
|
138
138
|
*/
|
|
139
139
|
private performStart;
|
|
140
|
+
/**
|
|
141
|
+
* Walks `initializing` -> `initialized` -> `starting` once every module
|
|
142
|
+
* has initialized, before the first `onReady` hook runs.
|
|
143
|
+
*/
|
|
144
|
+
private enterStartingPhase;
|
|
145
|
+
/**
|
|
146
|
+
* Disposes the container when the runtime owns it
|
|
147
|
+
* (`disposeContainerOnStop`). Returns the failure to record, if any.
|
|
148
|
+
*/
|
|
149
|
+
private releaseContainer;
|
|
140
150
|
/**
|
|
141
151
|
* Performs runtime shutdown.
|
|
142
152
|
*/
|
|
143
153
|
private performStop;
|
|
144
154
|
/**
|
|
145
155
|
* Runs shutdown in response to a termination signal.
|
|
156
|
+
*
|
|
157
|
+
* Rejects when the stop failed or a module failed to shut down, so the
|
|
158
|
+
* signal handler logs a `RuntimeSignalError` and sets a non-zero exit
|
|
159
|
+
* code. Swallowing the failure here let a broken shutdown exit with 0.
|
|
146
160
|
*/
|
|
147
161
|
private handleShutdownSignal;
|
|
148
162
|
/**
|
|
@@ -4,12 +4,13 @@ import { createRuntimeContext, withRuntimeContextState, } from "../runtimeContex
|
|
|
4
4
|
import { LifecycleManager } from "../lifecycle/index.js";
|
|
5
5
|
import { executeStartup, rollbackStartup } from "../startup/index.js";
|
|
6
6
|
import { executeShutdown } from "../shutdown/index.js";
|
|
7
|
+
import { disposeRuntimeContainer } from "../shutdown/shutdown.container.js";
|
|
7
8
|
import { SignalHandler } from "../signalHandler/index.js";
|
|
8
9
|
import { ReadinessTracker } from "../readiness/index.js";
|
|
9
10
|
import { computeRuntimeHealth } from "../health/index.js";
|
|
10
11
|
import { createRuntimeEventPayload, createFailureEventPayload, createHealthEventPayload, createReadinessEventPayload, publishRuntimeEvent, } from "../runtimeEvents/index.js";
|
|
11
12
|
import { createEvent } from "@zudojs/events";
|
|
12
|
-
import { RuntimeStateError, toRuntimeError } from "../runtimeError/index.js";
|
|
13
|
+
import { RuntimeRollbackError, RuntimeStartError, RuntimeStateError, RuntimeStopError, toRuntimeError, } from "../runtimeError/index.js";
|
|
13
14
|
/**
|
|
14
15
|
* Default runtime implementation.
|
|
15
16
|
*/
|
|
@@ -224,6 +225,10 @@ export class DefaultRuntime {
|
|
|
224
225
|
this._state = "stopped";
|
|
225
226
|
this._stoppedAt = new Date();
|
|
226
227
|
this.signalHandler.unregister();
|
|
228
|
+
const containerFailure = await this.releaseContainer();
|
|
229
|
+
if (containerFailure !== undefined) {
|
|
230
|
+
this._shutdownFailures = [containerFailure];
|
|
231
|
+
}
|
|
227
232
|
return;
|
|
228
233
|
}
|
|
229
234
|
if (!canStop(this._state)) {
|
|
@@ -250,7 +255,7 @@ export class DefaultRuntime {
|
|
|
250
255
|
// while modules are still coming up must be handled, not ignored.
|
|
251
256
|
this.signalHandler.register(() => this.handleShutdownSignal());
|
|
252
257
|
try {
|
|
253
|
-
await executeStartup(this.lifecycle, this.options.runtimeId, this._contextBase.eventBus, this.logger, this.options.emitEvents, this.options.startupTimeout);
|
|
258
|
+
await executeStartup(this.lifecycle, this.options.runtimeId, this._contextBase.eventBus, this.logger, this.options.emitEvents, this.options.startupTimeout, () => this.enterStartingPhase());
|
|
254
259
|
this.transitionTo("running");
|
|
255
260
|
this._startedAt = new Date();
|
|
256
261
|
// Evaluate any checks registered before startup instead of
|
|
@@ -282,31 +287,73 @@ export class DefaultRuntime {
|
|
|
282
287
|
this.logger.error("Runtime failed to start.", {
|
|
283
288
|
errorMessage: runtimeError.message,
|
|
284
289
|
});
|
|
285
|
-
|
|
290
|
+
// A module failure was already published by executeStartup, with the
|
|
291
|
+
// failing module and phase; publishing it again here reported one
|
|
292
|
+
// failure twice. A timeout or other error is published here.
|
|
293
|
+
const alreadyPublished = error instanceof RuntimeStartError && error.failedModuleId !== undefined;
|
|
294
|
+
if (this.options.emitEvents && !alreadyPublished) {
|
|
286
295
|
this.emitEvent("runtime.failed", createFailureEventPayload(this.options.runtimeId, "failed", runtimeError, "startup"));
|
|
287
296
|
}
|
|
297
|
+
let rollbackError;
|
|
288
298
|
try {
|
|
289
299
|
const rollbackFailures = await rollbackStartup(this.lifecycle, this.logger);
|
|
290
300
|
if (rollbackFailures.length > 0) {
|
|
291
301
|
this.logger.error("Rollback completed with failures.", {
|
|
292
302
|
failedModules: rollbackFailures.map((failure) => failure.moduleId),
|
|
293
303
|
});
|
|
304
|
+
rollbackError =
|
|
305
|
+
rollbackFailures.length === 1
|
|
306
|
+
? rollbackFailures[0].error
|
|
307
|
+
: new AggregateError(rollbackFailures.map((failure) => failure.error), `${rollbackFailures.length} modules failed to roll back: ` +
|
|
308
|
+
rollbackFailures.map((f) => f.moduleId).join(", ") +
|
|
309
|
+
".");
|
|
294
310
|
}
|
|
295
311
|
}
|
|
296
|
-
catch (
|
|
312
|
+
catch (caught) {
|
|
313
|
+
rollbackError =
|
|
314
|
+
caught instanceof Error ? caught : new Error(String(caught));
|
|
297
315
|
this.logger.error("Rollback failed.", {
|
|
298
|
-
errorMessage: rollbackError
|
|
299
|
-
? rollbackError.message
|
|
300
|
-
: String(rollbackError),
|
|
316
|
+
errorMessage: rollbackError.message,
|
|
301
317
|
});
|
|
302
318
|
}
|
|
319
|
+
// A rollback that failed leaves resources behind; say so rather than
|
|
320
|
+
// reporting only the startup failure. The startup failure stays
|
|
321
|
+
// reachable as `originalError`, and `cause` is unchanged.
|
|
322
|
+
const thrown = rollbackError === undefined
|
|
323
|
+
? runtimeError
|
|
324
|
+
: new RuntimeRollbackError(runtimeError, rollbackError);
|
|
325
|
+
this._error = thrown;
|
|
303
326
|
this.transitionTo("failed");
|
|
304
327
|
// A rolled-back runtime owns nothing, so it must not keep owning the
|
|
305
328
|
// process's signals and fatal-error handlers either.
|
|
306
329
|
this.signalHandler.unregister();
|
|
307
|
-
throw
|
|
330
|
+
throw thrown;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Walks `initializing` -> `initialized` -> `starting` once every module
|
|
335
|
+
* has initialized, before the first `onReady` hook runs.
|
|
336
|
+
*/
|
|
337
|
+
enterStartingPhase() {
|
|
338
|
+
this.transitionTo("initialized");
|
|
339
|
+
if (this.options.emitEvents) {
|
|
340
|
+
this.emitEvent("runtime.initialized");
|
|
341
|
+
}
|
|
342
|
+
this.transitionTo("starting");
|
|
343
|
+
if (this.options.emitEvents) {
|
|
344
|
+
this.emitEvent("runtime.starting");
|
|
308
345
|
}
|
|
309
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Disposes the container when the runtime owns it
|
|
349
|
+
* (`disposeContainerOnStop`). Returns the failure to record, if any.
|
|
350
|
+
*/
|
|
351
|
+
async releaseContainer() {
|
|
352
|
+
if (!this.options.disposeContainerOnStop) {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
return disposeRuntimeContainer(this._contextBase.container, this.logger);
|
|
356
|
+
}
|
|
310
357
|
/**
|
|
311
358
|
* Performs runtime shutdown.
|
|
312
359
|
*/
|
|
@@ -316,20 +363,29 @@ export class DefaultRuntime {
|
|
|
316
363
|
if (this.options.emitEvents) {
|
|
317
364
|
this.emitEvent("runtime.stopping");
|
|
318
365
|
}
|
|
366
|
+
// `executeShutdown` publishes `runtime.failed` itself when it rejects,
|
|
367
|
+
// so the catch below only publishes for a failure raised after it.
|
|
368
|
+
// Publishing on both paths delivered every failed stop twice.
|
|
369
|
+
let shutdownSettled = false;
|
|
319
370
|
try {
|
|
320
371
|
const result = await executeShutdown(this.lifecycle, this.options.runtimeId, this._contextBase.eventBus, this.logger, this.options.shutdownTimeout, this.options.emitEvents);
|
|
321
|
-
|
|
372
|
+
shutdownSettled = true;
|
|
373
|
+
const containerFailure = await this.releaseContainer();
|
|
374
|
+
this._shutdownFailures =
|
|
375
|
+
containerFailure === undefined
|
|
376
|
+
? result.failures
|
|
377
|
+
: [...result.failures, containerFailure];
|
|
322
378
|
this.transitionTo("stopped");
|
|
323
379
|
this._stoppedAt = new Date();
|
|
324
380
|
if (this.options.emitEvents) {
|
|
325
381
|
this.emitEvent("runtime.stopped");
|
|
326
382
|
}
|
|
327
|
-
if (
|
|
383
|
+
if (this._shutdownFailures.length > 0) {
|
|
328
384
|
// A teardown that dropped modules on the floor must not read as
|
|
329
385
|
// a clean stop; `status.shutdownFailures` records what failed.
|
|
330
386
|
this.logger.warn("Runtime stopped with module failures.", {
|
|
331
387
|
runtimeId: this.options.runtimeId,
|
|
332
|
-
failedModules:
|
|
388
|
+
failedModules: this._shutdownFailures.map((failure) => failure.moduleId),
|
|
333
389
|
});
|
|
334
390
|
}
|
|
335
391
|
else {
|
|
@@ -345,7 +401,7 @@ export class DefaultRuntime {
|
|
|
345
401
|
this.logger.error("Runtime failed to stop.", {
|
|
346
402
|
errorMessage: runtimeError.message,
|
|
347
403
|
});
|
|
348
|
-
if (this.options.emitEvents) {
|
|
404
|
+
if (this.options.emitEvents && shutdownSettled) {
|
|
349
405
|
this.emitEvent("runtime.failed", createFailureEventPayload(this.options.runtimeId, "failed", runtimeError, "stop"));
|
|
350
406
|
}
|
|
351
407
|
this.transitionTo("failed");
|
|
@@ -360,14 +416,22 @@ export class DefaultRuntime {
|
|
|
360
416
|
}
|
|
361
417
|
/**
|
|
362
418
|
* Runs shutdown in response to a termination signal.
|
|
419
|
+
*
|
|
420
|
+
* Rejects when the stop failed or a module failed to shut down, so the
|
|
421
|
+
* signal handler logs a `RuntimeSignalError` and sets a non-zero exit
|
|
422
|
+
* code. Swallowing the failure here let a broken shutdown exit with 0.
|
|
363
423
|
*/
|
|
364
424
|
async handleShutdownSignal() {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
425
|
+
await this.stop();
|
|
426
|
+
const failures = this._shutdownFailures;
|
|
427
|
+
if (failures.length > 0) {
|
|
428
|
+
throw new RuntimeStopError(`Runtime stopped with ${failures.length} module failure(s): ` +
|
|
429
|
+
failures.map((failure) => failure.moduleId).join(", ") +
|
|
430
|
+
".", {
|
|
431
|
+
phase: "shutdown",
|
|
432
|
+
cause: failures.length === 1
|
|
433
|
+
? failures[0].error
|
|
434
|
+
: new AggregateError(failures.map((failure) => failure.error)),
|
|
371
435
|
});
|
|
372
436
|
}
|
|
373
437
|
}
|
|
@@ -28,11 +28,18 @@ export declare class RuntimeStopError extends RuntimeError {
|
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
|
-
* Error thrown when
|
|
31
|
+
* Error thrown when startup fails during initialization: a module's
|
|
32
|
+
* `onInitialize` threw, or the configuration manager failed to load.
|
|
33
|
+
*
|
|
34
|
+
* A {@link RuntimeStartError} with `phase: "initialize"`, so handlers
|
|
35
|
+
* written against `RuntimeStartError` keep matching. `cause` is the
|
|
36
|
+
* original error and `failedModuleId` names the module (absent for a
|
|
37
|
+
* configuration failure).
|
|
32
38
|
*/
|
|
33
|
-
export declare class RuntimeInitializationError extends
|
|
39
|
+
export declare class RuntimeInitializationError extends RuntimeStartError {
|
|
34
40
|
constructor(message: string, options?: {
|
|
35
41
|
readonly cause?: Error;
|
|
42
|
+
readonly failedModuleId?: string;
|
|
36
43
|
});
|
|
37
44
|
}
|
|
38
45
|
/**
|
|
@@ -44,9 +51,17 @@ export declare class RuntimeTimeoutError extends RuntimeError {
|
|
|
44
51
|
constructor(operation: string, timeoutMs: number);
|
|
45
52
|
}
|
|
46
53
|
/**
|
|
47
|
-
* Error thrown when
|
|
54
|
+
* Error thrown when startup failed and the rollback that followed failed
|
|
55
|
+
* too, so some module may still hold resources.
|
|
56
|
+
*
|
|
57
|
+
* A {@link RuntimeStartError} describing the ORIGINAL failure: `phase`,
|
|
58
|
+
* `failedModuleId` and `cause` (what the module threw) match the error
|
|
59
|
+
* `start()` would otherwise have rejected with, which is kept whole as
|
|
60
|
+
* `originalError`. `rollbackError` is what failed during rollback (an
|
|
61
|
+
* `AggregateError` when several modules failed). Call `stop()` to retry
|
|
62
|
+
* releasing what rollback did not reach.
|
|
48
63
|
*/
|
|
49
|
-
export declare class RuntimeRollbackError extends
|
|
64
|
+
export declare class RuntimeRollbackError extends RuntimeStartError {
|
|
50
65
|
readonly originalError: Error;
|
|
51
66
|
readonly rollbackError: Error;
|
|
52
67
|
constructor(originalError: Error, rollbackError: Error);
|
|
@@ -67,11 +82,18 @@ export declare class RuntimeDependencyError extends RuntimeError {
|
|
|
67
82
|
constructor(moduleId: string, dependencyId: string);
|
|
68
83
|
}
|
|
69
84
|
/**
|
|
70
|
-
*
|
|
85
|
+
* Reports a shutdown triggered by a signal (`SIGTERM`, `SIGINT`, or
|
|
86
|
+
* `"fatal"` for an uncaught exception or unhandled rejection) that failed.
|
|
87
|
+
*
|
|
88
|
+
* Signal listeners have no caller to throw to, so the signal handler logs
|
|
89
|
+
* this error (as the `error` field of its "Shutdown handler failed." log
|
|
90
|
+
* entry) with the shutdown failure as `cause`.
|
|
71
91
|
*/
|
|
72
92
|
export declare class RuntimeSignalError extends RuntimeError {
|
|
73
93
|
readonly signal: string;
|
|
74
|
-
constructor(signal: string
|
|
94
|
+
constructor(signal: string, options?: {
|
|
95
|
+
readonly cause?: unknown;
|
|
96
|
+
});
|
|
75
97
|
}
|
|
76
98
|
/**
|
|
77
99
|
* Converts an unknown error to a RuntimeError.
|
|
@@ -41,15 +41,22 @@ export class RuntimeStopError extends RuntimeError {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
|
-
* Error thrown when
|
|
44
|
+
* Error thrown when startup fails during initialization: a module's
|
|
45
|
+
* `onInitialize` threw, or the configuration manager failed to load.
|
|
46
|
+
*
|
|
47
|
+
* A {@link RuntimeStartError} with `phase: "initialize"`, so handlers
|
|
48
|
+
* written against `RuntimeStartError` keep matching. `cause` is the
|
|
49
|
+
* original error and `failedModuleId` names the module (absent for a
|
|
50
|
+
* configuration failure).
|
|
45
51
|
*/
|
|
46
|
-
export class RuntimeInitializationError extends
|
|
52
|
+
export class RuntimeInitializationError extends RuntimeStartError {
|
|
47
53
|
constructor(message, options = {}) {
|
|
48
54
|
super(message, {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
phase: "initialize",
|
|
56
|
+
...(options.cause !== undefined && { cause: options.cause }),
|
|
57
|
+
...(options.failedModuleId !== undefined && {
|
|
58
|
+
failedModuleId: options.failedModuleId,
|
|
59
|
+
}),
|
|
53
60
|
});
|
|
54
61
|
}
|
|
55
62
|
}
|
|
@@ -71,18 +78,30 @@ export class RuntimeTimeoutError extends RuntimeError {
|
|
|
71
78
|
}
|
|
72
79
|
}
|
|
73
80
|
/**
|
|
74
|
-
* Error thrown when
|
|
81
|
+
* Error thrown when startup failed and the rollback that followed failed
|
|
82
|
+
* too, so some module may still hold resources.
|
|
83
|
+
*
|
|
84
|
+
* A {@link RuntimeStartError} describing the ORIGINAL failure: `phase`,
|
|
85
|
+
* `failedModuleId` and `cause` (what the module threw) match the error
|
|
86
|
+
* `start()` would otherwise have rejected with, which is kept whole as
|
|
87
|
+
* `originalError`. `rollbackError` is what failed during rollback (an
|
|
88
|
+
* `AggregateError` when several modules failed). Call `stop()` to retry
|
|
89
|
+
* releasing what rollback did not reach.
|
|
75
90
|
*/
|
|
76
|
-
export class RuntimeRollbackError extends
|
|
91
|
+
export class RuntimeRollbackError extends RuntimeStartError {
|
|
77
92
|
originalError;
|
|
78
93
|
rollbackError;
|
|
79
94
|
constructor(originalError, rollbackError) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
95
|
+
const start = originalError instanceof RuntimeStartError ? originalError : undefined;
|
|
96
|
+
const cause = start !== undefined && start.cause instanceof Error
|
|
97
|
+
? start.cause
|
|
98
|
+
: originalError;
|
|
99
|
+
super(`${originalError.message} Rollback also failed: ${rollbackError.message}`, {
|
|
100
|
+
phase: start?.phase ?? "startup",
|
|
101
|
+
cause,
|
|
102
|
+
...(start?.failedModuleId !== undefined && {
|
|
103
|
+
failedModuleId: start.failedModuleId,
|
|
104
|
+
}),
|
|
86
105
|
});
|
|
87
106
|
this.originalError = originalError;
|
|
88
107
|
this.rollbackError = rollbackError;
|
|
@@ -120,12 +139,25 @@ export class RuntimeDependencyError extends RuntimeError {
|
|
|
120
139
|
}
|
|
121
140
|
}
|
|
122
141
|
/**
|
|
123
|
-
*
|
|
142
|
+
* Reports a shutdown triggered by a signal (`SIGTERM`, `SIGINT`, or
|
|
143
|
+
* `"fatal"` for an uncaught exception or unhandled rejection) that failed.
|
|
144
|
+
*
|
|
145
|
+
* Signal listeners have no caller to throw to, so the signal handler logs
|
|
146
|
+
* this error (as the `error` field of its "Shutdown handler failed." log
|
|
147
|
+
* entry) with the shutdown failure as `cause`.
|
|
124
148
|
*/
|
|
125
149
|
export class RuntimeSignalError extends RuntimeError {
|
|
126
150
|
signal;
|
|
127
|
-
constructor(signal) {
|
|
128
|
-
|
|
151
|
+
constructor(signal, options = {}) {
|
|
152
|
+
const detail = options.cause === undefined
|
|
153
|
+
? undefined
|
|
154
|
+
: options.cause instanceof Error
|
|
155
|
+
? options.cause.message
|
|
156
|
+
: String(options.cause);
|
|
157
|
+
super(detail === undefined
|
|
158
|
+
? `Runtime received unexpected signal "${signal}".`
|
|
159
|
+
: `Shutdown triggered by "${signal}" failed: ${detail}`, {
|
|
160
|
+
...(options.cause instanceof Error && { cause: options.cause }),
|
|
129
161
|
metadata: {
|
|
130
162
|
signal,
|
|
131
163
|
},
|
|
@@ -3,9 +3,10 @@ import type { RuntimeFailureState, RuntimeState } from "../runtimeState/runtimeS
|
|
|
3
3
|
* Runtime lifecycle events.
|
|
4
4
|
*
|
|
5
5
|
* These events are emitted through the EventBus when
|
|
6
|
-
* the runtime transitions through its lifecycle.
|
|
6
|
+
* the runtime transitions through its lifecycle. Derived from
|
|
7
|
+
* {@link RuntimeEventMap}, so every event with a payload type is named here.
|
|
7
8
|
*/
|
|
8
|
-
export type RuntimeEventType =
|
|
9
|
+
export type RuntimeEventType = keyof RuntimeEventMap;
|
|
9
10
|
/**
|
|
10
11
|
* Base payload for all runtime events.
|
|
11
12
|
*/
|
|
@@ -60,17 +61,19 @@ export interface RuntimeReadinessEventPayload extends RuntimeEventPayload {
|
|
|
60
61
|
/**
|
|
61
62
|
* Event types carrying a {@link RuntimeModuleEventPayload}.
|
|
62
63
|
*/
|
|
63
|
-
export type RuntimeModuleEventType =
|
|
64
|
+
export type RuntimeModuleEventType = Extract<RuntimeEventType, `runtime.module.${string}`>;
|
|
64
65
|
/**
|
|
65
66
|
* Maps event types to their payload types.
|
|
66
67
|
*
|
|
67
|
-
* Every entry here is emitted by the runtime.
|
|
68
|
-
*
|
|
69
|
-
* `runtime.starting`
|
|
70
|
-
*
|
|
68
|
+
* Every entry here is emitted by the runtime. `runtime.created` is not:
|
|
69
|
+
* nothing is subscribed before construction. `runtime.initialized` and
|
|
70
|
+
* `runtime.starting` are published between module initialization and the
|
|
71
|
+
* first `onReady` hook.
|
|
71
72
|
*/
|
|
72
73
|
export interface RuntimeEventMap {
|
|
73
74
|
"runtime.initializing": RuntimeEventPayload;
|
|
75
|
+
"runtime.initialized": RuntimeEventPayload;
|
|
76
|
+
"runtime.starting": RuntimeEventPayload;
|
|
74
77
|
"runtime.running": RuntimeEventPayload;
|
|
75
78
|
"runtime.stopping": RuntimeEventPayload;
|
|
76
79
|
"runtime.stopped": RuntimeEventPayload;
|
|
@@ -98,6 +98,20 @@ export interface RuntimeOptions {
|
|
|
98
98
|
* @default false
|
|
99
99
|
*/
|
|
100
100
|
readonly parallelInitialization?: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Whether `stop()` disposes the container passed in `dependencies`.
|
|
103
|
+
*
|
|
104
|
+
* The caller that created the container owns it, so by default the
|
|
105
|
+
* runtime leaves it alone: dispose it yourself after `stop()`, or set
|
|
106
|
+
* this to hand ownership to the runtime. It is disposed after every
|
|
107
|
+
* module has shut down and been destroyed (or on `stop()` of a runtime
|
|
108
|
+
* that never started). A disposal failure is recorded in
|
|
109
|
+
* `status.shutdownFailures` under `"(container)"` rather than failing
|
|
110
|
+
* the stop. `createTestRuntime` creates its own container and sets this.
|
|
111
|
+
*
|
|
112
|
+
* @default false
|
|
113
|
+
*/
|
|
114
|
+
readonly disposeContainerOnStop?: boolean;
|
|
101
115
|
/**
|
|
102
116
|
* Additional runtime metadata.
|
|
103
117
|
*/
|
|
@@ -123,6 +137,7 @@ export interface ResolvedRuntimeOptions {
|
|
|
123
137
|
readonly trackHealth: boolean;
|
|
124
138
|
readonly readinessCheckTimeout: number;
|
|
125
139
|
readonly parallelInitialization: boolean;
|
|
140
|
+
readonly disposeContainerOnStop: boolean;
|
|
126
141
|
readonly metadata: Readonly<Record<string, unknown>>;
|
|
127
142
|
}
|
|
128
143
|
/**
|
|
@@ -141,6 +156,7 @@ export declare const DEFAULT_RUNTIME_OPTIONS: Readonly<{
|
|
|
141
156
|
readonly trackHealth: true;
|
|
142
157
|
readonly readinessCheckTimeout: 5000;
|
|
143
158
|
readonly parallelInitialization: false;
|
|
159
|
+
readonly disposeContainerOnStop: false;
|
|
144
160
|
readonly applicationVersion: "0.1.0";
|
|
145
161
|
readonly metadata: Readonly<{}>;
|
|
146
162
|
}>;
|
|
@@ -14,6 +14,7 @@ export const DEFAULT_RUNTIME_OPTIONS = Object.freeze({
|
|
|
14
14
|
trackHealth: true,
|
|
15
15
|
readinessCheckTimeout: 5_000,
|
|
16
16
|
parallelInitialization: false,
|
|
17
|
+
disposeContainerOnStop: false,
|
|
17
18
|
applicationVersion: "0.1.0",
|
|
18
19
|
// `metadata` is required on ResolvedRuntimeOptions, so it needs a
|
|
19
20
|
// default; without one the resolved options claimed a value the
|
|
@@ -4,7 +4,13 @@ import type { RuntimeState, RuntimeStatus } from "./runtimeState.type.js";
|
|
|
4
4
|
*/
|
|
5
5
|
export declare const RUNTIME_STATE_TRANSITIONS: Readonly<Record<RuntimeState, readonly RuntimeState[]>>;
|
|
6
6
|
/**
|
|
7
|
-
* Terminal runtime states.
|
|
7
|
+
* Terminal runtime states: no further transition is possible.
|
|
8
|
+
*
|
|
9
|
+
* Only `stopped`. `failed` is NOT terminal: `stop()` is allowed from it
|
|
10
|
+
* (`failed -> stopping -> stopped`) so an operator can release whatever
|
|
11
|
+
* startup rollback did not reach. It used to be listed here, so
|
|
12
|
+
* `isTerminalState("failed")` claimed a state was final while `stop()`
|
|
13
|
+
* still moved out of it.
|
|
8
14
|
*/
|
|
9
15
|
export declare const TERMINAL_STATES: readonly RuntimeState[];
|
|
10
16
|
/**
|
|
@@ -28,7 +34,9 @@ export declare function canTransition(from: RuntimeState, to: RuntimeState): boo
|
|
|
28
34
|
*/
|
|
29
35
|
export declare function assertTransition(from: RuntimeState, to: RuntimeState): void;
|
|
30
36
|
/**
|
|
31
|
-
* Returns whether the runtime is in a terminal state.
|
|
37
|
+
* Returns whether the runtime is in a terminal state (`stopped`).
|
|
38
|
+
*
|
|
39
|
+
* `failed` is not terminal: call `stop()` to release it.
|
|
32
40
|
*/
|
|
33
41
|
export declare function isTerminalState(state: RuntimeState): boolean;
|
|
34
42
|
/**
|
|
@@ -4,8 +4,12 @@ import { RuntimeStateError } from "../runtimeError/runtimeError.base.js";
|
|
|
4
4
|
*/
|
|
5
5
|
export const RUNTIME_STATE_TRANSITIONS = Object.freeze({
|
|
6
6
|
created: ["initializing", "stopped", "failed"],
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// Startup walks every state in order: modules initialize under
|
|
8
|
+
// "initializing", the runtime passes through "initialized", and modules'
|
|
9
|
+
// onReady hooks run under "starting". The shortcuts straight to
|
|
10
|
+
// "running" were never taken once the runtime entered these states.
|
|
11
|
+
initializing: ["initialized", "failed"],
|
|
12
|
+
initialized: ["starting", "failed"],
|
|
9
13
|
starting: ["running", "failed"],
|
|
10
14
|
running: ["stopping", "failed"],
|
|
11
15
|
stopping: ["stopped", "failed"],
|
|
@@ -17,11 +21,16 @@ export const RUNTIME_STATE_TRANSITIONS = Object.freeze({
|
|
|
17
21
|
failed: ["stopping", "stopped"],
|
|
18
22
|
});
|
|
19
23
|
/**
|
|
20
|
-
* Terminal runtime states.
|
|
24
|
+
* Terminal runtime states: no further transition is possible.
|
|
25
|
+
*
|
|
26
|
+
* Only `stopped`. `failed` is NOT terminal: `stop()` is allowed from it
|
|
27
|
+
* (`failed -> stopping -> stopped`) so an operator can release whatever
|
|
28
|
+
* startup rollback did not reach. It used to be listed here, so
|
|
29
|
+
* `isTerminalState("failed")` claimed a state was final while `stop()`
|
|
30
|
+
* still moved out of it.
|
|
21
31
|
*/
|
|
22
32
|
export const TERMINAL_STATES = Object.freeze([
|
|
23
33
|
"stopped",
|
|
24
|
-
"failed",
|
|
25
34
|
]);
|
|
26
35
|
/**
|
|
27
36
|
* Runtime states from which startup is allowed.
|
|
@@ -59,7 +68,9 @@ export function assertTransition(from, to) {
|
|
|
59
68
|
}
|
|
60
69
|
}
|
|
61
70
|
/**
|
|
62
|
-
* Returns whether the runtime is in a terminal state.
|
|
71
|
+
* Returns whether the runtime is in a terminal state (`stopped`).
|
|
72
|
+
*
|
|
73
|
+
* `failed` is not terminal: call `stop()` to release it.
|
|
63
74
|
*/
|
|
64
75
|
export function isTerminalState(state) {
|
|
65
76
|
return TERMINAL_STATES.includes(state);
|
package/dist/shutdown/index.d.ts
CHANGED
package/dist/shutdown/index.js
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Container } from "@zudojs/container";
|
|
2
|
+
import type { Logger } from "@zudojs/logger";
|
|
3
|
+
import type { LifecycleFailure } from "../lifecycle/lifecycle.type.js";
|
|
4
|
+
/**
|
|
5
|
+
* The `moduleId` under which a container disposal failure is reported in
|
|
6
|
+
* `status.shutdownFailures`. Parenthesised so it cannot collide with a
|
|
7
|
+
* real module id.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CONTAINER_SHUTDOWN_ID = "(container)";
|
|
10
|
+
/**
|
|
11
|
+
* Disposes the runtime's container after its modules have shut down.
|
|
12
|
+
*
|
|
13
|
+
* Runs only when the runtime owns the container
|
|
14
|
+
* (`disposeContainerOnStop: true`, which `createTestRuntime` sets). A
|
|
15
|
+
* disposal failure does not fail the stop, matching module teardown
|
|
16
|
+
* failures: it is logged and returned so it lands in
|
|
17
|
+
* `status.shutdownFailures`.
|
|
18
|
+
*
|
|
19
|
+
* @returns The failure, or `undefined` when disposal succeeded.
|
|
20
|
+
*/
|
|
21
|
+
export declare function disposeRuntimeContainer(container: Container, logger: Logger): Promise<LifecycleFailure | undefined>;
|
|
22
|
+
//# sourceMappingURL=shutdown.container.d.ts.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `moduleId` under which a container disposal failure is reported in
|
|
3
|
+
* `status.shutdownFailures`. Parenthesised so it cannot collide with a
|
|
4
|
+
* real module id.
|
|
5
|
+
*/
|
|
6
|
+
export const CONTAINER_SHUTDOWN_ID = "(container)";
|
|
7
|
+
/**
|
|
8
|
+
* Disposes the runtime's container after its modules have shut down.
|
|
9
|
+
*
|
|
10
|
+
* Runs only when the runtime owns the container
|
|
11
|
+
* (`disposeContainerOnStop: true`, which `createTestRuntime` sets). A
|
|
12
|
+
* disposal failure does not fail the stop, matching module teardown
|
|
13
|
+
* failures: it is logged and returned so it lands in
|
|
14
|
+
* `status.shutdownFailures`.
|
|
15
|
+
*
|
|
16
|
+
* @returns The failure, or `undefined` when disposal succeeded.
|
|
17
|
+
*/
|
|
18
|
+
export async function disposeRuntimeContainer(container, logger) {
|
|
19
|
+
const startedAt = Date.now();
|
|
20
|
+
try {
|
|
21
|
+
await container.dispose();
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const failure = {
|
|
26
|
+
moduleId: CONTAINER_SHUTDOWN_ID,
|
|
27
|
+
phase: "destroy",
|
|
28
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
29
|
+
durationMs: Date.now() - startedAt,
|
|
30
|
+
};
|
|
31
|
+
logger.error("Runtime container failed to dispose.", {
|
|
32
|
+
errorMessage: failure.error.message,
|
|
33
|
+
});
|
|
34
|
+
return failure;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=shutdown.container.js.map
|
|
@@ -28,6 +28,12 @@ export interface SignalHandlerOptions {
|
|
|
28
28
|
readonly fatalExitTimeout?: number;
|
|
29
29
|
/** Exit hook, injected for testing. Defaults to `process.exit`. */
|
|
30
30
|
readonly exit?: (code: number) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Exit-code hook, injected for testing. Defaults to setting
|
|
33
|
+
* `process.exitCode`. Called with `1` when a signal-triggered shutdown
|
|
34
|
+
* fails; a clean shutdown leaves the exit code alone.
|
|
35
|
+
*/
|
|
36
|
+
readonly setExitCode?: (code: number) => void;
|
|
31
37
|
}
|
|
32
38
|
/**
|
|
33
39
|
* Signal handler for process lifecycle events.
|
|
@@ -39,6 +45,7 @@ export declare class SignalHandler {
|
|
|
39
45
|
private isShuttingDown;
|
|
40
46
|
private registered;
|
|
41
47
|
private forcedExitTimer;
|
|
48
|
+
private flushedPendingSignals;
|
|
42
49
|
constructor(logger: Logger, options: SignalHandlerOptions);
|
|
43
50
|
/**
|
|
44
51
|
* Registers signal handlers.
|
|
@@ -60,6 +67,11 @@ export declare class SignalHandler {
|
|
|
60
67
|
get shuttingDown(): boolean;
|
|
61
68
|
private handleTermination;
|
|
62
69
|
private handleInterruption;
|
|
70
|
+
/**
|
|
71
|
+
* Gives a signal delivered just before the loop drained one turn to be
|
|
72
|
+
* dispatched, once per registration so an idle process still exits.
|
|
73
|
+
*/
|
|
74
|
+
private handleBeforeExit;
|
|
63
75
|
/**
|
|
64
76
|
* Handles uncaught exceptions.
|
|
65
77
|
*
|
|
@@ -77,6 +89,9 @@ export declare class SignalHandler {
|
|
|
77
89
|
*
|
|
78
90
|
* A second termination signal exits immediately: an operator pressing
|
|
79
91
|
* Ctrl-C again on a stuck shutdown is asking for exactly that.
|
|
92
|
+
*
|
|
93
|
+
* The event loop is held open until the shutdown settles, and a failed
|
|
94
|
+
* shutdown sets a non-zero exit code.
|
|
80
95
|
*/
|
|
81
96
|
private initiateShutdown;
|
|
82
97
|
private exit;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { RuntimeSignalError } from "../runtimeError/runtimeError.base.js";
|
|
2
|
+
import { flushPendingSignals, holdEventLoop, setProcessExitCode, } from "./signalHandler.keepAlive.js";
|
|
1
3
|
/** Default grace period for a fatal-error shutdown. */
|
|
2
4
|
const DEFAULT_FATAL_EXIT_TIMEOUT = 10_000;
|
|
3
5
|
/**
|
|
@@ -10,6 +12,7 @@ export class SignalHandler {
|
|
|
10
12
|
isShuttingDown = false;
|
|
11
13
|
registered = false;
|
|
12
14
|
forcedExitTimer = null;
|
|
15
|
+
flushedPendingSignals = false;
|
|
13
16
|
constructor(logger, options) {
|
|
14
17
|
this.logger = logger;
|
|
15
18
|
this.options = options;
|
|
@@ -28,8 +31,10 @@ export class SignalHandler {
|
|
|
28
31
|
this.registered = true;
|
|
29
32
|
this.isShuttingDown = false;
|
|
30
33
|
if (this.options.handleSignals) {
|
|
34
|
+
this.flushedPendingSignals = false;
|
|
31
35
|
process.on("SIGTERM", this.handleTermination);
|
|
32
36
|
process.on("SIGINT", this.handleInterruption);
|
|
37
|
+
process.on("beforeExit", this.handleBeforeExit);
|
|
33
38
|
}
|
|
34
39
|
if (this.options.handleFatalErrors) {
|
|
35
40
|
process.on("uncaughtException", this.handleUncaughtException);
|
|
@@ -54,6 +59,7 @@ export class SignalHandler {
|
|
|
54
59
|
if (this.options.handleSignals) {
|
|
55
60
|
process.off("SIGTERM", this.handleTermination);
|
|
56
61
|
process.off("SIGINT", this.handleInterruption);
|
|
62
|
+
process.off("beforeExit", this.handleBeforeExit);
|
|
57
63
|
}
|
|
58
64
|
if (this.options.handleFatalErrors) {
|
|
59
65
|
process.off("uncaughtException", this.handleUncaughtException);
|
|
@@ -77,6 +83,16 @@ export class SignalHandler {
|
|
|
77
83
|
this.logger.info("Received SIGINT signal.");
|
|
78
84
|
this.initiateShutdown("SIGINT");
|
|
79
85
|
};
|
|
86
|
+
/**
|
|
87
|
+
* Gives a signal delivered just before the loop drained one turn to be
|
|
88
|
+
* dispatched, once per registration so an idle process still exits.
|
|
89
|
+
*/
|
|
90
|
+
handleBeforeExit = () => {
|
|
91
|
+
if (this.flushedPendingSignals || this.isShuttingDown)
|
|
92
|
+
return;
|
|
93
|
+
this.flushedPendingSignals = true;
|
|
94
|
+
flushPendingSignals();
|
|
95
|
+
};
|
|
80
96
|
/**
|
|
81
97
|
* Handles uncaught exceptions.
|
|
82
98
|
*
|
|
@@ -131,6 +147,9 @@ export class SignalHandler {
|
|
|
131
147
|
*
|
|
132
148
|
* A second termination signal exits immediately: an operator pressing
|
|
133
149
|
* Ctrl-C again on a stuck shutdown is asking for exactly that.
|
|
150
|
+
*
|
|
151
|
+
* The event loop is held open until the shutdown settles, and a failed
|
|
152
|
+
* shutdown sets a non-zero exit code.
|
|
134
153
|
*/
|
|
135
154
|
initiateShutdown(source) {
|
|
136
155
|
if (this.isShuttingDown) {
|
|
@@ -144,12 +163,19 @@ export class SignalHandler {
|
|
|
144
163
|
return;
|
|
145
164
|
}
|
|
146
165
|
this.isShuttingDown = true;
|
|
147
|
-
|
|
148
|
-
|
|
166
|
+
const handler = this.shutdownHandler;
|
|
167
|
+
if (handler) {
|
|
168
|
+
const release = holdEventLoop();
|
|
169
|
+
return new Promise((resolve) => resolve(handler()))
|
|
170
|
+
.catch((error) => {
|
|
171
|
+
const signalError = new RuntimeSignalError(source, { cause: error });
|
|
149
172
|
this.logger.error("Shutdown handler failed.", {
|
|
150
|
-
errorMessage:
|
|
173
|
+
errorMessage: signalError.message,
|
|
174
|
+
error: signalError,
|
|
151
175
|
});
|
|
152
|
-
|
|
176
|
+
(this.options.setExitCode ?? setProcessExitCode)(1);
|
|
177
|
+
})
|
|
178
|
+
.finally(release);
|
|
153
179
|
}
|
|
154
180
|
}
|
|
155
181
|
exit(code) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping the process alive while a signal-triggered shutdown runs.
|
|
3
|
+
*
|
|
4
|
+
* Node's signal listeners do not hold the event loop open. A SIGTERM
|
|
5
|
+
* handled while nothing else was keeping the loop alive — or whose
|
|
6
|
+
* shutdown closed the last open handle before an `onShutdown` hook had
|
|
7
|
+
* finished — let the process exit mid-shutdown with code 0, the runtime
|
|
8
|
+
* still `running` or `stopping`, and the remaining hooks never run.
|
|
9
|
+
*
|
|
10
|
+
* @module signalHandler/signalHandler.keepAlive
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Holds the event loop open until the returned release function is called.
|
|
14
|
+
*
|
|
15
|
+
* The handle is a ref'd interval that does nothing, so the process stays
|
|
16
|
+
* up exactly as long as the shutdown it guards.
|
|
17
|
+
*/
|
|
18
|
+
export declare function holdEventLoop(): () => void;
|
|
19
|
+
/**
|
|
20
|
+
* Runs one more event-loop turn so a signal that was already delivered,
|
|
21
|
+
* but not yet dispatched, reaches its listener.
|
|
22
|
+
*
|
|
23
|
+
* A process that signals itself (`process.kill(process.pid, "SIGTERM")`)
|
|
24
|
+
* with nothing else keeping the loop alive otherwise exits without ever
|
|
25
|
+
* polling for the signal, so the listener never runs.
|
|
26
|
+
*/
|
|
27
|
+
export declare function flushPendingSignals(): void;
|
|
28
|
+
/** Sets the exit code the process ends with once the loop drains. */
|
|
29
|
+
export declare function setProcessExitCode(code: number): void;
|
|
30
|
+
//# sourceMappingURL=signalHandler.keepAlive.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping the process alive while a signal-triggered shutdown runs.
|
|
3
|
+
*
|
|
4
|
+
* Node's signal listeners do not hold the event loop open. A SIGTERM
|
|
5
|
+
* handled while nothing else was keeping the loop alive — or whose
|
|
6
|
+
* shutdown closed the last open handle before an `onShutdown` hook had
|
|
7
|
+
* finished — let the process exit mid-shutdown with code 0, the runtime
|
|
8
|
+
* still `running` or `stopping`, and the remaining hooks never run.
|
|
9
|
+
*
|
|
10
|
+
* @module signalHandler/signalHandler.keepAlive
|
|
11
|
+
*/
|
|
12
|
+
/** Longest delay a Node timer accepts; the timer never fires in practice. */
|
|
13
|
+
const KEEP_ALIVE_INTERVAL = 2_147_483_647;
|
|
14
|
+
/**
|
|
15
|
+
* Holds the event loop open until the returned release function is called.
|
|
16
|
+
*
|
|
17
|
+
* The handle is a ref'd interval that does nothing, so the process stays
|
|
18
|
+
* up exactly as long as the shutdown it guards.
|
|
19
|
+
*/
|
|
20
|
+
export function holdEventLoop() {
|
|
21
|
+
const handle = setInterval(() => undefined, KEEP_ALIVE_INTERVAL);
|
|
22
|
+
let released = false;
|
|
23
|
+
return () => {
|
|
24
|
+
if (released)
|
|
25
|
+
return;
|
|
26
|
+
released = true;
|
|
27
|
+
clearInterval(handle);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Runs one more event-loop turn so a signal that was already delivered,
|
|
32
|
+
* but not yet dispatched, reaches its listener.
|
|
33
|
+
*
|
|
34
|
+
* A process that signals itself (`process.kill(process.pid, "SIGTERM")`)
|
|
35
|
+
* with nothing else keeping the loop alive otherwise exits without ever
|
|
36
|
+
* polling for the signal, so the listener never runs.
|
|
37
|
+
*/
|
|
38
|
+
export function flushPendingSignals() {
|
|
39
|
+
setImmediate(() => undefined);
|
|
40
|
+
}
|
|
41
|
+
/** Sets the exit code the process ends with once the loop drains. */
|
|
42
|
+
export function setProcessExitCode(code) {
|
|
43
|
+
process.exitCode = code;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=signalHandler.keepAlive.js.map
|
|
@@ -4,8 +4,11 @@ import { LifecycleManager } from "../lifecycle/index.js";
|
|
|
4
4
|
import type { LifecycleFailure } from "../lifecycle/lifecycle.type.js";
|
|
5
5
|
/**
|
|
6
6
|
* Executes the startup sequence.
|
|
7
|
+
*
|
|
8
|
+
* @param onInitialized - Called once every module has initialized and
|
|
9
|
+
* before any `onReady` hook runs.
|
|
7
10
|
*/
|
|
8
|
-
export declare function executeStartup(lifecycle: LifecycleManager, runtimeId: string, eventBus: EventBus | undefined, logger: Logger, emitEvents: boolean, startupTimeout?: number): Promise<void>;
|
|
11
|
+
export declare function executeStartup(lifecycle: LifecycleManager, runtimeId: string, eventBus: EventBus | undefined, logger: Logger, emitEvents: boolean, startupTimeout?: number, onInitialized?: () => void): Promise<void>;
|
|
9
12
|
/**
|
|
10
13
|
* Rolls back a failed startup.
|
|
11
14
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createEvent } from "@zudojs/events";
|
|
2
2
|
import { createFailureEventPayload, publishRuntimeEvent, } from "../runtimeEvents/index.js";
|
|
3
3
|
import { LifecycleManager } from "../lifecycle/index.js";
|
|
4
|
-
import { RuntimeStartError, RuntimeTimeoutError, } from "../runtimeError/index.js";
|
|
4
|
+
import { RuntimeInitializationError, RuntimeStartError, RuntimeTimeoutError, } from "../runtimeError/index.js";
|
|
5
5
|
/** Largest delay a timer can represent. */
|
|
6
6
|
const MAX_TIMER_DELAY = 2_147_483_647;
|
|
7
7
|
/**
|
|
@@ -40,14 +40,17 @@ async function withStartupTimeout(operation, timeoutMs, onTimeout) {
|
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
42
|
* Executes the startup sequence.
|
|
43
|
+
*
|
|
44
|
+
* @param onInitialized - Called once every module has initialized and
|
|
45
|
+
* before any `onReady` hook runs.
|
|
43
46
|
*/
|
|
44
|
-
export async function executeStartup(lifecycle, runtimeId, eventBus, logger, emitEvents, startupTimeout = 0) {
|
|
45
|
-
return withStartupTimeout(runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents), startupTimeout, () => lifecycle.cancel());
|
|
47
|
+
export async function executeStartup(lifecycle, runtimeId, eventBus, logger, emitEvents, startupTimeout = 0, onInitialized) {
|
|
48
|
+
return withStartupTimeout(runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents, onInitialized), startupTimeout, () => lifecycle.cancel());
|
|
46
49
|
}
|
|
47
50
|
/**
|
|
48
51
|
* Runs the startup sequence.
|
|
49
52
|
*/
|
|
50
|
-
async function runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents) {
|
|
53
|
+
async function runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents, onInitialized) {
|
|
51
54
|
// Per-module `runtime.module.*` events are emitted by the lifecycle
|
|
52
55
|
// manager, which is the only layer that knows which module is running.
|
|
53
56
|
const initResult = await lifecycle.initialize();
|
|
@@ -62,8 +65,7 @@ async function runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents) {
|
|
|
62
65
|
payload: createFailureEventPayload(runtimeId, "initialization_failed", failure.error, "initialize", failure.moduleId),
|
|
63
66
|
}));
|
|
64
67
|
}
|
|
65
|
-
throw new
|
|
66
|
-
phase: "initialize",
|
|
68
|
+
throw new RuntimeInitializationError(`Module "${failure.moduleId}" failed during initialization.`, {
|
|
67
69
|
failedModuleId: failure.moduleId,
|
|
68
70
|
cause: failure.error,
|
|
69
71
|
});
|
|
@@ -72,6 +74,10 @@ async function runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents) {
|
|
|
72
74
|
modules: initResult.succeeded,
|
|
73
75
|
durationMs: initResult.durationMs,
|
|
74
76
|
});
|
|
77
|
+
// Lets the runtime enter "initialized" and "starting" between the two
|
|
78
|
+
// phases, so onReady hooks observe "starting" rather than
|
|
79
|
+
// "initializing".
|
|
80
|
+
onInitialized?.();
|
|
75
81
|
const startResult = await lifecycle.start();
|
|
76
82
|
if (lifecycle.cancelled) {
|
|
77
83
|
return;
|
|
@@ -10,6 +10,9 @@ import type { RuntimeOptions } from "../runtimeOptions/runtimeOptions.type.js";
|
|
|
10
10
|
/**
|
|
11
11
|
* Creates a test runtime with mock infrastructure.
|
|
12
12
|
*
|
|
13
|
+
* The runtime owns the container it creates, so `stop()` disposes it
|
|
14
|
+
* (`disposeContainerOnStop: true`; pass `false` to keep it).
|
|
15
|
+
*
|
|
13
16
|
* @param modules - Optional modules to register.
|
|
14
17
|
* @param options - Optional runtime options overrides.
|
|
15
18
|
* @returns A runtime instance ready for testing.
|
|
@@ -11,6 +11,9 @@ import { DefaultRuntime } from "../runtime/runtime.core.js";
|
|
|
11
11
|
/**
|
|
12
12
|
* Creates a test runtime with mock infrastructure.
|
|
13
13
|
*
|
|
14
|
+
* The runtime owns the container it creates, so `stop()` disposes it
|
|
15
|
+
* (`disposeContainerOnStop: true`; pass `false` to keep it).
|
|
16
|
+
*
|
|
14
17
|
* @param modules - Optional modules to register.
|
|
15
18
|
* @param options - Optional runtime options overrides.
|
|
16
19
|
* @returns A runtime instance ready for testing.
|
|
@@ -38,6 +41,8 @@ export function createTestRuntime(modules = [], options = {}) {
|
|
|
38
41
|
shutdownTimeout: 5000,
|
|
39
42
|
startupTimeout: 10000,
|
|
40
43
|
emitEvents: false,
|
|
44
|
+
// The test runtime created this container, so it owns and releases it.
|
|
45
|
+
disposeContainerOnStop: true,
|
|
41
46
|
...options,
|
|
42
47
|
};
|
|
43
48
|
return new DefaultRuntime(dependencies, runtimeOptions);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -22,17 +22,17 @@
|
|
|
22
22
|
"!dist/.tsbuildinfo"
|
|
23
23
|
],
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@types/node": "^26.
|
|
25
|
+
"@types/node": "^26.6.2",
|
|
26
26
|
"typescript": "7.0.2",
|
|
27
|
-
"vitest": "^
|
|
27
|
+
"vitest": "^5.0.1"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@zudojs/constants": "1.1.
|
|
31
|
-
"@zudojs/container": "1.1
|
|
32
|
-
"@zudojs/core": "1.2.
|
|
33
|
-
"@zudojs/errors": "1.
|
|
34
|
-
"@zudojs/events": "1.
|
|
35
|
-
"@zudojs/logger": "1.
|
|
30
|
+
"@zudojs/constants": "1.1.2",
|
|
31
|
+
"@zudojs/container": "1.2.1",
|
|
32
|
+
"@zudojs/core": "1.2.2",
|
|
33
|
+
"@zudojs/errors": "1.3.0",
|
|
34
|
+
"@zudojs/events": "1.3.1",
|
|
35
|
+
"@zudojs/logger": "1.4.1"
|
|
36
36
|
},
|
|
37
37
|
"license": "MIT",
|
|
38
38
|
"author": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"orchestrator",
|
|
53
53
|
"services"
|
|
54
54
|
],
|
|
55
|
-
"homepage": "https://
|
|
55
|
+
"homepage": "https://zudojs.oyinlola.site/docs/packages-runtime",
|
|
56
56
|
"bugs": {
|
|
57
57
|
"url": "https://github.com/oyinlola-tech/zudo/issues"
|
|
58
58
|
},
|