@zudojs/core 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.
Files changed (32) hide show
  1. package/README.md +14 -4
  2. package/dist/configuration/configurationManager.manager.js +1 -1
  3. package/dist/container/container.d.ts +4 -2
  4. package/dist/container/container.js +11 -0
  5. package/dist/container/container.lifetime.d.ts +21 -0
  6. package/dist/container/container.lifetime.js +39 -0
  7. package/dist/container/scope.d.ts +3 -1
  8. package/dist/context/provider/contextStorage.storage.d.ts +8 -0
  9. package/dist/context/provider/contextStorage.storage.js +12 -1
  10. package/dist/lifecycle/core/lifecycle.d.ts +8 -3
  11. package/dist/lifecycle/core/lifecycle.js +18 -5
  12. package/dist/lifecycle/core/lifecycle.rollback.d.ts +18 -0
  13. package/dist/lifecycle/core/lifecycle.rollback.js +34 -0
  14. package/dist/modules/moduleLifecycle/moduleLifecycle.lifecycle.js +2 -2
  15. package/dist/modules/moduleLifecycle/moduleLifecycle.stateMachine.d.ts +1 -1
  16. package/dist/modules/moduleLifecycle/moduleLifecycle.stateMachine.js +3 -1
  17. package/dist/modules/moduleLifecycle/moduleLifecycle.type.d.ts +7 -0
  18. package/dist/runtime/runtime.d.ts +2 -0
  19. package/dist/runtime/runtime.js +15 -4
  20. package/dist/runtime/runtimeBootstrap/pipeline/runtimeBootstrap.pipeline.d.ts +3 -1
  21. package/dist/runtime/runtimeBootstrap/pipeline/runtimeBootstrap.pipeline.js +16 -5
  22. package/dist/runtime/runtimeOptions/runtimeOptions.defaults.js +3 -1
  23. package/dist/runtime/runtimeOptions/runtimeOptions.mode.d.ts +14 -0
  24. package/dist/runtime/runtimeOptions/runtimeOptions.mode.js +16 -0
  25. package/dist/runtime/runtimeOptions/runtimeOptions.resolver.js +10 -2
  26. package/dist/runtime/runtimeOptions/runtimeOptions.type.d.ts +18 -2
  27. package/dist/runtime/runtimeOptions/runtimeOptions.validation.d.ts +1 -1
  28. package/dist/runtime/runtimeSignals/runtimeSignals.d.ts +14 -3
  29. package/dist/runtime/runtimeSignals/runtimeSignals.fatal.d.ts +29 -0
  30. package/dist/runtime/runtimeSignals/runtimeSignals.fatal.js +51 -0
  31. package/dist/runtime/runtimeSignals/runtimeSignals.js +15 -4
  32. package/package.json +3 -3
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Application lifecycle management, execution context propagation, and runtime orchestration for Zudojs applications.
4
4
 
