@zudojs/runtime 1.2.0 → 1.3.0

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,86 @@ 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
+ ```typescript
90
+ import { RuntimeStartError } from "@zudojs/runtime";
91
+
92
+ try {
93
+ await runtime.start();
94
+ } catch (error) {
95
+ if (error instanceof RuntimeStartError) {
96
+ const original = error.cause; // what the module threw
97
+ logger.error(`Module ${error.failedModuleId} failed in ${error.phase}`, {
98
+ original,
99
+ });
100
+ }
101
+ await runtime.stop(); // release anything rollback did not reach
102
+ }
103
+ ```
104
+
105
+ A startup that outlives `startupTimeout` rejects with `RuntimeTimeoutError`,
106
+ which has no cause.
107
+
108
+ ## Container ownership
109
+
110
+ The runtime does not create the container; you pass it in, so by default
111
+ you own it and `stop()` leaves it alone. Dispose it yourself after
112
+ `stop()`:
113
+
114
+ ```typescript
115
+ await runtime.stop();
116
+ await container.dispose();
117
+ ```
118
+
119
+ or hand ownership to the runtime with `disposeContainerOnStop: true`, and
120
+ `stop()` disposes it after every module has shut down and been destroyed.
121
+ A disposal failure does not fail `stop()`; it is logged and recorded in
122
+ `runtime.status.shutdownFailures` under the id `"(container)"`
123
+ (exported as `CONTAINER_SHUTDOWN_ID`).
124
+ `createTestRuntime` (from `@zudojs/runtime/testing`) creates its own
125
+ container and sets `disposeContainerOnStop: true`.
126
+
47
127
  ## Readiness and health
48
128
 
49
129
  Readiness checks are registered on the runtime and re-evaluated on demand.
@@ -104,7 +184,7 @@ Subscribe with `bus.on(type, handler)`; the payload types are in
104
184
 
105
185
  | Event | Payload |
106
186
  | --- | --- |
107
- | `runtime.initializing`, `runtime.running`, `runtime.stopping`, `runtime.stopped` | `RuntimeEventPayload` |
187
+ | `runtime.initializing`, `runtime.initialized`, `runtime.starting`, `runtime.running`, `runtime.stopping`, `runtime.stopped` | `RuntimeEventPayload` |
108
188
  | `runtime.failed` | `RuntimeFailureEventPayload` |
109
189
  | `runtime.module.initializing` / `initialized` / `starting` / `started` / `stopping` / `stopped` / `failed` | `RuntimeModuleEventPayload` |
110
190
  | `runtime.shutdown.drain`, `runtime.shutdown.complete` | `RuntimeEventPayload` |
@@ -142,6 +222,7 @@ createRuntime(dependencies, {
142
222
  trackHealth: true,
143
223
  readinessCheckTimeout: 5_000, // 0 removes the bound
144
224
  parallelInitialization: false, // initialize each depth group at once
225
+ disposeContainerOnStop: false, // true: stop() disposes the container
145
226
  metadata: { region: "eu-west-1" },
146
227
  });
