@zudojs/runtime 1.0.0 → 1.2.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
@@ -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,6 +35,17 @@ 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
  */
@@ -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 = [];
@@ -90,14 +117,16 @@ export class LifecycleManager {
90
117
  ? depGraph.parallelGroups
91
118
  : depGraph.order.map((moduleId) => [moduleId]);
92
119
  for (const group of groups) {
120
+ if (this.cancellation.isCancelled) {
121
+ break;
122
+ }
93
123
  const results = await Promise.all(group.map((moduleId) => this.initializeModule(moduleId)));
94
124
  for (const result of results) {
95
125
  if (result.failure) {
96
126
  failed.push(result.failure);
97
127
  }
98
- else {
128
+ else if (!result.abandoned) {
99
129
  succeeded.push(result.moduleId);
100
- this.initializedModules.push(result.moduleId);
101
130
  }
102
131
  }
103
132
  if (failed.length > 0 && !this.options.continueOnFailure) {
@@ -131,8 +160,16 @@ export class LifecycleManager {
131
160
  this.emitModuleEvent("runtime.module.initializing", moduleId, "initializing");
132
161
  try {
133
162
  if (module.onInitialize) {
134
- await module.onInitialize(this.createModuleContext(module));
163
+ await this.cancellation.track(moduleId, Promise.resolve(module.onInitialize(this.createModuleContext(module))));
135
164
  }
165
+ if (this.cancellation.isCancelled) {
166
+ await this.cancellation.release(module, this.createModuleContext(module), false, this.logger);
167
+ return { moduleId, abandoned: true };
168
+ }
169
+ // Recorded as soon as it succeeds, not after its whole depth group:
170
+ // a rollback that runs while a sibling is still initializing must
171
+ // still reach this module.
172
+ this.initializedModules.push(moduleId);
136
173
  const durationMs = Date.now() - startedAt;
137
174
  this.logger.debug(`Module "${moduleId}" initialized.`, { durationMs });
138
175
  this.emitModuleEvent("runtime.module.initialized", moduleId, "initialized", {
@@ -150,6 +187,12 @@ export class LifecycleManager {
150
187
  this.logger.error(`Module "${moduleId}" failed during initialization.`, {
151
188
  error: failure.error,
152
189
  });
190
+ if (this.cancellation.isCancelled) {
191
+ await this.cancellation.release(module, this.createModuleContext(module), false, this.logger);
192
+ }
193
+ else {
194
+ this.failedInitializations.push(moduleId);
195
+ }
153
196
  this.emitModuleEvent("runtime.module.failed", moduleId, "failed", {
154
197
  durationMs: failure.durationMs,
155
198
  error: failure.error,
@@ -165,8 +208,10 @@ export class LifecycleManager {
165
208
  this.startedModules = [];
166
209
  const succeeded = [];
167
210
  const failed = [];
168
- for (const moduleId of this.initializedModules) {
211
+ for (const moduleId of [...this.initializedModules]) {
169
212
  const module = this.modules.get(moduleId);
213
+ if (this.cancellation.isCancelled)
214
+ break;
170
215
  if (!module)
171
216
  continue;
172
217
  const moduleStartTime = Date.now();
@@ -174,7 +219,18 @@ export class LifecycleManager {
174
219
  try {
175
220
  if (module.onReady) {
176
221
  const context = this.createModuleContext(module);
177
- await module.onReady(context);
222
+ await this.cancellation
223
+ .track(moduleId, Promise.resolve(module.onReady(context)))
224
+ .catch(async (error) => {
225
+ if (this.cancellation.isCancelled) {
226
+ await this.cancellation.release(module, context, false, this.logger);
227
+ }
228
+ throw error;
229
+ });
230
+ }
231
+ if (this.cancellation.isCancelled) {
232
+ await this.cancellation.release(module, this.createModuleContext(module), true, this.logger);
233
+ break;
178
234
  }
179
235
  succeeded.push(moduleId);
180
236
  this.startedModules.push(moduleId);
@@ -218,11 +274,15 @@ export class LifecycleManager {
218
274
  const startTime = Date.now();
219
275
  const succeeded = [];
220
276
  const failed = [];
277
+ await this.cancellation.settle();
221
278
  const reversedModules = [...this.startedModules].reverse();
222
279
  for (const moduleId of reversedModules) {
223
280
  const module = this.modules.get(moduleId);
224
281
  if (!module)
225
282
  continue;
283
+ // Removed before its hook runs, so a second stop() (for example
284
+ // after a shutdown timeout) never calls onShutdown on it again.
285
+ this.startedModules = this.startedModules.filter((id) => id !== moduleId);
226
286
  const moduleStartTime = Date.now();
227
287
  this.emitModuleEvent("runtime.module.stopping", moduleId, "stopping");
228
288
  try {
@@ -268,7 +328,12 @@ export class LifecycleManager {
268
328
  const startTime = Date.now();
269
329
  const succeeded = [];
270
330
  const failed = [];
271
- const reversedModules = [...this.startedModules, ...this.initializedModules]
331
+ await this.cancellation.settle();
332
+ const reversedModules = [
333
+ ...this.startedModules,
334
+ ...this.initializedModules,
335
+ ...this.failedInitializations,
336
+ ]
272
337
  .filter((id, index, arr) => arr.indexOf(id) === index)
273
338
  .reverse();
274
339
  for (const moduleId of reversedModules) {
@@ -303,6 +368,7 @@ export class LifecycleManager {
303
368
  // rebuilds the lists from scratch.
304
369
  this.startedModules = [];
305
370
  this.initializedModules = [];
371
+ this.failedInitializations = [];
306
372
  this.contexts.clear();
307
373
  return Object.freeze({
308
374
  phase: "destroy",
@@ -322,6 +388,7 @@ export class LifecycleManager {
322
388
  */
323
389
  async rollback() {
324
390
  const failures = [];
391
+ this.cancellation.cancel();
325
392
  for (const moduleId of [...this.startedModules].reverse()) {
326
393
  const module = this.modules.get(moduleId);
327
394
  if (!module?.onShutdown)
@@ -343,9 +410,14 @@ export class LifecycleManager {
343
410
  });
344
411
  }
345
412
  }
346
- for (const moduleId of [...this.initializedModules].reverse()) {
413
+ for (const moduleId of [
414
+ ...this.initializedModules,
415
+ ...this.failedInitializations,
416
+ ].reverse()) {
347
417
  const module = this.modules.get(moduleId);
348
- if (!module?.onDestroy)
418
+ // A module whose hook is still running is torn down by the
419
+ // cancellation once that hook settles, never concurrently with it.
420
+ if (!module?.onDestroy || this.cancellation.isRunning(moduleId))
349
421
  continue;
350
422
  const startedAt = Date.now();
351
423
  try {
@@ -368,6 +440,7 @@ export class LifecycleManager {
368
440
  // later shutdown does not stop modules a second time.
369
441
  this.startedModules = [];
370
442
  this.initializedModules = [];
443
+ this.failedInitializations = [];
371
444
  this.contexts.clear();
372
445
  return failures;
373
446
  }
@@ -72,6 +72,12 @@ 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;
76
82
  readonly continueOnFailure?: boolean;
77
83
  /**
@@ -53,10 +53,23 @@ export class ReadinessTracker {
53
53
  removeCheck(name) {
54
54
  const existed = this.checks.delete(name);
55
55
  this.checkFns.delete(name);
56
- if (existed) {
56
+ if (!existed) {
57
+ return false;
58
+ }
59
+ if (this.autoMarkReady &&
60
+ this.checks.size === 0 &&
61
+ this.state === "degraded") {
62
+ // `degraded` is only ever entered from `ready` because a check
63
+ // failed. Removing the last check leaves nothing failing, so the
64
+ // tracker returns to ready — otherwise `evaluateReadiness()` (which
65
+ // only acts when checks exist) left it stuck at `ready: false` while
66
+ // the derived health, seeing no checks, reported `healthy`.
67
+ this.markReady("All readiness checks removed.");
68
+ }
69
+ else {
57
70
  this.evaluateReadiness();
58
71
  }
59
- return existed;
72
+ return true;
60
73
  }
61
74
  /**
62
75
  * Evaluates a readiness check and records its result.
@@ -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,
@@ -206,6 +209,17 @@ export class DefaultRuntime {
206
209
  if (this.stopPromise) {
207
210
  return this.stopPromise;
208
211
  }
212
+ if (this.startPromise) {
213
+ // A stop requested while startup is in flight — typically a SIGTERM
214
+ // arriving while modules are still coming up — waits for startup to
215
+ // settle and then shuts down whatever it produced. Previously this
216
+ // threw `RuntimeStateError` ("cannot stop a runtime in state
217
+ // initializing"), so the signal handler logged "Shutdown failed" and
218
+ // the runtime carried on to `running` as if nothing had happened.
219
+ // Startup is bounded by `startupTimeout`, so this wait is too.
220
+ await this.startPromise.catch(() => undefined);
221
+ return this.stop();
222
+ }
209
223
  if (this._state === "created") {
210
224
  this._state = "stopped";
211
225
  this._stoppedAt = new Date();
@@ -287,6 +301,9 @@ export class DefaultRuntime {
287
301
  });
288
302
  }
289
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();
290
307
  throw runtimeError;
291
308
  }
292
309
  }
@@ -1,6 +1,11 @@
1
1
  import type { ResolvedRuntimeOptions } from "./runtimeOptions.type.js";
2
2
  /**
3
3
  * Resolves runtime options with defaults applied.
4
+ *
5
+ * `runtimeId` is documented as "auto-generated if not provided", but until
6
+ * this generated one nothing did: `DEFAULT_RUNTIME_OPTIONS` has no entry
7
+ * for it, so an omitted id reached every event payload, log line and
8
+ * `runtime.context.runtimeId` as `undefined`.
4
9
  */
5
10
  export declare function resolveRuntimeOptions(options: ResolvedRuntimeOptions): ResolvedRuntimeOptions;
6
11
  /**
@@ -1,6 +1,12 @@
1
1
  import { DEFAULT_RUNTIME_OPTIONS } from "./runtimeOptions.type.js";
2
+ import { createRuntimeId } from "../runtimeContext/runtimeContext.factory.js";
2
3
  /**
3
4
  * Resolves runtime options with defaults applied.
5
+ *
6
+ * `runtimeId` is documented as "auto-generated if not provided", but until
7
+ * this generated one nothing did: `DEFAULT_RUNTIME_OPTIONS` has no entry
8
+ * for it, so an omitted id reached every event payload, log line and
9
+ * `runtime.context.runtimeId` as `undefined`.
4
10
  */
5
11
  export function resolveRuntimeOptions(options) {
6
12
  // Spreading `options` wholesale lets an explicitly-undefined key erase
@@ -9,6 +15,7 @@ export function resolveRuntimeOptions(options) {
9
15
  const provided = Object.fromEntries(Object.entries(options ?? {}).filter(([, value]) => value !== undefined));
10
16
  return Object.freeze({
11
17
  ...DEFAULT_RUNTIME_OPTIONS,
18
+ runtimeId: createRuntimeId(),
12
19
  ...provided,
13
20
  });
14
21
  }
@@ -28,6 +35,9 @@ export function validateRuntimeOptions(options) {
28
35
  if (options.startupTimeout <= 0) {
29
36
  throw new Error("Startup timeout must be positive.");
30
37
  }
38
+ 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}.`);
40
+ }
31
41
  if (options.readinessCheckTimeout < 0) {
32
42
  throw new Error(`Readiness check timeout must be zero or positive, got ${options.readinessCheckTimeout}. Use 0 to run checks without a bound.`);
33
43
  }
@@ -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.0.0",
3
+ "version": "1.2.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",
@@ -27,15 +27,18 @@
27
27
  "vitest": "^4.1.11"
28
28
  },
29
29
  "dependencies": {
30
- "@zudojs/errors": "1.0.0",
31
- "@zudojs/constants": "1.0.0",
32
- "@zudojs/container": "1.0.0",
33
- "@zudojs/config": "1.0.0",
34
- "@zudojs/logger": "1.0.0",
35
- "@zudojs/events": "1.0.0",
36
- "@zudojs/core": "1.0.0"
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"
37
36
  },
38
37
  "license": "MIT",
38
+ "author": {
39
+ "name": "Oluwayemi Oyinlola",
40
+ "url": "https://github.com/oyinlola-tech"
41
+ },
39
42
  "publishConfig": {
40
43
  "access": "public"
41
44
  },