@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 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 RuntimeStartError("Configuration failed to load.", {
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
- 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) {
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 (rollbackError) {
312
+ catch (caught) {
313
+ rollbackError =
314
+ caught instanceof Error ? caught : new Error(String(caught));
297
315
  this.logger.error("Rollback failed.", {
298
- errorMessage: rollbackError instanceof Error
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 runtimeError;
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
- this._shutdownFailures = result.failures;
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 (result.failures.length > 0) {
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: result.failures.map((failure) => failure.moduleId),
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
- try {
366
- await this.stop();
367
- }
368
- catch (error) {
369
- this.logger.error("Shutdown failed.", {
370
- 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)),
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 runtime initialization fails.
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 RuntimeError {
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 runtime rollback fails.
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 RuntimeError {
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
- * Error thrown when runtime receives multiple signals.
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 runtime initialization fails.
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 RuntimeError {
52
+ export class RuntimeInitializationError extends RuntimeStartError {
47
53
  constructor(message, options = {}) {
48
54
  super(message, {
49
- cause: options.cause,
50
- metadata: {
51
- phase: "initialization",
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 runtime rollback fails.
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 RuntimeError {
91
+ export class RuntimeRollbackError extends RuntimeStartError {
77
92
  originalError;
78
93
  rollbackError;
79
94
  constructor(originalError, rollbackError) {
80
- super("Runtime rollback failed. Original error suppressed.", {
81
- cause: rollbackError,
82
- metadata: {
83
- originalErrorMessage: originalError.message,
84
- rollbackErrorMessage: rollbackError.message,
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
- * Error thrown when runtime receives multiple signals.
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
- super(`Runtime received unexpected signal "${signal}".`, {
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 = "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,17 +61,19 @@ 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
  *
67
- * Every entry here is emitted by the runtime. Entries for events nothing
68
- * ever published (`runtime.created`, `runtime.initialized`,
69
- * `runtime.starting`) were removed rather than left as names a consumer
70
- * could subscribe to and never hear from.
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
- initializing: ["initialized", "running", "failed"],
8
- initialized: ["starting", "running", "failed"],
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);
@@ -2,4 +2,5 @@
2
2
  * Runtime shutdown sequence.
3
3
  */
4
4
  export { executeShutdown } from "./shutdown.core.js";
5
+ export { CONTAINER_SHUTDOWN_ID } from "./shutdown.container.js";
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -2,4 +2,5 @@
2
2
  * Runtime shutdown sequence.
3
3
  */
4
4
  export { executeShutdown } from "./shutdown.core.js";
5
+ export { CONTAINER_SHUTDOWN_ID } from "./shutdown.container.js";
5
6
  //# sourceMappingURL=index.js.map
@@ -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
- if (this.shutdownHandler) {
148
- 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) => {
171
+ const signalError = new RuntimeSignalError(source, { cause: error });
149
172
  this.logger.error("Shutdown handler failed.", {
150
- errorMessage: error instanceof Error ? error.message : String(error),
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 RuntimeStartError(`Module "${failure.moduleId}" failed during initialization.`, {
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.2.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.4.1",
25
+ "@types/node": "^26.6.2",
26
26
  "typescript": "7.0.2",
27
- "vitest": "^4.1.11"
27
+ "vitest": "^5.0.1"
28
28
  },
29
29
  "dependencies": {
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"
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://github.com/oyinlola-tech/zudo#readme",
55
+ "homepage": "https://zudojs.oyinlola.site/docs/packages-runtime",
56
56
  "bugs": {
57
57
  "url": "https://github.com/oyinlola-tech/zudo/issues"
58
58
  },