@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 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
- if (this.options.emitEvents) {
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
- try {
413
- await this.stop();
414
- }
415
- catch (error) {
416
- this.logger.error("Shutdown failed.", {
417
- errorMessage: error instanceof Error ? error.message : String(error),
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 = "runtime.initializing" | "runtime.running" | "runtime.stopping" | "runtime.stopped" | "runtime.failed" | "runtime.module.initializing" | "runtime.module.initialized" | "runtime.module.starting" | "runtime.module.started" | "runtime.module.stopping" | "runtime.module.stopped" | "runtime.module.failed" | "runtime.shutdown.drain" | "runtime.shutdown.complete" | "runtime.health.changed" | "runtime.readiness.changed";
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 = "runtime.module.initializing" | "runtime.module.initialized" | "runtime.module.starting" | "runtime.module.started" | "runtime.module.stopping" | "runtime.module.stopped" | "runtime.module.failed";
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
- if (this.shutdownHandler) {
149
- return Promise.resolve(this.shutdownHandler()).catch((error) => {
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.0",
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.0",
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.0",
35
- "@zudojs/logger": "1.4.0"
34
+ "@zudojs/events": "1.3.1",
35
+ "@zudojs/logger": "1.4.1"
36
36
  },
37
37
  "license": "MIT",
38
38
  "author": {