@zudojs/runtime 1.1.0 → 1.2.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
@@ -2,6 +2,12 @@
2
2
 
3
3
  Application lifecycle orchestrator with dependency ordering, rollback, signals, and readiness checks.
4
4
 
5
+ <!-- zudo-docs:start -->
6
+
7
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-runtime](https://zudojs.oyinlola.site/docs/packages-runtime) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-runtime.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
8
+
9
+ <!-- zudo-docs:end -->
10
+
5
11
  ## Installation
6
12
 
7
13
  ```bash
@@ -126,6 +132,9 @@ createRuntime(dependencies, {
126
132
  runtimeId: "rt_custom", // generated when omitted
127
133
  handleSignals: true, // SIGTERM/SIGINT trigger a graceful stop
128
134
  handleFatalErrors: true, // uncaughtException/unhandledRejection
135
+ exitOnFatalError: true, // exit(1) once a fatal-error shutdown finishes
136
+ fatalExitTimeout: 10_000, // exit(1) anyway if that shutdown hangs
137
+ forceExitOnSecondSignal: true, // a second SIGTERM/SIGINT exits at once
129
138
  startupTimeout: 60_000,
130
139
  shutdownTimeout: 30_000,
131
140
  emitEvents: true,
@@ -137,6 +146,17 @@ createRuntime(dependencies, {
137
146
  });
138
147
  ```
139
148
 
149
+ `startupTimeout` abandons the startup: no further module hook starts,
150
+ and a module whose `onInitialize` or `onReady` finishes after the timeout
151
+ is shut down and destroyed as soon as it settles. `stop()` waits for that
152
+ teardown (bounded by `shutdownTimeout`), so a stopped runtime never has a
153
+ live module behind it. A second `stop()` after a shutdown timeout joins
154
+ the teardown still running rather than calling `onShutdown` again.
155
+
156
+ A configuration manager that fails to load fails startup with a
157
+ `RuntimeStartError` (`phase: "initialize"`) instead of letting modules
158
+ initialize against partial configuration.
159
+
140
160
  `parallelInitialization` initializes modules that share a dependency depth
141
161
  concurrently. They do not depend on one another by construction, but
142
162
  enabling it surfaces any ordering a module assumed without declaring, so
@@ -0,0 +1,41 @@
1
+ import type { Logger } from "@zudojs/logger";
2
+ import type { Module, ModuleContext } from "@zudojs/core";
3
+ /**
4
+ * Tracks module hooks that are still running after their startup was
5
+ * abandoned (by a startup timeout or a rollback).
6
+ *
7
+ * `Promise.race` cannot cancel the losing side, so a slow `onInitialize`
8
+ * or `onReady` keeps running after the runtime has already reported a
9
+ * failed start. Without this bookkeeping the late module came up with no
10
+ * owner left to stop it: a timed-out boot reported `failed` and then
11
+ * `stopped` while the module's server was listening.
12
+ */
13
+ export declare class LifecycleCancellation {
14
+ private cancelled;
15
+ private readonly running;
16
+ private readonly releases;
17
+ /** Whether the current startup has been abandoned. */
18
+ get isCancelled(): boolean;
19
+ /** Abandons the current startup. Idempotent. */
20
+ cancel(): void;
21
+ /** Clears the cancelled flag for a new startup. */
22
+ reset(): void;
23
+ /** Whether a hook for `moduleId` is currently running. */
24
+ isRunning(moduleId: string): boolean;
25
+ /**
26
+ * Records a running hook for `moduleId` until it settles.
27
+ */
28
+ track<T>(moduleId: string, hook: Promise<T>): Promise<T>;
29
+ /**
30
+ * Tears down a module whose hook completed after the startup was
31
+ * abandoned: `onShutdown` when it had reached `onReady`, then
32
+ * `onDestroy`. The teardown is tracked so {@link settle} waits for it.
33
+ */
34
+ release(module: Module, context: ModuleContext, started: boolean, logger: Logger): Promise<void>;
35
+ /**
36
+ * Waits until every abandoned hook has settled and every late module
37
+ * has been torn down.
38
+ */
39
+ settle(): Promise<void>;
40
+ }
41
+ //# sourceMappingURL=lifecycle.cancellation.d.ts.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Tracks module hooks that are still running after their startup was
3
+ * abandoned (by a startup timeout or a rollback).
4
+ *
5
+ * `Promise.race` cannot cancel the losing side, so a slow `onInitialize`
6
+ * or `onReady` keeps running after the runtime has already reported a
7
+ * failed start. Without this bookkeeping the late module came up with no
8
+ * owner left to stop it: a timed-out boot reported `failed` and then
9
+ * `stopped` while the module's server was listening.
10
+ */
11
+ export class LifecycleCancellation {
12
+ cancelled = false;
13
+ running = new Map();
14
+ releases = new Set();
15
+ /** Whether the current startup has been abandoned. */
16
+ get isCancelled() {
17
+ return this.cancelled;
18
+ }
19
+ /** Abandons the current startup. Idempotent. */
20
+ cancel() {
21
+ this.cancelled = true;
22
+ }
23
+ /** Clears the cancelled flag for a new startup. */
24
+ reset() {
25
+ this.cancelled = false;
26
+ }
27
+ /** Whether a hook for `moduleId` is currently running. */
28
+ isRunning(moduleId) {
29
+ return this.running.has(moduleId);
30
+ }
31
+ /**
32
+ * Records a running hook for `moduleId` until it settles.
33
+ */
34
+ track(moduleId, hook) {
35
+ this.running.set(moduleId, hook);
36
+ const clear = () => {
37
+ if (this.running.get(moduleId) === hook) {
38
+ this.running.delete(moduleId);
39
+ }
40
+ };
41
+ hook.then(clear, clear);
42
+ return hook;
43
+ }
44
+ /**
45
+ * Tears down a module whose hook completed after the startup was
46
+ * abandoned: `onShutdown` when it had reached `onReady`, then
47
+ * `onDestroy`. The teardown is tracked so {@link settle} waits for it.
48
+ */
49
+ release(module, context, started, logger) {
50
+ const release = releaseAbandonedModule(module, context, started, logger);
51
+ this.releases.add(release);
52
+ void release.finally(() => this.releases.delete(release));
53
+ return release;
54
+ }
55
+ /**
56
+ * Waits until every abandoned hook has settled and every late module
57
+ * has been torn down.
58
+ */
59
+ async settle() {
60
+ while (this.running.size > 0 || this.releases.size > 0) {
61
+ await Promise.allSettled([...this.running.values(), ...this.releases]);
62
+ }
63
+ }
64
+ }
65
+ /**
66
+ * Runs `onShutdown` (when the module had started) and `onDestroy` for a
67
+ * module that finished a hook after its startup was abandoned.
68
+ */
69
+ async function releaseAbandonedModule(module, context, started, logger) {
70
+ logger.warn(`Module "${module.id}" finished a lifecycle hook after startup was abandoned; tearing it down.`);
71
+ if (started && module.onShutdown) {
72
+ try {
73
+ await module.onShutdown(context);
74
+ }
75
+ catch (error) {
76
+ logger.error(`Abandoned module "${module.id}" failed to shut down.`, {
77
+ errorMessage: error instanceof Error ? error.message : String(error),
78
+ });
79
+ }
80
+ }
81
+ if (module.onDestroy) {
82
+ try {
83
+ await module.onDestroy(context);
84
+ }
85
+ catch (error) {
86
+ logger.error(`Abandoned module "${module.id}" failed to destroy.`, {
87
+ errorMessage: error instanceof Error ? error.message : String(error),
88
+ });
89
+ }
90
+ }
91
+ }
92
+ //# sourceMappingURL=lifecycle.cancellation.js.map
@@ -11,10 +11,17 @@ export declare class LifecycleManager {
11
11
  private readonly onModuleEvent;
12
12
  private readonly runtimeId;
13
13
  private initializedModules;
14
+ /**
15
+ * Modules whose `onInitialize` threw. They may have acquired resources
16
+ * before failing, so teardown still runs their `onDestroy`, matching
17
+ * `@zudojs/lifecycle` and `@zudojs/core`.
18
+ */
19
+ private failedInitializations;
14
20
  private startedModules;
15
21
  private readonly configuration;
16
22
  private readonly application;
17
23
  private readonly contexts;
24
+ private readonly cancellation;
18
25
  constructor(modules: ReadonlyMap<string, Module>, logger: Logger, options?: LifecycleManagerOptions, services?: ModuleContextServices);
19
26
  /**
20
27
  * Publishes a per-module lifecycle event.
@@ -28,10 +35,30 @@ export declare class LifecycleManager {
28
35
  * so that `context.getConfiguration()` is usable from the first hook.
29
36
  */
30
37
  private ensureConfigurationReady;
38
+ /**
39
+ * Abandons an in-flight startup.
40
+ *
41
+ * No further module hook is started, and any module whose hook is
42
+ * still running when it settles is shut down and destroyed rather than
43
+ * left running with no owner. Called on a startup timeout and by
44
+ * {@link rollback}.
45
+ */
46
+ cancel(): void;
47
+ /** Whether the current startup has been abandoned by {@link cancel}. */
48
+ get cancelled(): boolean;
31
49
  /**
32
50
  * Initializes all modules in dependency order.
33
51
  */
34
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;
35
62
  /**
36
63
  * Initializes a single module, converting a throw into a failure.
37
64
  */
@@ -1,7 +1,8 @@
1
1
  import { createConfigurationManager } from "@zudojs/core";
2
2
  import { createModuleEventPayload } from "../runtimeEvents/runtimeEvents.core.js";
3
3
  import { resolveDependencies } from "../dependencyGraph/index.js";
4
- import { RuntimeDependencyError, RuntimeStateError, } from "../runtimeError/index.js";
4
+ import { LifecycleCancellation } from "./lifecycle.cancellation.js";
5
+ import { RuntimeDependencyError, RuntimeStartError, RuntimeStateError, } from "../runtimeError/index.js";
5
6
  /**
6
7
  * Manages the lifecycle of runtime modules.
7
8
  */
@@ -12,10 +13,17 @@ export class LifecycleManager {
12
13
  onModuleEvent;
13
14
  runtimeId;
14
15
  initializedModules = [];
16
+ /**
17
+ * Modules whose `onInitialize` threw. They may have acquired resources
18
+ * before failing, so teardown still runs their `onDestroy`, matching
19
+ * `@zudojs/lifecycle` and `@zudojs/core`.
20
+ */
21
+ failedInitializations = [];
15
22
  startedModules = [];
16
23
  configuration;
17
24
  application;
18
25
  contexts = new Map();
26
+ cancellation = new LifecycleCancellation();
19
27
  constructor(modules, logger, options = {}, services = {}) {
20
28
  this.modules = modules;
21
29
  this.logger = logger;
@@ -24,7 +32,6 @@ export class LifecycleManager {
24
32
  this.onModuleEvent = options.onModuleEvent;
25
33
  this.runtimeId = options.runtimeId ?? "";
26
34
  this.options = {
27
- shutdownTimeout: options.shutdownTimeout ?? 30_000,
28
35
  continueOnFailure: options.continueOnFailure ?? false,
29
36
  // Defaults to sequential: initializing a whole depth group at once
30
37
  // is a real behaviour change for modules that assume ordering
@@ -62,15 +69,33 @@ export class LifecycleManager {
62
69
  if (this.configuration.isReady()) {
63
70
  return;
64
71
  }
72
+ // Fail closed: a configuration that did not load must not let
73
+ // modules initialize against partial or unvalidated state.
65
74
  try {
66
75
  await this.configuration.initialize();
67
76
  }
68
77
  catch (error) {
69
- this.logger.warn("Configuration failed to load.", {
70
- error: error instanceof Error ? error.message : String(error),
78
+ throw new RuntimeStartError("Configuration failed to load.", {
79
+ phase: "initialize",
80
+ ...(error instanceof Error && { cause: error }),
71
81
  });
72
82
  }
73
83
  }
84
+ /**
85
+ * Abandons an in-flight startup.
86
+ *
87
+ * No further module hook is started, and any module whose hook is
88
+ * still running when it settles is shut down and destroyed rather than
89
+ * left running with no owner. Called on a startup timeout and by
90
+ * {@link rollback}.
91
+ */
92
+ cancel() {
93
+ this.cancellation.cancel();
94
+ }
95
+ /** Whether the current startup has been abandoned by {@link cancel}. */
96
+ get cancelled() {
97
+ return this.cancellation.isCancelled;
98
+ }
74
99
  /**
75
100
  * Initializes all modules in dependency order.
76
101
  */
@@ -79,7 +104,9 @@ export class LifecycleManager {
79
104
  // Reset the per-run bookkeeping. Appending across runs would make a
80
105
  // stop-then-start cycle initialize and stop every module twice.
81
106
  this.initializedModules = [];
107
+ this.failedInitializations = [];
82
108
  this.startedModules = [];
109
+ this.cancellation.reset();
83
110
  await this.ensureConfigurationReady();
84
111
  const succeeded = [];
85
112
  const failed = [];
@@ -89,15 +116,47 @@ export class LifecycleManager {
89
116
  const groups = this.options.parallelInitialization
90
117
  ? depGraph.parallelGroups
91
118
  : depGraph.order.map((moduleId) => [moduleId]);
119
+ // Ids that must not have dependents initialized on top of them:
120
+ // modules whose own hook threw, plus modules already skipped for the
121
+ // same reason, so the skip cascades transitively. `continueOnFailure`
122
+ // used to consult only the failure COUNT, so a dependent of a broken
123
+ // module was initialized, readied, and reported as started — an API
124
+ // serving traffic against a database that never came up.
125
+ const blocked = new Set();
92
126
  for (const group of groups) {
93
- const results = await Promise.all(group.map((moduleId) => this.initializeModule(moduleId)));
127
+ if (this.cancellation.isCancelled) {
128
+ break;
129
+ }
130
+ const runnable = [];
131
+ for (const moduleId of group) {
132
+ const blocker = this.findBlockingDependency(moduleId, blocked);
133
+ if (blocker === undefined) {
134
+ runnable.push(moduleId);
135
+ continue;
136
+ }
137
+ const failure = {
138
+ moduleId,
139
+ phase: "initialize",
140
+ error: new RuntimeStartError(`Module "${moduleId}" was not initialized because its ` +
141
+ `dependency "${blocker}" failed.`, { phase: "initialize", failedModuleId: blocker }),
142
+ durationMs: 0,
143
+ };
144
+ failed.push(failure);
145
+ blocked.add(moduleId);
146
+ this.logger.error(`Module "${moduleId}" was skipped because its dependency "${blocker}" failed.`);
147
+ this.emitModuleEvent("runtime.module.failed", moduleId, "failed", {
148
+ durationMs: 0,
149
+ error: failure.error,
150
+ });
151
+ }
152
+ const results = await Promise.all(runnable.map((moduleId) => this.initializeModule(moduleId)));
94
153
  for (const result of results) {
95
154
  if (result.failure) {
96
155
  failed.push(result.failure);
156
+ blocked.add(result.moduleId);
97
157
  }
98
- else {
158
+ else if (!result.abandoned) {
99
159
  succeeded.push(result.moduleId);
100
- this.initializedModules.push(result.moduleId);
101
160
  }
102
161
  }
103
162
  if (failed.length > 0 && !this.options.continueOnFailure) {
@@ -111,6 +170,18 @@ export class LifecycleManager {
111
170
  durationMs: Date.now() - startTime,
112
171
  });
113
172
  }
173
+ /**
174
+ * Returns the first declared dependency of `moduleId` that is known to
175
+ * have failed or been skipped, or `undefined` when none has.
176
+ *
177
+ * Only direct dependencies are inspected: `blocked` already contains
178
+ * every module skipped by an earlier group, and groups are visited in
179
+ * dependency order, so the cascade is transitive.
180
+ */
181
+ findBlockingDependency(moduleId, blocked) {
182
+ const dependencies = this.modules.get(moduleId)?.dependencies ?? [];
183
+ return dependencies.find((dependency) => blocked.has(dependency));
184
+ }
114
185
  /**
115
186
  * Initializes a single module, converting a throw into a failure.
116
187
  */
@@ -131,8 +202,16 @@ export class LifecycleManager {
131
202
  this.emitModuleEvent("runtime.module.initializing", moduleId, "initializing");
132
203
  try {
133
204
  if (module.onInitialize) {
134
- await module.onInitialize(this.createModuleContext(module));
205
+ await this.cancellation.track(moduleId, Promise.resolve(module.onInitialize(this.createModuleContext(module))));
135
206
  }
207
+ if (this.cancellation.isCancelled) {
208
+ await this.cancellation.release(module, this.createModuleContext(module), false, this.logger);
209
+ return { moduleId, abandoned: true };
210
+ }
211
+ // Recorded as soon as it succeeds, not after its whole depth group:
212
+ // a rollback that runs while a sibling is still initializing must
213
+ // still reach this module.
214
+ this.initializedModules.push(moduleId);
136
215
  const durationMs = Date.now() - startedAt;
137
216
  this.logger.debug(`Module "${moduleId}" initialized.`, { durationMs });
138
217
  this.emitModuleEvent("runtime.module.initialized", moduleId, "initialized", {
@@ -150,6 +229,12 @@ export class LifecycleManager {
150
229
  this.logger.error(`Module "${moduleId}" failed during initialization.`, {
151
230
  error: failure.error,
152
231
  });
232
+ if (this.cancellation.isCancelled) {
233
+ await this.cancellation.release(module, this.createModuleContext(module), false, this.logger);
234
+ }
235
+ else {
236
+ this.failedInitializations.push(moduleId);
237
+ }
153
238
  this.emitModuleEvent("runtime.module.failed", moduleId, "failed", {
154
239
  durationMs: failure.durationMs,
155
240
  error: failure.error,
@@ -165,8 +250,10 @@ export class LifecycleManager {
165
250
  this.startedModules = [];
166
251
  const succeeded = [];
167
252
  const failed = [];
168
- for (const moduleId of this.initializedModules) {
253
+ for (const moduleId of [...this.initializedModules]) {
169
254
  const module = this.modules.get(moduleId);
255
+ if (this.cancellation.isCancelled)
256
+ break;
170
257
  if (!module)
171
258
  continue;
172
259
  const moduleStartTime = Date.now();
@@ -174,7 +261,18 @@ export class LifecycleManager {
174
261
  try {
175
262
  if (module.onReady) {
176
263
  const context = this.createModuleContext(module);
177
- await module.onReady(context);
264
+ await this.cancellation
265
+ .track(moduleId, Promise.resolve(module.onReady(context)))
266
+ .catch(async (error) => {
267
+ if (this.cancellation.isCancelled) {
268
+ await this.cancellation.release(module, context, false, this.logger);
269
+ }
270
+ throw error;
271
+ });
272
+ }
273
+ if (this.cancellation.isCancelled) {
274
+ await this.cancellation.release(module, this.createModuleContext(module), true, this.logger);
275
+ break;
178
276
  }
179
277
  succeeded.push(moduleId);
180
278
  this.startedModules.push(moduleId);
@@ -218,11 +316,15 @@ export class LifecycleManager {
218
316
  const startTime = Date.now();
219
317
  const succeeded = [];
220
318
  const failed = [];
319
+ await this.cancellation.settle();
221
320
  const reversedModules = [...this.startedModules].reverse();
222
321
  for (const moduleId of reversedModules) {
223
322
  const module = this.modules.get(moduleId);
224
323
  if (!module)
225
324
  continue;
325
+ // Removed before its hook runs, so a second stop() (for example
326
+ // after a shutdown timeout) never calls onShutdown on it again.
327
+ this.startedModules = this.startedModules.filter((id) => id !== moduleId);
226
328
  const moduleStartTime = Date.now();
227
329
  this.emitModuleEvent("runtime.module.stopping", moduleId, "stopping");
228
330
  try {
@@ -268,7 +370,12 @@ export class LifecycleManager {
268
370
  const startTime = Date.now();
269
371
  const succeeded = [];
270
372
  const failed = [];
271
- const reversedModules = [...this.startedModules, ...this.initializedModules]
373
+ await this.cancellation.settle();
374
+ const reversedModules = [
375
+ ...this.startedModules,
376
+ ...this.initializedModules,
377
+ ...this.failedInitializations,
378
+ ]
272
379
  .filter((id, index, arr) => arr.indexOf(id) === index)
273
380
  .reverse();
274
381
  for (const moduleId of reversedModules) {
@@ -303,6 +410,7 @@ export class LifecycleManager {
303
410
  // rebuilds the lists from scratch.
304
411
  this.startedModules = [];
305
412
  this.initializedModules = [];
413
+ this.failedInitializations = [];
306
414
  this.contexts.clear();
307
415
  return Object.freeze({
308
416
  phase: "destroy",
@@ -322,6 +430,7 @@ export class LifecycleManager {
322
430
  */
323
431
  async rollback() {
324
432
  const failures = [];
433
+ this.cancellation.cancel();
325
434
  for (const moduleId of [...this.startedModules].reverse()) {
326
435
  const module = this.modules.get(moduleId);
327
436
  if (!module?.onShutdown)
@@ -343,9 +452,14 @@ export class LifecycleManager {
343
452
  });
344
453
  }
345
454
  }
346
- for (const moduleId of [...this.initializedModules].reverse()) {
455
+ for (const moduleId of [
456
+ ...this.initializedModules,
457
+ ...this.failedInitializations,
458
+ ].reverse()) {
347
459
  const module = this.modules.get(moduleId);
348
- if (!module?.onDestroy)
460
+ // A module whose hook is still running is torn down by the
461
+ // cancellation once that hook settles, never concurrently with it.
462
+ if (!module?.onDestroy || this.cancellation.isRunning(moduleId))
349
463
  continue;
350
464
  const startedAt = Date.now();
351
465
  try {
@@ -368,6 +482,7 @@ export class LifecycleManager {
368
482
  // later shutdown does not stop modules a second time.
369
483
  this.startedModules = [];
370
484
  this.initializedModules = [];
485
+ this.failedInitializations = [];
371
486
  this.contexts.clear();
372
487
  return failures;
373
488
  }
@@ -72,7 +72,21 @@ export type ModuleEventListener = (type: RuntimeModuleEventType, payload: Runtim
72
72
  * Options for lifecycle management.
73
73
  */
74
74
  export interface LifecycleManagerOptions {
75
+ /**
76
+ * @deprecated Has no effect. Shutdown is bounded as a whole by
77
+ * `RuntimeOptions.shutdownTimeout`; bounding each module's hook here
78
+ * would let `onDestroy` run while a timed-out `onShutdown` is still
79
+ * closing the same resources.
80
+ */
75
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
+ */
76
90
  readonly continueOnFailure?: boolean;
77
91
  /**
78
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
  }
@@ -65,6 +65,9 @@ export class DefaultRuntime {
65
65
  this.signalHandler = new SignalHandler(this.logger, {
66
66
  handleSignals: this.options.handleSignals,
67
67
  handleFatalErrors: this.options.handleFatalErrors,
68
+ forceExitOnSecondSignal: this.options.forceExitOnSecondSignal,
69
+ exitOnFatalError: this.options.exitOnFatalError,
70
+ fatalExitTimeout: this.options.fatalExitTimeout,
68
71
  });
69
72
  this.readinessTracker = new ReadinessTracker({
70
73
  autoMarkReady: this.options.trackReadiness,
@@ -298,6 +301,9 @@ export class DefaultRuntime {
298
301
  });
299
302
  }
300
303
  this.transitionTo("failed");
304
+ // A rolled-back runtime owns nothing, so it must not keep owning the
305
+ // process's signals and fatal-error handlers either.
306
+ this.signalHandler.unregister();
301
307
  throw runtimeError;
302
308
  }
303
309
  }
@@ -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,19 +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
+ });
46
+ }
47
+ if (!Number.isFinite(options.fatalExitTimeout) || options.fatalExitTimeout < 0) {
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
+ });
37
54
  }
38
55
  if (options.readinessCheckTimeout < 0) {
39
- 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
+ });
40
62
  }
41
63
  }
42
64
  /**
@@ -31,6 +31,25 @@ export interface RuntimeOptions {
31
31
  * @default true
32
32
  */
33
33
  readonly handleFatalErrors?: boolean;
34
+ /**
35
+ * Whether a second SIGTERM/SIGINT during shutdown exits immediately
36
+ * with code 1.
37
+ * @default true
38
+ */
39
+ readonly forceExitOnSecondSignal?: boolean;
40
+ /**
41
+ * Whether an uncaught exception or unhandled rejection exits the
42
+ * process with code 1 once shutdown finishes. Set to `false` to stop
43
+ * the runtime but leave the process running.
44
+ * @default true
45
+ */
46
+ readonly exitOnFatalError?: boolean;
47
+ /**
48
+ * How long a fatal-error shutdown may take before the process exits
49
+ * anyway, in milliseconds.
50
+ * @default 10000
51
+ */
52
+ readonly fatalExitTimeout?: number;
34
53
  /**
35
54
  * Graceful shutdown timeout in milliseconds.
36
55
  * @default 30000
@@ -94,6 +113,9 @@ export interface ResolvedRuntimeOptions {
94
113
  readonly applicationVersion: string;
95
114
  readonly handleSignals: boolean;
96
115
  readonly handleFatalErrors: boolean;
116
+ readonly forceExitOnSecondSignal: boolean;
117
+ readonly exitOnFatalError: boolean;
118
+ readonly fatalExitTimeout: number;
97
119
  readonly shutdownTimeout: number;
98
120
  readonly startupTimeout: number;
99
121
  readonly emitEvents: boolean;
@@ -109,6 +131,9 @@ export interface ResolvedRuntimeOptions {
109
131
  export declare const DEFAULT_RUNTIME_OPTIONS: Readonly<{
110
132
  readonly handleSignals: true;
111
133
  readonly handleFatalErrors: true;
134
+ readonly forceExitOnSecondSignal: true;
135
+ readonly exitOnFatalError: true;
136
+ readonly fatalExitTimeout: 10000;
112
137
  readonly shutdownTimeout: 30000;
113
138
  readonly startupTimeout: 60000;
114
139
  readonly emitEvents: true;
@@ -4,6 +4,9 @@
4
4
  export const DEFAULT_RUNTIME_OPTIONS = Object.freeze({
5
5
  handleSignals: true,
6
6
  handleFatalErrors: true,
7
+ forceExitOnSecondSignal: true,
8
+ exitOnFatalError: true,
9
+ fatalExitTimeout: 10_000,
7
10
  shutdownTimeout: 30_000,
8
11
  startupTimeout: 60_000,
9
12
  emitEvents: true,
@@ -4,6 +4,27 @@ import { LifecycleManager } from "../lifecycle/index.js";
4
4
  import { RuntimeStopError, RuntimeTimeoutError, } from "../runtimeError/index.js";
5
5
  /** Largest delay a timer can represent. */
6
6
  const MAX_TIMER_DELAY = 2_147_483_647;
7
+ /**
8
+ * Teardown still running for a lifecycle, including one abandoned by a
9
+ * shutdown timeout. A later stop() joins it instead of calling every
10
+ * module's `onShutdown` a second time while the first is still closing.
11
+ */
12
+ const inProgress = new WeakMap();
13
+ /**
14
+ * Starts the teardown for `lifecycle`, or joins the one already running.
15
+ */
16
+ function shutdownOnce(lifecycle, logger) {
17
+ const existing = inProgress.get(lifecycle);
18
+ if (existing !== undefined) {
19
+ logger.warn("A previous shutdown is still running; waiting for it.");
20
+ return existing;
21
+ }
22
+ const teardown = performShutdown(lifecycle, logger).finally(() => {
23
+ inProgress.delete(lifecycle);
24
+ });
25
+ inProgress.set(lifecycle, teardown);
26
+ return teardown;
27
+ }
7
28
  /**
8
29
  * Executes the shutdown sequence with timeout.
9
30
  *
@@ -24,7 +45,7 @@ export async function executeShutdown(lifecycle, runtimeId, eventBus, logger, sh
24
45
  }));
25
46
  }
26
47
  logger.info("Initiating graceful shutdown.", { timeoutMs: shutdownTimeout });
27
- const stopPromise = performShutdown(lifecycle, logger);
48
+ const stopPromise = shutdownOnce(lifecycle, logger);
28
49
  // The shutdown promise keeps running if the timeout wins; attach a
29
50
  // handler now so its eventual rejection is never unhandled.
30
51
  stopPromise.catch(() => { });
@@ -11,7 +11,7 @@ const MAX_TIMER_DELAY = 2_147_483_647;
11
11
  * a module whose `onInitialize` never settles hung the boot forever with
12
12
  * no diagnostic. The timer is cleared whichever side wins.
13
13
  */
14
- async function withStartupTimeout(operation, timeoutMs) {
14
+ async function withStartupTimeout(operation, timeoutMs, onTimeout) {
15
15
  if (timeoutMs <= 0) {
16
16
  return operation;
17
17
  }
@@ -20,7 +20,13 @@ async function withStartupTimeout(operation, timeoutMs) {
20
20
  operation.catch(() => { });
21
21
  let timer;
22
22
  const timeout = new Promise((_, reject) => {
23
- timer = setTimeout(() => reject(new RuntimeTimeoutError("startup", timeoutMs)), Math.min(timeoutMs, MAX_TIMER_DELAY));
23
+ timer = setTimeout(() => {
24
+ // Abandon the startup before reporting the timeout, so no further
25
+ // module hook starts and a hook still running tears its module
26
+ // down when it settles instead of leaving it live.
27
+ onTimeout();
28
+ reject(new RuntimeTimeoutError("startup", timeoutMs));
29
+ }, Math.min(timeoutMs, MAX_TIMER_DELAY));
24
30
  timer.unref?.();
25
31
  });
26
32
  try {
@@ -36,7 +42,7 @@ async function withStartupTimeout(operation, timeoutMs) {
36
42
  * Executes the startup sequence.
37
43
  */
38
44
  export async function executeStartup(lifecycle, runtimeId, eventBus, logger, emitEvents, startupTimeout = 0) {
39
- return withStartupTimeout(runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents), startupTimeout);
45
+ return withStartupTimeout(runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents), startupTimeout, () => lifecycle.cancel());
40
46
  }
41
47
  /**
42
48
  * Runs the startup sequence.
@@ -45,6 +51,9 @@ async function runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents) {
45
51
  // Per-module `runtime.module.*` events are emitted by the lifecycle
46
52
  // manager, which is the only layer that knows which module is running.
47
53
  const initResult = await lifecycle.initialize();
54
+ if (lifecycle.cancelled) {
55
+ return;
56
+ }
48
57
  if (initResult.failed.length > 0) {
49
58
  const failure = initResult.failed[0];
50
59
  if (emitEvents && eventBus) {
@@ -64,6 +73,9 @@ async function runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents) {
64
73
  durationMs: initResult.durationMs,
65
74
  });
66
75
  const startResult = await lifecycle.start();
76
+ if (lifecycle.cancelled) {
77
+ return;
78
+ }
67
79
  if (startResult.failed.length > 0) {
68
80
  const failure = startResult.failed[0];
69
81
  if (emitEvents && eventBus) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/runtime",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",
@@ -27,13 +27,12 @@
27
27
  "vitest": "^4.1.11"
28
28
  },
29
29
  "dependencies": {
30
- "@zudojs/errors": "1.0.1",
31
- "@zudojs/constants": "1.0.1",
32
- "@zudojs/container": "1.1.0",
33
- "@zudojs/config": "1.0.1",
34
- "@zudojs/logger": "1.1.0",
35
- "@zudojs/events": "1.0.1",
36
- "@zudojs/core": "1.1.0"
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"
37
36
  },
38
37
  "license": "MIT",
39
38
  "author": {