5
+ <!-- zudo-docs:start -->
6
+
7
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-core](https://zudojs.oyinlola.site/docs/packages-core) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-core.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
8
+
9
+ <!-- zudo-docs:end -->
10
+
5
11
  ## Installation
6
12
 
7
13
  ```bash
@@ -43,6 +49,8 @@ await app.start(); // restart: a fresh runtime is created
43
49
  await app.shutdown(); // stop + dispose; cannot be restarted afterwards
44
50
  ```
45
51
 
52
+ When `runtime.mode` is omitted, it is derived from `NODE_ENV` with `resolveEnvironment()` from `@zudojs/constants` (`prod` / `Production` → `"production"`, `staging` → `"production"`, `test` → `"test"`, unset → `"development"`).
53
+
46
54
  `createApplication` wires the standard graph (container, configuration manager, logger, module registry/loader/lifecycle, runtime, application lifecycle). Every piece can also be assembled by hand:
47
55
 
48
56
  ```typescript
@@ -66,6 +74,8 @@ const app = await Application.create({
66
74
  });
67
75
  ```
68
76
 
77
+ A failed `lifecycle.start()` stops, in reverse order, every participant whose `start()` completed, so a retry starts from the first one again. `dispose()` only reaches participants whose `initialize()` ran.
78
+
69
79
  ## Runtime
70
80
 
71
81
  `createRuntime(dependencies, options)` returns a single-use runtime:
@@ -77,14 +87,14 @@ FAILED is reachable from every non-terminal state via fail() or a failure.
77
87
 
78
88
  - `start()` loads, initializes, and starts modules through the bootstrap pipeline; `getStatus().bootstrap` reports counts, errors, and duration.
79
89
  - `stop()` stops and destroys modules through the shutdown pipeline; `getStatus().shutdown` reports the same.
80
- - `startup.timeoutMs` / `shutdown.timeoutMs` abort the pipeline with a `RuntimeTimeoutError`; the abandoned pipeline can never surface an unhandled rejection.
90
+ - `startup.timeoutMs` / `shutdown.timeoutMs` abort the pipeline with a `RuntimeTimeoutError`; the abandoned pipeline can never surface an unhandled rejection. A timed-out bootstrap starts no further phase or module hook; the hook still running finishes, and the runtime then stops and destroys whatever it brought up. `stop()` waits for that teardown.
81
91
  - `continueOnInitializeError` / `continueOnStartError` (and the stop/destroy equivalents) make the runtime finish with `success: false` and the failures listed instead of throwing.
82
92
  - Every error the runtime throws extends `RuntimeError` (which itself extends `RuntimeError` from `@zudojs/errors`), so `isRuntimeError()` from either package recognises it.
83
93
  - `runtime.context` is the runtime's immutable `RuntimeExecutionContext` (`executionId` = runtime id, `service` = runtime name, `metadata.runtimeId/runtimeName/runtimeMode/runtimeRole` plus `RuntimeOptions.metadata`); `runtime.timing` holds the state-transition timestamps; `runtime.contextStorage` is the `ContextStorage` the context is established in (`RuntimeDependencies.contextStorage`, default `getDefaultContextStorage()`).
84
94
 
85
95
  ### Signals
86
96
 
87
- When `signals.handleSigint` / `handleSigterm` / `handleSighup` are on (SIGINT and SIGTERM default to on), the runtime registers handlers on start and removes them on stop, failure, or dispose. The first signal triggers a graceful `stop()`. A second signal during shutdown is logged and ignored unless `signals.forceExitOnSecondSignal` is explicitly enabled, in which case the process exits with `signals.forceExitCode`. `handleUncaughtException` / `handleUnhandledRejection` mark the runtime failed and stop it. The runtime never calls `process.exit()` otherwise.
97
+ When `signals.handleSigint` / `handleSigterm` / `handleSighup` are on (SIGINT and SIGTERM default to on), the runtime registers handlers on start and removes them on stop, failure, or dispose. The first signal triggers a graceful `stop()`. A second signal during shutdown exits the process with `signals.forceExitCode` (default 1); set `signals.forceExitOnSecondSignal: false` to log and ignore it instead. `handleUncaughtException` / `handleUnhandledRejection` mark the runtime failed, stop it, and then exit the process with code 1, because the installed handler suppresses Node's own crash and the process would otherwise end with code 0. `signals.fatalExitTimeout` (default 10000 ms) bounds a shutdown that hangs; set `signals.exitOnFatalError: false` to stop the runtime and keep the process running. The runtime never calls `process.exit()` otherwise.
88
98
 
89
99
  ## Modules
90
100
 
@@ -120,14 +130,14 @@ onInitialize → onReady → onShutdown → onDestroy
120
130
 
121
131
  ## Execution context
122
132
 
123
- There is one execution-context model: the immutable `ExecutionContext` (`createExecutionContext`, `deriveExecutionContext`, `withExecutionMetadata`) propagated by `ContextStorage`, which wraps `AsyncLocalStorage`. `run(context, fn)` and `runDerived(overrides, fn)` establish a context for the callback and everything it awaits, `runWithValues(context, values, fn)` additionally binds a `ContextValues` collection (read back with `getValues()`), and `capture()` / `runSnapshot(snapshot, fn)` carry a context across queue or timer boundaries. `getDefaultContextStorage()` is the process-wide instance every component uses unless another is injected.
133
+ There is one execution-context model: the immutable `ExecutionContext` (`createExecutionContext`, `deriveExecutionContext`, `withExecutionMetadata`) propagated by `ContextStorage`, which wraps `AsyncLocalStorage`. `run(context, fn)` and `runDerived(overrides, fn)` establish a context for the callback and everything it awaits, `runWithValues(context, values, fn)` additionally binds a `ContextValues` collection (read back with `getValues()`; `run()` starts a new execution without the enclosing execution's values, `runDerived()` keeps them), and `capture()` / `runSnapshot(snapshot, fn)` carry a context across queue or timer boundaries. `getDefaultContextStorage()` is the process-wide instance every component uses unless another is injected.
124
134
 
125
135
  Propagation is real at runtime:
126
136
 
127
137
  - `runtime.start()` and `runtime.stop()` run the bootstrap and shutdown pipelines inside `runtime.contextStorage.run(runtime.context, ...)`.
128
138
  - Each module hook (`onInitialize`, `onReady`, `onShutdown`, `onDestroy`) runs in a context derived from it: `module` is the module id, `operation` is the hook name, and `{ moduleId, phase }` is merged into `metadata`. `ModuleLifecycleOptions.contextStorage` selects the storage (default: the shared one).
129
139
  - `Application.start()` / `stop()` / `shutdown()` run lifecycle participants inside the runtime's context as well.
130
- - `createApplication` threads one storage (`CreateApplicationOptions.contextStorage`) through the `ApplicationContext` (`getContextStorage()`), the container's `currentScope` (so `"scoped"` providers resolve once per execution context), the module lifecycle, the runtime, and the logger.
140
+ - `createApplication` threads one storage (`CreateApplicationOptions.contextStorage`) through the `ApplicationContext` (`getContextStorage()`), the container's `currentScope` (so `"scoped"` providers resolve once per execution context; resolving one outside any execution context, or from a singleton's factory, throws a `DependencyResolutionError`), the module lifecycle, the runtime, and the logger.
131
141
 
132
142
  ```typescript
133
143
  import { defineModule, getDefaultContextStorage } from "@zudojs/core";
@@ -219,7 +219,7 @@ export class ConfigurationManager {
219
219
  }
220
220
  if (!this.missingSetConfigurationWarned) {
221
221
  this.missingSetConfigurationWarned = true;
222
- console.warn("ConfigurationManager: the configured ConfigurationProvider does not implement setConfiguration(); loaded configuration will not be pushed into the provider.");
222
+ process.emitWarning("ConfigurationManager: the configured ConfigurationProvider does not implement setConfiguration(); loaded configuration will not be pushed into the provider.", { type: "ZudojsCoreWarning", code: "ZUDOJS_CONFIG_PROVIDER_NO_SET" });
223
223
  }
224
224
  }
225
225
  emit(event) {
@@ -12,8 +12,8 @@ export interface ContainerOptions {
12
12
  * new Container({ currentScope: () => storage.get() })
13
13
  *
14
14
  * "scoped" providers resolve to one instance per returned object.
15
- * When it returns undefined and no explicit scope is active, scoped
16
- * providers behave as transient.
15
+ * When it returns undefined and no explicit scope is active, resolving
16
+ * a scoped provider throws a DependencyResolutionError.
17
17
  */
18
18
  readonly currentScope?: () => object | undefined;
19
19
  }
@@ -29,6 +29,8 @@ export declare class Container {
29
29
  private readonly currentScope;
30
30
  private readonly resolving;
31
31
  private activeScope;
32
+ /** The singleton under construction, if any; see assertScopedResolvable. */
33
+ private captor;
32
34
  constructor(options?: ContainerOptions);
33
35
  /**
34
36
  * Registers a provider in the container.
@@ -1,4 +1,5 @@
1
1
  import { ProviderNotFoundError, ProviderAlreadyRegisteredError, InvalidProviderError, DependencyResolutionError, } from "../errors/exceptions.js";
2
+ import { assertScopedResolvable } from "./container.lifetime.js";
2
3
  /**
3
4
  * Dependency Injection container for Zudojs applications.
4
5
  *
@@ -11,6 +12,8 @@ export class Container {
11
12
  currentScope;
12
13
  resolving = [];
13
14
  activeScope;
15
+ /** The singleton under construction, if any; see assertScopedResolvable. */
16
+ captor;
14
17
  constructor(options = {}) {
15
18
  this.currentScope = options.currentScope;
16
19
  }
@@ -80,6 +83,9 @@ export class Container {
80
83
  if (registration.scope === "singleton" && registration.resolved) {
81
84
  return registration.instance;
82
85
  }
86
+ if (registration.scope === "scoped") {
87
+ assertScopedResolvable(token, this.captor, scopeKey, this.resolving);
88
+ }
83
89
  if (registration.scope === "scoped" && scopeKey) {
84
90
  if (registration.scopedInstances.has(scopeKey)) {
85
91
  return registration.scopedInstances.get(scopeKey);
@@ -109,8 +115,12 @@ export class Container {
109
115
  return provider.useValue;
110
116
  }
111
117
  const previousScope = this.activeScope;
118
+ const previousCaptor = this.captor;
112
119
  this.resolving.push(token);
113
120
  this.activeScope = scopeKey;
121
+ if (registration.scope === "singleton") {
122
+ this.captor = token;
123
+ }
114
124
  try {
115
125
  if ("useFactory" in provider) {
116
126
  return provider.useFactory(this);
@@ -125,6 +135,7 @@ export class Container {
125
135
  finally {
126
136
  this.resolving.pop();
127
137
  this.activeScope = previousScope;
138
+ this.captor = previousCaptor;
128
139
  }
129
140
  }
130
141
  }
@@ -0,0 +1,21 @@
1
+ import type { Token } from "./token.js";
2
+ /**
3
+ * Rejects a "scoped" resolution that would outlive or escape its scope.
4
+ *
5
+ * - A scoped provider resolved (directly or transitively) while a
6
+ * singleton is being constructed would be captured by that singleton
7
+ * and served to every later scope: one request's user, tenant or
8
+ * transaction leaking into all the others. This is the captive
9
+ * dependency error mature DI containers raise.
10
+ * - A scoped provider resolved with no active scope has nothing to be
11
+ * scoped to. It used to behave as transient, which hid the missing
12
+ * scope; it now fails like `@zudojs/container`'s ScopedResolutionError.
13
+ *
14
+ * @param token - The scoped token being resolved.
15
+ * @param captor - The singleton currently under construction, if any.
16
+ * @param scopeKey - The active scope key, if any.
17
+ * @param chain - The current resolution chain, for the diagnostic.
18
+ * @throws DependencyResolutionError when either rule is broken.
19
+ */
20
+ export declare function assertScopedResolvable(token: Token<unknown>, captor: Token<unknown> | undefined, scopeKey: object | undefined, chain: readonly Token<unknown>[]): void;
21
+ //# sourceMappingURL=container.lifetime.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { DependencyResolutionError } from "../errors/exceptions.js";
2
+ /**
3
+ * Rejects a "scoped" resolution that would outlive or escape its scope.
4
+ *
5
+ * - A scoped provider resolved (directly or transitively) while a
6
+ * singleton is being constructed would be captured by that singleton
7
+ * and served to every later scope: one request's user, tenant or
8
+ * transaction leaking into all the others. This is the captive
9
+ * dependency error mature DI containers raise.
10
+ * - A scoped provider resolved with no active scope has nothing to be
11
+ * scoped to. It used to behave as transient, which hid the missing
12
+ * scope; it now fails like `@zudojs/container`'s ScopedResolutionError.
13
+ *
14
+ * @param token - The scoped token being resolved.
15
+ * @param captor - The singleton currently under construction, if any.
16
+ * @param scopeKey - The active scope key, if any.
17
+ * @param chain - The current resolution chain, for the diagnostic.
18
+ * @throws DependencyResolutionError when either rule is broken.
19
+ */
20
+ export function assertScopedResolvable(token, captor, scopeKey, chain) {
21
+ if (captor !== undefined) {
22
+ throw new DependencyResolutionError(`Captive dependency: singleton "${describe(captor)}" cannot depend on ` +
23
+ `scoped "${describe(token)}". Register the consumer as "scoped" or ` +
24
+ `"transient", or resolve the scoped dependency lazily per call.`, [...chain, token]);
25
+ }
26
+ if (scopeKey === undefined) {
27
+ throw new DependencyResolutionError(`Scoped provider "${describe(token)}" was resolved outside any scope. ` +
28
+ `Resolve it through container.createScope() or inside an execution ` +
29
+ `context.`, [...chain, token]);
30
+ }
31
+ }
32
+ function describe(token) {
33
+ if (typeof token === "function")
34
+ return token.name || "anonymous class";
35
+ if (typeof token === "symbol")
36
+ return token.description ?? token.toString();
37
+ return String(token);
38
+ }
39
+ //# sourceMappingURL=container.lifetime.js.map
@@ -15,7 +15,9 @@ export type Scope =
15
15
  * example the current ExecutionContext of an HTTP request,
16
16
  * background job, message consumption, or RPC call).
17
17
  *
18
- * When no scope is active, scoped providers behave as transient.
18
+ * Resolving a scoped provider with no active scope, or from inside a
19
+ * singleton's construction (a captive dependency), throws a
20
+ * DependencyResolutionError.
19
21
  */
20
22
  | "scoped"
21
23
  /**
@@ -30,6 +30,14 @@ export declare class ContextStorage {
30
30
  *
31
31
  * The context is automatically available to all asynchronous
32
32
  * operations created within the callback.
33
+ *
34
+ * A new execution starts without ContextValues: values bound by an
35
+ * enclosing `runWithValues` belong to that execution (its tenant,
36
+ * user, transaction) and must not leak into an unrelated one started
37
+ * from inside its async scope, such as a listener or consumer created
38
+ * during a request. Re-entering the execution that is already current
39
+ * keeps its values. Use `runDerived` or `runWithValues` to carry
40
+ * values on purpose.
33
41
  */
34
42
  run<T>(context: ExecutionContext, callback: () => T): T;
35
43
  /**
@@ -24,9 +24,20 @@ export class ContextStorage {
24
24
  *
25
25
  * The context is automatically available to all asynchronous
26
26
  * operations created within the callback.
27
+ *
28
+ * A new execution starts without ContextValues: values bound by an
29
+ * enclosing `runWithValues` belong to that execution (its tenant,
30
+ * user, transaction) and must not leak into an unrelated one started
31
+ * from inside its async scope, such as a listener or consumer created
32
+ * during a request. Re-entering the execution that is already current
33
+ * keeps its values. Use `runDerived` or `runWithValues` to carry
34
+ * values on purpose.
27
35
  */
28
36
  run(context, callback) {
29
- return this.storage.run(context, callback);
37
+ if (context === this.storage.getStore()) {
38
+ return this.storage.run(context, callback);
39
+ }
40
+ return this.storage.run(context, () => this.valuesStorage.exit(callback));
30
41
  }
31
42
  /**
32
43
  * Returns the current execution context.
@@ -39,9 +39,12 @@ export interface LifecycleOptions {
39
39
  *
40
40
  * CREATED → INITIALIZING → INITIALIZED → STARTING → RUNNING
41
41
  * RUNNING → STOPPING → STOPPED → (STARTING → RUNNING) restart
42
- * any phase failure → FAILED; initialize()/start() retry from FAILED
43
- * resume with the participant that failed; stop() from FAILED unwinds
44
- * whatever started.
42
+ * any phase failure → FAILED; initialize() retries from FAILED resume
43
+ * with the participant that failed. A failed start() is rolled back:
44
+ * every participant whose start() completed is stopped in reverse
45
+ * order, so a retry starts again from the first one. dispose() only
46
+ * reaches participants whose initialize() ran (including one that
47
+ * threw, which may have acquired resources before failing).
45
48
  *
46
49
  * Concurrent callers of the same phase share the in-flight promise.
47
50
  */
@@ -52,6 +55,8 @@ export declare class Lifecycle {
52
55
  private readonly continueOnShutdownError;
53
56
  private initializedCount;
54
57
  private startedCount;
58
+ /** Participants whose initialize() was invoked, whether or not it threw. */
59
+ private initializeAttempted;
55
60
  private disposed;
56
61
  private failedPhase;
57
62
  private initializePromise;
@@ -1,4 +1,5 @@
1
1
  import { InvalidStateError } from "../../errors/exceptions.js";
2
+ import { rollbackStartedParticipants } from "./lifecycle.rollback.js";
2
3
  /** Lifecycle states supported by the Zudojs application runtime. */
3
4
  export const LifecycleState = {
4
5
  CREATED: "created",
@@ -18,9 +19,12 @@ export const LifecycleState = {
18
19
  *
19
20
  * CREATED → INITIALIZING → INITIALIZED → STARTING → RUNNING
20
21
  * RUNNING → STOPPING → STOPPED → (STARTING → RUNNING) restart
21
- * any phase failure → FAILED; initialize()/start() retry from FAILED
22
- * resume with the participant that failed; stop() from FAILED unwinds
23
- * whatever started.
22
+ * any phase failure → FAILED; initialize() retries from FAILED resume
23
+ * with the participant that failed. A failed start() is rolled back:
24
+ * every participant whose start() completed is stopped in reverse
25
+ * order, so a retry starts again from the first one. dispose() only
26
+ * reaches participants whose initialize() ran (including one that
27
+ * threw, which may have acquired resources before failing).
24
28
  *
25
29
  * Concurrent callers of the same phase share the in-flight promise.
26
30
  */
@@ -31,6 +35,8 @@ export class Lifecycle {
31
35
  continueOnShutdownError;
32
36
  initializedCount = 0;
33
37
  startedCount = 0;
38
+ /** Participants whose initialize() was invoked, whether or not it threw. */
39
+ initializeAttempted = 0;
34
40
  disposed = false;
35
41
  failedPhase;
36
42
  initializePromise;
@@ -194,7 +200,8 @@ export class Lifecycle {
194
200
  }
195
201
  this.disposed = true;
196
202
  const errors = [];
197
- for (let i = this.participants.length - 1; i >= 0; i--) {
203
+ // A participant whose initialize() never ran has nothing to release.
204
+ for (let i = this.initializeAttempted - 1; i >= 0; i--) {
198
205
  const participant = this.participants[i];
199
206
  try {
200
207
  this.logger?.debug("Disposing lifecycle participant", {
@@ -213,6 +220,7 @@ export class Lifecycle {
213
220
  }
214
221
  this.initializedCount = 0;
215
222
  this.startedCount = 0;
223
+ this.initializeAttempted = 0;
216
224
  if (errors.length > 0 && !this.continueOnShutdownError) {
217
225
  this.state = LifecycleState.FAILED;
218
226
  this.failedPhase = "dispose";
@@ -255,6 +263,7 @@ export class Lifecycle {
255
263
  this.logger?.debug("Initializing lifecycle participant", {
256
264
  participant: participant.name,
257
265
  });
266
+ this.initializeAttempted = Math.max(this.initializeAttempted, i + 1);
258
267
  await participant.initialize?.();
259
268
  this.initializedCount = i + 1;
260
269
  }
@@ -285,9 +294,13 @@ export class Lifecycle {
285
294
  this.logger?.info("Application startup completed");
286
295
  }
287
296
  catch (error) {
297
+ this.logger?.error("Application startup failed", error);
298
+ // Roll back rather than leave earlier participants running until
299
+ // someone remembers to call shutdown().
300
+ await rollbackStartedParticipants(this.participants, this.startedCount, this.logger);
301
+ this.startedCount = 0;
288
302
  this.state = LifecycleState.FAILED;
289
303
  this.failedPhase = "start";
290
- this.logger?.error("Application startup failed", error);
291
304
  throw error;
292
305
  }
293
306
  }
@@ -0,0 +1,18 @@
1
+ import type { Logger } from "../../logging/core/logger.js";
2
+ import type { LifecycleParticipant } from "./lifecycle.js";
3
+ /**
4
+ * Stops, in reverse order, the first `count` participants after a
5
+ * failed start.
6
+ *
7
+ * Only participants whose `start()` completed are stopped; the one
8
+ * that threw is left to its own error handling, as `stop()` has always
9
+ * done. Stop failures are logged and returned; they never mask the
10
+ * start error.
11
+ *
12
+ * @param participants - Registered participants, in start order.
13
+ * @param count - How many leading participants started successfully.
14
+ * @param logger - Optional logger for rollback diagnostics.
15
+ * @returns The errors raised by `stop()` hooks during the rollback.
16
+ */
17
+ export declare function rollbackStartedParticipants(participants: readonly LifecycleParticipant[], count: number, logger: Logger | undefined): Promise<readonly unknown[]>;
18
+ //# sourceMappingURL=lifecycle.rollback.d.ts.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Stops, in reverse order, the first `count` participants after a
3
+ * failed start.
4
+ *
5
+ * Only participants whose `start()` completed are stopped; the one
6
+ * that threw is left to its own error handling, as `stop()` has always
7
+ * done. Stop failures are logged and returned; they never mask the
8
+ * start error.
9
+ *
10
+ * @param participants - Registered participants, in start order.
11
+ * @param count - How many leading participants started successfully.
12
+ * @param logger - Optional logger for rollback diagnostics.
13
+ * @returns The errors raised by `stop()` hooks during the rollback.
14
+ */
15
+ export async function rollbackStartedParticipants(participants, count, logger) {
16
+ const errors = [];
17
+ for (let i = count - 1; i >= 0; i--) {
18
+ const participant = participants[i];
19
+ try {
20
+ logger?.debug("Rolling back lifecycle participant", {
21
+ participant: participant.name,
22
+ });
23
+ await participant.stop?.();
24
+ }
25
+ catch (error) {
26
+ errors.push(error);
27
+ logger?.error("Failed to roll back lifecycle participant", error, {
28
+ participant: participant.name,
29
+ });
30
+ }
31
+ }
32
+ return errors;
33
+ }
34
+ //# sourceMappingURL=lifecycle.rollback.js.map
@@ -29,7 +29,7 @@ export class ModuleLifecycleManager {
29
29
  return this.runExclusive(async () => {
30
30
  ensureStateSynchronized(this.registry, this.states);
31
31
  try {
32
- return await executeLifecyclePhase(this.getStartupOrder(), "initialize", "initializing", "initialized", options.continueOnError ?? this.options.continueOnInitializeError, this.registry, this.loader, this.states, this.contextStorage);
32
+ return await executeLifecyclePhase(this.getStartupOrder(), "initialize", "initializing", "initialized", options.continueOnError ?? this.options.continueOnInitializeError, this.registry, this.loader, this.states, this.contextStorage, options.signal);
33
33
  }
34
34
  catch (error) {
35
35
  await this.rollbackAfterFailure(error, { stopFirst: false });
@@ -41,7 +41,7 @@ export class ModuleLifecycleManager {
41
41
  return this.runExclusive(async () => {
42
42
  ensureStateSynchronized(this.registry, this.states);
43
43
  try {
44
- return await executeLifecyclePhase(this.getStartupOrder(), "start", "starting", "started", options.continueOnError ?? this.options.continueOnStartError, this.registry, this.loader, this.states, this.contextStorage);
44
+ return await executeLifecyclePhase(this.getStartupOrder(), "start", "starting", "started", options.continueOnError ?? this.options.continueOnStartError, this.registry, this.loader, this.states, this.contextStorage, options.signal);
45
45
  }
46
46
  catch (error) {
47
47
  await this.rollbackAfterFailure(error, { stopFirst: true });
@@ -38,7 +38,7 @@ export declare function isModuleDestroyed(moduleId: ModuleId, states: LifecycleS
38
38
  export declare function invokeLifecycleHook(module: Module, step: ModuleLifecycleStep, context: ModuleContext, contextStorage?: ContextStorage, phase?: ModuleLifecyclePhase): Promise<void>;
39
39
  export declare function canModuleEnterPhase(moduleId: ModuleId, hook: ModuleLifecycleStep, states: LifecycleStateMap): boolean;
40
40
  export declare function setLifecycleState(moduleId: ModuleId, phase: ModuleLifecyclePhase, states: LifecycleStateMap, error?: unknown): void;
41
- export declare function executeLifecyclePhase(order: readonly ModuleId[], hook: ModuleLifecycleStep, activePhase: ModuleLifecyclePhase, completedPhase: ModuleLifecyclePhase, continueOnError: boolean, registry: ModuleRegistry, loader: ModuleLoader, states: LifecycleStateMap, contextStorage?: ContextStorage): Promise<{
41
+ export declare function executeLifecyclePhase(order: readonly ModuleId[], hook: ModuleLifecycleStep, activePhase: ModuleLifecyclePhase, completedPhase: ModuleLifecyclePhase, continueOnError: boolean, registry: ModuleRegistry, loader: ModuleLoader, states: LifecycleStateMap, contextStorage?: ContextStorage, signal?: AbortSignal): Promise<{
42
42
  readonly completed: readonly ModuleId[];
43
43
  readonly failed: readonly ModuleId[];
44
44
  readonly skipped: readonly ModuleLifecycleSkip[];
@@ -146,13 +146,15 @@ const REQUIRED_DEPENDENCY_PHASES = {
146
146
  initialize: ["initialized", "starting", "started"],
147
147
  start: ["started"],
148
148
  };
149
- export async function executeLifecyclePhase(order, hook, activePhase, completedPhase, continueOnError, registry, loader, states, contextStorage) {
149
+ export async function executeLifecyclePhase(order, hook, activePhase, completedPhase, continueOnError, registry, loader, states, contextStorage, signal) {
150
150
  const completed = [];
151
151
  const failed = [];
152
152
  const skipped = [];
153
153
  const blocked = new Set();
154
154
  const requiredDependencyPhases = REQUIRED_DEPENDENCY_PHASES[hook];
155
155
  for (const moduleId of order) {
156
+ if (signal?.aborted)
157
+ break;
156
158
  const registration = registry.get(moduleId);
157
159
  if (!registration?.instance)
158
160
  continue;
@@ -98,6 +98,13 @@ export interface ModuleLifecyclePhaseOptions {
98
98
  * runtime that drives it.
99
99
  */
100
100
  readonly continueOnError?: boolean;
101
+ /**
102
+ * Abandons the phase once aborted: no further module hook is started.
103
+ * A hook already running completes and its module keeps the phase it
104
+ * reached, so a later stop()/destroy() tears it down. The runtime
105
+ * passes its startup-timeout signal here.
106
+ */
107
+ readonly signal?: AbortSignal;
101
108
  }
102
109
  import { ModuleOperationError } from "../moduleError/moduleError.lifecycle.js";
103
110
  /**
@@ -135,6 +135,7 @@ export declare class DefaultRuntime implements Runtime {
135
135
  private _startPromise;
136
136
  private _stopPromise;
137
137
  private _unwound;
138
+ private _unwindPromise;
138
139
  constructor(dependencies: RuntimeDependencies, options?: RuntimeOptions);
139
140
  get state(): RuntimeState;
140
141
  get context(): RuntimeExecutionContext;
@@ -158,6 +159,7 @@ export declare class DefaultRuntime implements Runtime {
158
159
  * logged and swallowed; the runtime stays FAILED.
159
160
  */
160
161
  private unwind;
162
+ private performUnwind;
161
163
  /**
162
164
  * Runs an operation inside the runtime's execution context so that
163
165
  * module hooks, container factories, and loggers reached from it
@@ -37,6 +37,7 @@ export class DefaultRuntime {
37
37
  _startPromise;
38
38
  _stopPromise;
39
39
  _unwound = false;
40
+ _unwindPromise;
40
41
  constructor(dependencies, options = {}) {
41
42
  this._options = resolveRuntimeOptions(options);
42
43
  this._application = dependencies.application;
@@ -203,9 +204,15 @@ export class DefaultRuntime {
203
204
  this.fail(error);
204
205
  this._logger.error("Runtime failed to start.", error, this.logContext());
205
206
  // The module subsystem has already rolled back when the module
206
- // lifecycle manager threw; a timed-out bootstrap is still
207
- // running and must not be unwound concurrently.
208
- if (!(error instanceof RuntimeTimeoutError)) {
207
+ // lifecycle manager threw. A timed-out bootstrap is still
208
+ // running: its teardown is queued behind the lifecycle manager's
209
+ // lock, so it runs once the in-flight hook settles and reaches
210
+ // every module that came up late. start() rejects now; stop()
211
+ // waits for that teardown.
212
+ if (error instanceof RuntimeTimeoutError) {
213
+ void this.unwind();
214
+ }
215
+ else {
209
216
  await this.unwind();
210
217
  }
211
218
  throw error;
@@ -236,7 +243,11 @@ export class DefaultRuntime {
236
243
  * Best-effort module unwinding used after a failure. Errors are
237
244
  * logged and swallowed; the runtime stays FAILED.
238
245
  */
239
- async unwind() {
246
+ unwind() {
247
+ this._unwindPromise ??= this.performUnwind();
248
+ return this._unwindPromise;
249
+ }
250
+ async performUnwind() {
240
251
  if (this._unwound)
241
252
  return;
242
253
  try {
@@ -38,7 +38,9 @@ export declare function createBootstrapPipelineState(): BootstrapPipelineState;
38
38
  * throws; the thrown error is NOT pushed to `errors` here — the
39
39
  * caller records it exactly once.
40
40
  * - Once `signal` is aborted (timeout) the pipeline stops publishing
41
- * phase changes so an abandoned run cannot mutate the owner.
41
+ * phase changes so an abandoned run cannot mutate the owner, starts
42
+ * no further phase, and the lifecycle manager starts no further
43
+ * module hook.
42
44
  */
43
45
  export declare function executeBootstrapPipeline(options: ResolvedBootstrapOptions, services: BootstrapPipelineServices, state: BootstrapPipelineState, signal: AbortSignal, setPhase: (phase: RuntimeBootstrapPhase) => void, log: BootstrapLogFn): Promise<void>;
44
46
  export declare function createBootstrapResult(success: boolean, phase: RuntimeBootstrapPhase, counters: BootstrapCounters, errors: readonly RuntimeBootstrapErrorInfo[], startedAt: Date, completedAt: Date): RuntimeBootstrapResult;
@@ -15,7 +15,9 @@ export function createBootstrapPipelineState() {
15
15
  * throws; the thrown error is NOT pushed to `errors` here — the
16
16
  * caller records it exactly once.
17
17
  * - Once `signal` is aborted (timeout) the pipeline stops publishing
18
- * phase changes so an abandoned run cannot mutate the owner.
18
+ * phase changes so an abandoned run cannot mutate the owner, starts
19
+ * no further phase, and the lifecycle manager starts no further
20
+ * module hook.
19
21
  */
20
22
  export async function executeBootstrapPipeline(options, services, state, signal, setPhase, log) {
21
23
  const publish = (phase) => {
@@ -29,13 +31,22 @@ export async function executeBootstrapPipeline(options, services, state, signal,
29
31
  if (options.loadModules) {
30
32
  await loadModules(services.moduleLoader, identity, state, publish, log);
31
33
  }
32
- if (options.initializeModules) {
33
- await runLifecyclePhase("initializing", "initialized", () => services.moduleLifecycle.initialize(phaseOptions(options.continueOnInitializeError)), services.moduleLifecycle, options.continueOnInitializeError, (count) => {
34
+ // Every phase and every module hook checks the signal: a timed-out
35
+ // bootstrap must not bring modules up after the runtime has already
36
+ // reported a failed start.
37
+ if (options.initializeModules && !signal.aborted) {
38
+ await runLifecyclePhase("initializing", "initialized", () => services.moduleLifecycle.initialize({
39
+ ...phaseOptions(options.continueOnInitializeError),
40
+ signal,
41
+ }), services.moduleLifecycle, options.continueOnInitializeError, (count) => {
34
42
  state.counters.initializedModules = count;
35
43
  }, (message, opts) => new RuntimeInitializationError(message, { ...identity, ...opts }), state, publish, log);
36
44
  }
37
- if (options.startModules) {
38
- await runLifecyclePhase("starting", "started", () => services.moduleLifecycle.start(phaseOptions(options.continueOnStartError)), services.moduleLifecycle, options.continueOnStartError, (count) => {
45
+ if (options.startModules && !signal.aborted) {
46
+ await runLifecyclePhase("starting", "started", () => services.moduleLifecycle.start({
47
+ ...phaseOptions(options.continueOnStartError),
48
+ signal,
49
+ }), services.moduleLifecycle, options.continueOnStartError, (count) => {
39
50
  state.counters.startedModules = count;
40
51
  }, (message, opts) => new RuntimeStartError(message, {
41
52
  ...identity,
@@ -26,8 +26,10 @@ export const DEFAULT_RUNTIME_OPTIONS = Object.freeze({
26
26
  handleSighup: false,
27
27
  handleUncaughtException: true,
28
28
  handleUnhandledRejection: true,
29
- forceExitOnSecondSignal: false,
29
+ forceExitOnSecondSignal: true,
30
30
  forceExitCode: 1,
31
+ exitOnFatalError: true,
32
+ fatalExitTimeout: 10_000,
31
33
  },
32
34
  diagnostics: {
33
35
  startupLogging: true,
@@ -0,0 +1,14 @@
1
+ import type { RuntimeEnvironmentVariables } from "../runtimeEnvironment/runtimeEnvironment.type.js";
2
+ import type { RuntimeMode } from "./runtimeOptions.type.js";
3
+ /**
4
+ * Derives the runtime mode used when `RuntimeOptions.mode` is not set.
5
+ *
6
+ * `NODE_ENV` is read through `resolveEnvironment()` from `@zudojs/constants`,
7
+ * so every layer maps the same value to the same environment (`prod` and
8
+ * `Production` are production, unset is development). `staging` has no
9
+ * runtime mode of its own and runs as `production`.
10
+ *
11
+ * @param variables - Environment variables to read instead of `process.env`.
12
+ */
13
+ export declare function resolveDefaultRuntimeMode(variables?: RuntimeEnvironmentVariables): RuntimeMode;
14
+ //# sourceMappingURL=runtimeOptions.mode.d.ts.map
@@ -0,0 +1,16 @@
1
+ import { resolveEnvironment } from "@zudojs/constants";
2
+ /**
3
+ * Derives the runtime mode used when `RuntimeOptions.mode` is not set.
4
+ *
5
+ * `NODE_ENV` is read through `resolveEnvironment()` from `@zudojs/constants`,
6
+ * so every layer maps the same value to the same environment (`prod` and
7
+ * `Production` are production, unset is development). `staging` has no
8
+ * runtime mode of its own and runs as `production`.
9
+ *
10
+ * @param variables - Environment variables to read instead of `process.env`.
11
+ */
12
+ export function resolveDefaultRuntimeMode(variables) {
13
+ const environment = resolveEnvironment(variables === undefined ? undefined : { ...variables });
14
+ return environment === "staging" ? "production" : environment;
15
+ }
16
+ //# sourceMappingURL=runtimeOptions.mode.js.map
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_RUNTIME_OPTIONS } from "./runtimeOptions.defaults.js";
2
+ import { resolveDefaultRuntimeMode } from "./runtimeOptions.mode.js";
2
3
  import { validateRuntimeName, validateRuntimeTimeout, assertRuntimeMode, assertRuntimeRole, } from "./runtimeOptions.validation.js";
3
4
  /**
4
5
  * Resolves partial runtime options into a complete immutable runtime configuration.
@@ -14,14 +15,16 @@ export function resolveRuntimeOptions(options = {}) {
14
15
  * accepted silently and make the environment report neither
15
16
  * production, development, nor test.
16
17
  */
17
- assertRuntimeMode(options.mode ?? DEFAULT_RUNTIME_OPTIONS.mode);
18
+ const mode = options.mode ?? resolveDefaultRuntimeMode(options.environment?.variables);
19
+ assertRuntimeMode(mode);
18
20
  assertRuntimeRole(options.role ?? DEFAULT_RUNTIME_OPTIONS.role);
19
21
  validateRuntimeName(options.name);
20
22
  validateRuntimeTimeout(startup.timeoutMs, "startup");
21
23
  validateRuntimeTimeout(shutdown.timeoutMs, "shutdown");
24
+ validateRuntimeTimeout(signals.fatalExitTimeout, "fatal exit");
22
25
  const resolved = {
23
26
  name: options.name ?? DEFAULT_RUNTIME_OPTIONS.name,
24
- mode: options.mode ?? DEFAULT_RUNTIME_OPTIONS.mode,
27
+ mode,
25
28
  role: options.role ?? DEFAULT_RUNTIME_OPTIONS.role,
26
29
  startup: {
27
30
  autoLoadModules: startup.autoLoadModules ??
@@ -58,6 +61,10 @@ export function resolveRuntimeOptions(options = {}) {
58
61
  forceExitOnSecondSignal: signals.forceExitOnSecondSignal ??
59
62
  DEFAULT_RUNTIME_OPTIONS.signals.forceExitOnSecondSignal,
60
63
  forceExitCode: signals.forceExitCode ?? DEFAULT_RUNTIME_OPTIONS.signals.forceExitCode,
64
+ exitOnFatalError: signals.exitOnFatalError ??
65
+ DEFAULT_RUNTIME_OPTIONS.signals.exitOnFatalError,
66
+ fatalExitTimeout: signals.fatalExitTimeout ??
67
+ DEFAULT_RUNTIME_OPTIONS.signals.fatalExitTimeout,
61
68
  },
62
69
  diagnostics: {
63
70
  startupLogging: diagnostics.startupLogging ??
@@ -87,5 +94,6 @@ export function validateRuntimeOptions(options) {
87
94
  validateRuntimeName(options.name);
88
95
  validateRuntimeTimeout(options.startup?.timeoutMs, "startup");
89
96
  validateRuntimeTimeout(options.shutdown?.timeoutMs, "shutdown");
97
+ validateRuntimeTimeout(options.signals?.fatalExitTimeout, "fatal exit");
90
98
  }
91
99
  //# sourceMappingURL=runtimeOptions.resolver.js.map
@@ -63,12 +63,27 @@ export interface RuntimeSignalOptions {
63
63
  /**
64
64
  * When a second termination signal arrives while a graceful stop is
65
65
  * already in progress, exit the process immediately with
66
- * `forceExitCode`. Off by default: the second signal is logged and
67
- * ignored.
66
+ * `forceExitCode`. On by default, matching `@zudojs/runtime`: an
67
+ * operator pressing Ctrl-C again on a stuck shutdown is asking for
68
+ * exactly that. Set to `false` to log and ignore the second signal.
68
69
  */
69
70
  readonly forceExitOnSecondSignal?: boolean;
70
71
  /** Exit code used by `forceExitOnSecondSignal`. Defaults to 1. */
71
72
  readonly forceExitCode?: number;
73
+ /**
74
+ * After an uncaught exception or unhandled rejection has stopped the
75
+ * runtime, exit the process with code 1. On by default: the
76
+ * installed handler suppresses Node's own crash, so without this the
77
+ * process exited 0 and supervisors never restarted or alerted. Set to
78
+ * `false` to stop the runtime and leave the process running.
79
+ */
80
+ readonly exitOnFatalError?: boolean;
81
+ /**
82
+ * How long that fatal-error shutdown may take before the process
83
+ * exits anyway, in milliseconds. `0` waits for the shutdown however
84
+ * long it takes. Defaults to 10000.
85
+ */
86
+ readonly fatalExitTimeout?: number;
72
87
  }
73
88
  /**
74
89
  * Options controlling runtime diagnostics.
@@ -97,6 +112,7 @@ export interface RuntimeEnvironmentOverrides {
97
112
  */
98
113
  export interface RuntimeOptions {
99
114
  readonly name?: string;
115
+ /** Omitted: derived from NODE_ENV by `resolveEnvironment()` (@zudojs/constants); staging runs as production. */
100
116
  readonly mode?: RuntimeMode;
101
117
  readonly role?: RuntimeRole;
102
118
  readonly startup?: RuntimeStartupOptions;
@@ -22,5 +22,5 @@ export declare function validateRuntimeName(name: string | undefined): void;
22
22
  /**
23
23
  * Validates a runtime timeout.
24
24
  */
25
- export declare function validateRuntimeTimeout(timeout: number | undefined, field: "startup" | "shutdown"): void;
25
+ export declare function validateRuntimeTimeout(timeout: number | undefined, field: "startup" | "shutdown" | "fatal exit"): void;
26
26
  //# sourceMappingURL=runtimeOptions.validation.d.ts.map
@@ -1,5 +1,11 @@
1
1
  import type { Logger } from "../../logging/core/logger.js";
2
2
  import type { RuntimeSignalOptions } from "../runtimeOptions/runtimeOptions.type.js";
3
+ /**
4
+ * Fatal-exit settings are optional on the manager so that a
5
+ * hand-assembled `signals` object from before they existed still
6
+ * type-checks; they default to exiting after 10 seconds.
7
+ */
8
+ type ManagerSignalOptions = Required<Omit<RuntimeSignalOptions, "exitOnFatalError" | "fatalExitTimeout">> & Pick<RuntimeSignalOptions, "exitOnFatalError" | "fatalExitTimeout">;
3
9
  /**
4
10
  * Termination signals the runtime can react to.
5
11
  */
@@ -39,7 +45,7 @@ export interface RuntimeSignalHandlers {
39
45
  * Options for the signal manager.
40
46
  */
41
47
  export interface RuntimeSignalManagerOptions {
42
- readonly signals: Required<RuntimeSignalOptions>;
48
+ readonly signals: ManagerSignalOptions;
43
49
  readonly target?: RuntimeSignalTarget;
44
50
  readonly logger?: Logger;
45
51
  }
@@ -52,8 +58,11 @@ export interface RuntimeSignalManagerOptions {
52
58
  * `unregister()`; both are idempotent.
53
59
  * - The first termination signal triggers `onSignal` (graceful stop).
54
60
  * - A second termination signal while the first is still being
55
- * handled is logged and ignored, unless `forceExitOnSecondSignal`
56
- * is on, in which case `target.exit(forceExitCode)` is called.
61
+ * handled exits with `forceExitCode` when `forceExitOnSecondSignal`
62
+ * is on (the default), and is logged and ignored otherwise.
63
+ * - An uncaught exception or unhandled rejection runs the fatal
64
+ * handler and then exits with code 1, unless `exitOnFatalError` is
65
+ * off; `fatalExitTimeout` bounds a shutdown that hangs.
57
66
  * - `process.exit` is never called otherwise.
58
67
  */
59
68
  export declare class RuntimeSignalManager {
@@ -88,6 +97,8 @@ export declare class RuntimeSignalManager {
88
97
  unregister(): void;
89
98
  private createListener;
90
99
  private handleSignal;
100
+ private runFatal;
91
101
  private run;
92
102
  }
103
+ export {};
93
104
  //# sourceMappingURL=runtimeSignals.d.ts.map
@@ -0,0 +1,29 @@
1
+ import type { Logger } from "../../logging/core/logger.js";
2
+ import type { RuntimeSignalTarget } from "./runtimeSignals.js";
3
+ /** Default grace period for a fatal-error shutdown, in milliseconds. */
4
+ export declare const DEFAULT_FATAL_EXIT_TIMEOUT = 10000;
5
+ /**
6
+ * How a fatal process event (uncaughtException, unhandledRejection)
7
+ * ends the process.
8
+ */
9
+ export interface RuntimeFatalExitPolicy {
10
+ /** Exit non-zero once the fatal-error shutdown settles. */
11
+ readonly exitOnFatalError: boolean;
12
+ /** Exit anyway if that shutdown takes longer than this. */
13
+ readonly fatalExitTimeout: number;
14
+ /** Exit code used for a fatal exit. */
15
+ readonly exitCode: number;
16
+ }
17
+ /**
18
+ * Runs the runtime's fatal-error handler and then ends the process
19
+ * with a non-zero code.
20
+ *
21
+ * Installing an `uncaughtException` listener suppresses Node's own
22
+ * crash, so without an explicit exit the process either ended with
23
+ * code 0 (supervisors saw a clean exit and never restarted or alerted)
24
+ * or lingered as a stopped zombie behind any open handle. The grace
25
+ * timer bounds a shutdown that hangs; both are skipped when
26
+ * `exitOnFatalError` is off.
27
+ */
28
+ export declare function runFatalHandler(handler: () => void | Promise<void> | undefined, target: RuntimeSignalTarget | undefined, policy: RuntimeFatalExitPolicy, logger: Logger | undefined, event: string): void;
29
+ //# sourceMappingURL=runtimeSignals.fatal.d.ts.map
@@ -0,0 +1,51 @@
1
+ /** Default grace period for a fatal-error shutdown, in milliseconds. */
2
+ export const DEFAULT_FATAL_EXIT_TIMEOUT = 10_000;
3
+ /**
4
+ * Runs the runtime's fatal-error handler and then ends the process
5
+ * with a non-zero code.
6
+ *
7
+ * Installing an `uncaughtException` listener suppresses Node's own
8
+ * crash, so without an explicit exit the process either ended with
9
+ * code 0 (supervisors saw a clean exit and never restarted or alerted)
10
+ * or lingered as a stopped zombie behind any open handle. The grace
11
+ * timer bounds a shutdown that hangs; both are skipped when
12
+ * `exitOnFatalError` is off.
13
+ */
14
+ export function runFatalHandler(handler, target, policy, logger, event) {
15
+ const exit = () => {
16
+ if (!policy.exitOnFatalError)
17
+ return;
18
+ target?.exit?.(policy.exitCode);
19
+ };
20
+ let timer;
21
+ if (policy.exitOnFatalError && policy.fatalExitTimeout > 0) {
22
+ timer = setTimeout(() => {
23
+ logger?.error(`Runtime shutdown after ${event} timed out; exiting.`, {
24
+ event,
25
+ timeoutMs: policy.fatalExitTimeout,
26
+ });
27
+ exit();
28
+ }, Math.min(policy.fatalExitTimeout, 2_147_483_647));
29
+ timer.unref?.();
30
+ }
31
+ const finish = () => {
32
+ if (timer !== undefined)
33
+ clearTimeout(timer);
34
+ exit();
35
+ };
36
+ let result;
37
+ try {
38
+ result = handler();
39
+ }
40
+ catch (error) {
41
+ logger?.error(`Runtime ${event} handler failed.`, error, { event });
42
+ finish();
43
+ return;
44
+ }
45
+ void Promise.resolve(result)
46
+ .catch((error) => {
47
+ logger?.error(`Runtime ${event} handler failed.`, error, { event });
48
+ })
49
+ .finally(finish);
50
+ }
51
+ //# sourceMappingURL=runtimeSignals.fatal.js.map
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_FATAL_EXIT_TIMEOUT, runFatalHandler, } from "./runtimeSignals.fatal.js";
1
2
  /**
2
3
  * Registers and removes process signal/exception handlers on behalf
3
4
  * of the runtime.
@@ -7,8 +8,11 @@
7
8
  * `unregister()`; both are idempotent.
8
9
  * - The first termination signal triggers `onSignal` (graceful stop).
9
10
  * - A second termination signal while the first is still being
10
- * handled is logged and ignored, unless `forceExitOnSecondSignal`
11
- * is on, in which case `target.exit(forceExitCode)` is called.
11
+ * handled exits with `forceExitCode` when `forceExitOnSecondSignal`
12
+ * is on (the default), and is logged and ignored otherwise.
13
+ * - An uncaught exception or unhandled rejection runs the fatal
14
+ * handler and then exits with code 1, unless `exitOnFatalError` is
15
+ * off; `fatalExitTimeout` bounds a shutdown that hangs.
12
16
  * - `process.exit` is never called otherwise.
13
17
  */
14
18
  export class RuntimeSignalManager {
@@ -91,11 +95,11 @@ export class RuntimeSignalManager {
91
95
  };
92
96
  case "uncaughtException":
93
97
  return ((error) => {
94
- this.run(() => this._handlers?.onUncaughtException(error), "uncaughtException");
98
+ this.runFatal(() => this._handlers?.onUncaughtException(error), "uncaughtException");
95
99
  });
96
100
  case "unhandledRejection":
97
101
  return ((reason) => {
98
- this.run(() => this._handlers?.onUnhandledRejection(reason), "unhandledRejection");
102
+ this.runFatal(() => this._handlers?.onUnhandledRejection(reason), "unhandledRejection");
99
103
  });
100
104
  default:
101
105
  return () => { };
@@ -118,6 +122,13 @@ export class RuntimeSignalManager {
118
122
  });
119
123
  this.run(() => this._handlers?.onSignal(signal), signal);
120
124
  }
125
+ runFatal(handler, event) {
126
+ runFatalHandler(handler, this._target, {
127
+ exitOnFatalError: this._signals.exitOnFatalError ?? true,
128
+ fatalExitTimeout: this._signals.fatalExitTimeout ?? DEFAULT_FATAL_EXIT_TIMEOUT,
129
+ exitCode: 1,
130
+ }, this._logger, event);
131
+ }
121
132
  run(handler, event) {
122
133
  try {
123
134
  const result = handler();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/core",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Application lifecycle management, execution context propagation, and runtime orchestration for Zudojs applications.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,8 +55,8 @@
55
55
  "vitest": "^4.1.11"
56
56
  },
57
57
  "dependencies": {
58
- "@zudojs/errors": "1.0.1",
59
- "@zudojs/constants": "1.0.1"
58
+ "@zudojs/errors": "1.2.0",
59
+ "@zudojs/constants": "1.1.1"
60
60
  },
61
61
  "license": "MIT",
62
62
  "author": {