@zudojs/runtime 1.2.1 → 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
  ```
@@ -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,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;
@@ -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.1",
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.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.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
  },