147
228
  ```
@@ -50,6 +50,15 @@ export declare class LifecycleManager {
50
50
  * Initializes all modules in dependency order.
51
51
  */
52
52
  initialize(): Promise<LifecycleResult>;
53
+ /**
54
+ * Returns the first declared dependency of `moduleId` that is known to
55
+ * have failed or been skipped, or `undefined` when none has.
56
+ *
57
+ * Only direct dependencies are inspected: `blocked` already contains
58
+ * every module skipped by an earlier group, and groups are visited in
59
+ * dependency order, so the cascade is transitive.
60
+ */
61
+ private findBlockingDependency;
53
62
  /**
54
63
  * Initializes a single module, converting a throw into a failure.
55
64
  */
@@ -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
  }
@@ -116,14 +115,44 @@ export class LifecycleManager {
116
115
  const groups = this.options.parallelInitialization
117
116
  ? depGraph.parallelGroups
118
117
  : depGraph.order.map((moduleId) => [moduleId]);
118
+ // Ids that must not have dependents initialized on top of them:
119
+ // modules whose own hook threw, plus modules already skipped for the
120
+ // same reason, so the skip cascades transitively. `continueOnFailure`
121
+ // used to consult only the failure COUNT, so a dependent of a broken
122
+ // module was initialized, readied, and reported as started — an API
123
+ // serving traffic against a database that never came up.
124
+ const blocked = new Set();
119
125
  for (const group of groups) {
120
126
  if (this.cancellation.isCancelled) {
121
127
  break;
122
128
  }
123
- const results = await Promise.all(group.map((moduleId) => this.initializeModule(moduleId)));
129
+ const runnable = [];
130
+ for (const moduleId of group) {
131
+ const blocker = this.findBlockingDependency(moduleId, blocked);
132
+ if (blocker === undefined) {
133
+ runnable.push(moduleId);
134
+ continue;
135
+ }
136
+ const failure = {
137
+ moduleId,
138
+ phase: "initialize",
139
+ error: new RuntimeStartError(`Module "${moduleId}" was not initialized because its ` +
140
+ `dependency "${blocker}" failed.`, { phase: "initialize", failedModuleId: blocker }),
141
+ durationMs: 0,
142
+ };
143
+ failed.push(failure);
144
+ blocked.add(moduleId);
145
+ this.logger.error(`Module "${moduleId}" was skipped because its dependency "${blocker}" failed.`);
146
+ this.emitModuleEvent("runtime.module.failed", moduleId, "failed", {
147
+ durationMs: 0,
148
+ error: failure.error,
149
+ });
150
+ }
151
+ const results = await Promise.all(runnable.map((moduleId) => this.initializeModule(moduleId)));
124
152
  for (const result of results) {
125
153
  if (result.failure) {
126
154
  failed.push(result.failure);
155
+ blocked.add(result.moduleId);
127
156
  }
128
157
  else if (!result.abandoned) {
129
158
  succeeded.push(result.moduleId);
@@ -140,6 +169,18 @@ export class LifecycleManager {
140
169
  durationMs: Date.now() - startTime,
141
170
  });
142
171
  }
172
+ /**
173
+ * Returns the first declared dependency of `moduleId` that is known to
174
+ * have failed or been skipped, or `undefined` when none has.
175
+ *
176
+ * Only direct dependencies are inspected: `blocked` already contains
177
+ * every module skipped by an earlier group, and groups are visited in
178
+ * dependency order, so the cascade is transitive.
179
+ */
180
+ findBlockingDependency(moduleId, blocked) {
181
+ const dependencies = this.modules.get(moduleId)?.dependencies ?? [];
182
+ return dependencies.find((dependency) => blocked.has(dependency));
183
+ }
143
184
  /**
144
185
  * Initializes a single module, converting a throw into a failure.
145
186
  */
@@ -79,6 +79,14 @@ export interface LifecycleManagerOptions {
79
79
  * closing the same resources.
80
80
  */
81
81
  readonly shutdownTimeout?: number;
82
+ /**
83
+ * Keeps initializing and starting after a module fails.
84
+ *
85
+ * Only modules INDEPENDENT of the failure continue: a module that
86
+ * declares a failed (or already skipped) module in `dependencies` is
87
+ * never initialized or readied, and is reported in `failed` with the
88
+ * blocking dependency named. Defaults to false.
89
+ */
82
90
  readonly continueOnFailure?: boolean;
83
91
  /**
84
92
  * Receives one event per module per lifecycle phase.
@@ -4,6 +4,7 @@
4
4
  * Manages multiple runtime instances for scenarios where Zudojs
5
5
  * runs multiple applications or workers in a single process.
6
6
  */
7
+ import { RuntimeStateError } from "../runtimeError/index.js";
7
8
  /**
8
9
  * Runtime registry for managing multiple runtime instances.
9
10
  *
@@ -19,7 +20,7 @@ export class RuntimeRegistry {
19
20
  */
20
21
  register(id, runtime) {
21
22
  if (this.runtimes.has(id)) {
22
- throw new Error(`Runtime "${id}" is already registered.`);
23
+ throw new RuntimeStateError(`Runtime "${id}" is already registered.`);
23
24
  }
24
25
  this.runtimes.set(id, runtime);
25
26
  }
@@ -56,7 +57,7 @@ export class RuntimeRegistry {
56
57
  require(id) {
57
58
  const runtime = this.runtimes.get(id);
58
59
  if (!runtime) {
59
- throw new Error(`Runtime "${id}" not found.`);
60
+ throw new RuntimeStateError(`Runtime "${id}" not found.`);
60
61
  }
61
62
  return runtime;
62
63
  }
@@ -137,6 +137,16 @@ 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
  */
@@ -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, RuntimeStateError, 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
@@ -285,27 +290,65 @@ export class DefaultRuntime {
285
290
  if (this.options.emitEvents) {
286
291
  this.emitEvent("runtime.failed", createFailureEventPayload(this.options.runtimeId, "failed", runtimeError, "startup"));
287
292
  }
293
+ let rollbackError;
288
294
  try {
289
295
  const rollbackFailures = await rollbackStartup(this.lifecycle, this.logger);
290
296
  if (rollbackFailures.length > 0) {
291
297
  this.logger.error("Rollback completed with failures.", {
292
298
  failedModules: rollbackFailures.map((failure) => failure.moduleId),
293
299
  });
300
+ rollbackError =
301
+ rollbackFailures.length === 1
302
+ ? rollbackFailures[0].error
303
+ : new AggregateError(rollbackFailures.map((failure) => failure.error), `${rollbackFailures.length} modules failed to roll back: ` +
304
+ rollbackFailures.map((f) => f.moduleId).join(", ") +
305
+ ".");
294
306
  }
295
307
  }
296
- catch (rollbackError) {
308
+ catch (caught) {
309
+ rollbackError =
310
+ caught instanceof Error ? caught : new Error(String(caught));
297
311
  this.logger.error("Rollback failed.", {
298
- errorMessage: rollbackError instanceof Error
299
- ? rollbackError.message
300
- : String(rollbackError),
312
+ errorMessage: rollbackError.message,
301
313
  });
302
314
  }
315
+ // A rollback that failed leaves resources behind; say so rather than
316
+ // reporting only the startup failure. The startup failure stays
317
+ // reachable as `originalError`, and `cause` is unchanged.
318
+ const thrown = rollbackError === undefined
319
+ ? runtimeError
320
+ : new RuntimeRollbackError(runtimeError, rollbackError);
321
+ this._error = thrown;
303
322
  this.transitionTo("failed");
304
323
  // A rolled-back runtime owns nothing, so it must not keep owning the
305
324
  // process's signals and fatal-error handlers either.
306
325
  this.signalHandler.unregister();
307
- throw runtimeError;
326
+ throw thrown;
327
+ }
328
+ }
329
+ /**
330
+ * Walks `initializing` -> `initialized` -> `starting` once every module
331
+ * has initialized, before the first `onReady` hook runs.
332
+ */
333
+ enterStartingPhase() {
334
+ this.transitionTo("initialized");
335
+ if (this.options.emitEvents) {
336
+ this.emitEvent("runtime.initialized");
337
+ }
338
+ this.transitionTo("starting");
339
+ if (this.options.emitEvents) {
340
+ this.emitEvent("runtime.starting");
341
+ }
342
+ }
343
+ /**
344
+ * Disposes the container when the runtime owns it
345
+ * (`disposeContainerOnStop`). Returns the failure to record, if any.
346
+ */
347
+ async releaseContainer() {
348
+ if (!this.options.disposeContainerOnStop) {
349
+ return undefined;
308
350
  }
351
+ return disposeRuntimeContainer(this._contextBase.container, this.logger);
309
352
  }
310
353
  /**
311
354
  * Performs runtime shutdown.
@@ -318,18 +361,22 @@ export class DefaultRuntime {
318
361
  }
319
362
  try {
320
363
  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;
364
+ const containerFailure = await this.releaseContainer();
365
+ this._shutdownFailures =
366
+ containerFailure === undefined
367
+ ? result.failures
368
+ : [...result.failures, containerFailure];
322
369
  this.transitionTo("stopped");
323
370
  this._stoppedAt = new Date();
324
371
  if (this.options.emitEvents) {
325
372
  this.emitEvent("runtime.stopped");
326
373
  }
327
- if (result.failures.length > 0) {
374
+ if (this._shutdownFailures.length > 0) {
328
375
  // A teardown that dropped modules on the floor must not read as
329
376
  // a clean stop; `status.shutdownFailures` records what failed.
330
377
  this.logger.warn("Runtime stopped with module failures.", {
331
378
  runtimeId: this.options.runtimeId,
332
- failedModules: result.failures.map((failure) => failure.moduleId),
379
+ failedModules: this._shutdownFailures.map((failure) => failure.moduleId),
333
380
  });
334
381
  }
335
382
  else {
@@ -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
  },
@@ -64,13 +64,15 @@ export type RuntimeModuleEventType = "runtime.module.initializing" | "runtime.mo
64
64
  /**
65
65
  * Maps event types to their payload types.
66
66
  *
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.
67
+ * Every entry here is emitted by the runtime. `runtime.created` is not:
68
+ * nothing is subscribed before construction. `runtime.initialized` and
69
+ * `runtime.starting` are published between module initialization and the
70
+ * first `onReady` hook.
71
71
  */
72
72
  export interface RuntimeEventMap {
73
73
  "runtime.initializing": RuntimeEventPayload;
74
+ "runtime.initialized": RuntimeEventPayload;
75
+ "runtime.starting": RuntimeEventPayload;
74
76
  "runtime.running": RuntimeEventPayload;
75
77
  "runtime.stopping": RuntimeEventPayload;
76
78
  "runtime.stopped": RuntimeEventPayload;
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_RUNTIME_OPTIONS } from "./runtimeOptions.type.js";
2
2
  import { createRuntimeId } from "../runtimeContext/runtimeContext.factory.js";
3
+ import { RuntimeError } from "@zudojs/errors";
3
4
  /**
4
5
  * Resolves runtime options with defaults applied.
5
6
  *
@@ -24,22 +25,40 @@ export function resolveRuntimeOptions(options) {
24
25
  */
25
26
  export function validateRuntimeOptions(options) {
26
27
  if (!options.environment) {
27
- throw new Error("Runtime environment is required.");
28
+ throw new RuntimeError("Runtime environment is required.", {
29
+ metadata: { option: "environment" },
30
+ });
28
31
  }
29
32
  if (!options.applicationName) {
30
- throw new Error("Application name is required.");
33
+ throw new RuntimeError("Application name is required.", {
34
+ metadata: { option: "applicationName" },
35
+ });
31
36
  }
32
37
  if (options.shutdownTimeout <= 0) {
33
- throw new Error("Shutdown timeout must be positive.");
38
+ throw new RuntimeError("Shutdown timeout must be positive.", {
39
+ metadata: { option: "shutdownTimeout", value: options.shutdownTimeout },
40
+ });
34
41
  }
35
42
  if (options.startupTimeout <= 0) {
36
- throw new Error("Startup timeout must be positive.");
43
+ throw new RuntimeError("Startup timeout must be positive.", {
44
+ metadata: { option: "startupTimeout", value: options.startupTimeout },
45
+ });
37
46
  }
38
47
  if (!Number.isFinite(options.fatalExitTimeout) || options.fatalExitTimeout < 0) {
39
- throw new Error(`Fatal exit timeout must be a finite, non-negative number, got ${options.fatalExitTimeout}.`);
48
+ throw new RuntimeError(`Fatal exit timeout must be a finite, non-negative number, got ${options.fatalExitTimeout}.`, {
49
+ metadata: {
50
+ option: "fatalExitTimeout",
51
+ value: options.fatalExitTimeout,
52
+ },
53
+ });
40
54
  }
41
55
  if (options.readinessCheckTimeout < 0) {
42
- throw new Error(`Readiness check timeout must be zero or positive, got ${options.readinessCheckTimeout}. Use 0 to run checks without a bound.`);
56
+ throw new RuntimeError(`Readiness check timeout must be zero or positive, got ${options.readinessCheckTimeout}. Use 0 to run checks without a bound.`, {
57
+ metadata: {
58
+ option: "readinessCheckTimeout",
59
+ value: options.readinessCheckTimeout,
60
+ },
61
+ });
43
62
  }
44
63
  }
45
64
  /**
@@ -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
@@ -1,3 +1,4 @@
1
+ import { RuntimeSignalError } from "../runtimeError/runtimeError.base.js";
1
2
  /** Default grace period for a fatal-error shutdown. */
2
3
  const DEFAULT_FATAL_EXIT_TIMEOUT = 10_000;
3
4
  /**
@@ -146,8 +147,10 @@ export class SignalHandler {
146
147
  this.isShuttingDown = true;
147
148
  if (this.shutdownHandler) {
148
149
  return Promise.resolve(this.shutdownHandler()).catch((error) => {
150
+ const signalError = new RuntimeSignalError(source, { cause: error });
149
151
  this.logger.error("Shutdown handler failed.", {
150
- errorMessage: error instanceof Error ? error.message : String(error),
152
+ errorMessage: signalError.message,
153
+ error: signalError,
151
154
  });
152
155
  });
153
156
  }
@@ -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.0",
3
+ "version": "1.3.0",
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.0",
31
- "@zudojs/container": "1.1.1",
32
- "@zudojs/core": "1.2.0",
33
- "@zudojs/errors": "1.1.0",
34
- "@zudojs/events": "1.1.0",
35
- "@zudojs/logger": "1.2.0"
30
+ "@zudojs/constants": "1.1.2",
31
+ "@zudojs/container": "1.2.0",
32
+ "@zudojs/core": "1.2.2",
33
+ "@zudojs/errors": "1.3.0",
34
+ "@zudojs/events": "1.3.0",
35
+ "@zudojs/logger": "1.4.0"
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
  },