@zudojs/runtime 1.3.0 → 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 +30 -0
- package/dist/runtime/runtime.core.d.ts +4 -0
- package/dist/runtime/runtime.core.js +26 -9
- package/dist/runtimeEvents/runtimeEvents.type.d.ts +4 -3
- package/dist/signalHandler/signalHandler.core.d.ts +15 -0
- package/dist/signalHandler/signalHandler.core.js +26 -3
- package/dist/signalHandler/signalHandler.keepAlive.d.ts +30 -0
- package/dist/signalHandler/signalHandler.keepAlive.js +45 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -86,6 +86,36 @@ to reject; if it fails, the runtime logs a `RuntimeSignalError` (with the
|
|
|
86
86
|
failure as `cause`) as the `error` field of its "Shutdown handler
|
|
87
87
|
failed." entry.
|
|
88
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
|
+
|
|
89
119
|
```typescript
|
|
90
120
|
import { RuntimeStartError } from "@zudojs/runtime";
|
|
91
121
|
|
|
@@ -153,6 +153,10 @@ export declare class DefaultRuntime implements Runtime {
|
|
|
153
153
|
private performStop;
|
|
154
154
|
/**
|
|
155
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.
|
|
156
160
|
*/
|
|
157
161
|
private handleShutdownSignal;
|
|
158
162
|
/**
|
|
@@ -10,7 +10,7 @@ import { ReadinessTracker } from "../readiness/index.js";
|
|
|
10
10
|
import { computeRuntimeHealth } from "../health/index.js";
|
|
11
11
|
import { createRuntimeEventPayload, createFailureEventPayload, createHealthEventPayload, createReadinessEventPayload, publishRuntimeEvent, } from "../runtimeEvents/index.js";
|
|
12
12
|
import { createEvent } from "@zudojs/events";
|
|
13
|
-
import { RuntimeRollbackError, RuntimeStateError, toRuntimeError, } from "../runtimeError/index.js";
|
|
13
|
+
import { RuntimeRollbackError, RuntimeStartError, RuntimeStateError, RuntimeStopError, toRuntimeError, } from "../runtimeError/index.js";
|
|
14
14
|
/**
|
|
15
15
|
* Default runtime implementation.
|
|
16
16
|
*/
|
|
@@ -287,7 +287,11 @@ export class DefaultRuntime {
|
|
|
287
287
|
this.logger.error("Runtime failed to start.", {
|
|
288
288
|
errorMessage: runtimeError.message,
|
|
289
289
|
});
|
|
290
|
-
|
|
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) {
|
|
291
295
|
this.emitEvent("runtime.failed", createFailureEventPayload(this.options.runtimeId, "failed", runtimeError, "startup"));
|
|
292
296
|
}
|
|
293
297
|
let rollbackError;
|
|
@@ -359,8 +363,13 @@ export class DefaultRuntime {
|
|
|
359
363
|
if (this.options.emitEvents) {
|
|
360
364
|
this.emitEvent("runtime.stopping");
|
|
361
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;
|
|
362
370
|
try {
|
|
363
371
|
const result = await executeShutdown(this.lifecycle, this.options.runtimeId, this._contextBase.eventBus, this.logger, this.options.shutdownTimeout, this.options.emitEvents);
|
|
372
|
+
shutdownSettled = true;
|
|
364
373
|
const containerFailure = await this.releaseContainer();
|
|
365
374
|
this._shutdownFailures =
|
|
366
375
|
containerFailure === undefined
|
|
@@ -392,7 +401,7 @@ export class DefaultRuntime {
|
|
|
392
401
|
this.logger.error("Runtime failed to stop.", {
|
|
393
402
|
errorMessage: runtimeError.message,
|
|
394
403
|
});
|
|
395
|
-
if (this.options.emitEvents) {
|
|
404
|
+
if (this.options.emitEvents && shutdownSettled) {
|
|
396
405
|
this.emitEvent("runtime.failed", createFailureEventPayload(this.options.runtimeId, "failed", runtimeError, "stop"));
|
|
397
406
|
}
|
|
398
407
|
this.transitionTo("failed");
|
|
@@ -407,14 +416,22 @@ export class DefaultRuntime {
|
|
|
407
416
|
}
|
|
408
417
|
/**
|
|
409
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.
|
|
410
423
|
*/
|
|
411
424
|
async handleShutdownSignal() {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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)),
|
|
418
435
|
});
|
|
419
436
|
}
|
|
420
437
|
}
|
|
@@ -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,7 +61,7 @@ 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
|
*
|
|
@@ -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,4 +1,5 @@
|
|
|
1
1
|
import { RuntimeSignalError } from "../runtimeError/runtimeError.base.js";
|
|
2
|
+
import { flushPendingSignals, holdEventLoop, setProcessExitCode, } from "./signalHandler.keepAlive.js";
|
|
2
3
|
/** Default grace period for a fatal-error shutdown. */
|
|
3
4
|
const DEFAULT_FATAL_EXIT_TIMEOUT = 10_000;
|
|
4
5
|
/**
|
|
@@ -11,6 +12,7 @@ export class SignalHandler {
|
|
|
11
12
|
isShuttingDown = false;
|
|
12
13
|
registered = false;
|
|
13
14
|
forcedExitTimer = null;
|
|
15
|
+
flushedPendingSignals = false;
|
|
14
16
|
constructor(logger, options) {
|
|
15
17
|
this.logger = logger;
|
|
16
18
|
this.options = options;
|
|
@@ -29,8 +31,10 @@ export class SignalHandler {
|
|
|
29
31
|
this.registered = true;
|
|
30
32
|
this.isShuttingDown = false;
|
|
31
33
|
if (this.options.handleSignals) {
|
|
34
|
+
this.flushedPendingSignals = false;
|
|
32
35
|
process.on("SIGTERM", this.handleTermination);
|
|
33
36
|
process.on("SIGINT", this.handleInterruption);
|
|
37
|
+
process.on("beforeExit", this.handleBeforeExit);
|
|
34
38
|
}
|
|
35
39
|
if (this.options.handleFatalErrors) {
|
|
36
40
|
process.on("uncaughtException", this.handleUncaughtException);
|
|
@@ -55,6 +59,7 @@ export class SignalHandler {
|
|
|
55
59
|
if (this.options.handleSignals) {
|
|
56
60
|
process.off("SIGTERM", this.handleTermination);
|
|
57
61
|
process.off("SIGINT", this.handleInterruption);
|
|
62
|
+
process.off("beforeExit", this.handleBeforeExit);
|
|
58
63
|
}
|
|
59
64
|
if (this.options.handleFatalErrors) {
|
|
60
65
|
process.off("uncaughtException", this.handleUncaughtException);
|
|
@@ -78,6 +83,16 @@ export class SignalHandler {
|
|
|
78
83
|
this.logger.info("Received SIGINT signal.");
|
|
79
84
|
this.initiateShutdown("SIGINT");
|
|
80
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
|
+
};
|
|
81
96
|
/**
|
|
82
97
|
* Handles uncaught exceptions.
|
|
83
98
|
*
|
|
@@ -132,6 +147,9 @@ export class SignalHandler {
|
|
|
132
147
|
*
|
|
133
148
|
* A second termination signal exits immediately: an operator pressing
|
|
134
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.
|
|
135
153
|
*/
|
|
136
154
|
initiateShutdown(source) {
|
|
137
155
|
if (this.isShuttingDown) {
|
|
@@ -145,14 +163,19 @@ export class SignalHandler {
|
|
|
145
163
|
return;
|
|
146
164
|
}
|
|
147
165
|
this.isShuttingDown = true;
|
|
148
|
-
|
|
149
|
-
|
|
166
|
+
const handler = this.shutdownHandler;
|
|
167
|
+
if (handler) {
|
|
168
|
+
const release = holdEventLoop();
|
|
169
|
+
return new Promise((resolve) => resolve(handler()))
|
|
170
|
+
.catch((error) => {
|
|
150
171
|
const signalError = new RuntimeSignalError(source, { cause: error });
|
|
151
172
|
this.logger.error("Shutdown handler failed.", {
|
|
152
173
|
errorMessage: signalError.message,
|
|
153
174
|
error: signalError,
|
|
154
175
|
});
|
|
155
|
-
|
|
176
|
+
(this.options.setExitCode ?? setProcessExitCode)(1);
|
|
177
|
+
})
|
|
178
|
+
.finally(release);
|
|
156
179
|
}
|
|
157
180
|
}
|
|
158
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/runtime",
|
|
3
|
-
"version": "1.3.
|
|
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",
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@zudojs/constants": "1.1.2",
|
|
31
|
-
"@zudojs/container": "1.2.
|
|
31
|
+
"@zudojs/container": "1.2.1",
|
|
32
32
|
"@zudojs/core": "1.2.2",
|
|
33
33
|
"@zudojs/errors": "1.3.0",
|
|
34
|
-
"@zudojs/events": "1.3.
|
|
35
|
-
"@zudojs/logger": "1.4.
|
|
34
|
+
"@zudojs/events": "1.3.1",
|
|
35
|
+
"@zudojs/logger": "1.4.1"
|
|
36
36
|
},
|
|
37
37
|
"license": "MIT",
|
|
38
38
|
"author": {
|