@zudojs/core 1.1.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 +14 -4
- package/dist/configuration/configurationManager.manager.js +1 -1
- package/dist/container/container.d.ts +4 -2
- package/dist/container/container.js +11 -0
- package/dist/container/container.lifetime.d.ts +21 -0
- package/dist/container/container.lifetime.js +39 -0
- package/dist/container/scope.d.ts +3 -1
- package/dist/context/provider/contextStorage.storage.d.ts +8 -0
- package/dist/context/provider/contextStorage.storage.js +12 -1
- package/dist/lifecycle/core/lifecycle.d.ts +8 -3
- package/dist/lifecycle/core/lifecycle.js +18 -5
- package/dist/lifecycle/core/lifecycle.rollback.d.ts +18 -0
- package/dist/lifecycle/core/lifecycle.rollback.js +34 -0
- package/dist/modules/moduleLifecycle/moduleLifecycle.lifecycle.js +2 -2
- package/dist/modules/moduleLifecycle/moduleLifecycle.stateMachine.d.ts +1 -1
- package/dist/modules/moduleLifecycle/moduleLifecycle.stateMachine.js +3 -1
- package/dist/modules/moduleLifecycle/moduleLifecycle.type.d.ts +7 -0
- package/dist/runtime/runtime.d.ts +2 -0
- package/dist/runtime/runtime.js +15 -4
- package/dist/runtime/runtimeBootstrap/pipeline/runtimeBootstrap.pipeline.d.ts +3 -1
- package/dist/runtime/runtimeBootstrap/pipeline/runtimeBootstrap.pipeline.js +16 -5
- package/dist/runtime/runtimeOptions/runtimeOptions.defaults.js +3 -1
- package/dist/runtime/runtimeOptions/runtimeOptions.mode.d.ts +14 -0
- package/dist/runtime/runtimeOptions/runtimeOptions.mode.js +16 -0
- package/dist/runtime/runtimeOptions/runtimeOptions.resolver.js +10 -2
- package/dist/runtime/runtimeOptions/runtimeOptions.type.d.ts +18 -2
- package/dist/runtime/runtimeOptions/runtimeOptions.validation.d.ts +1 -1
- package/dist/runtime/runtimeSignals/runtimeSignals.d.ts +14 -3
- package/dist/runtime/runtimeSignals/runtimeSignals.fatal.d.ts +29 -0
- package/dist/runtime/runtimeSignals/runtimeSignals.fatal.js +51 -0
- package/dist/runtime/runtimeSignals/runtimeSignals.js +15 -4
- 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
|
|
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
|
-
|
|
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,
|
|
16
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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()
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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()
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
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
|
package/dist/runtime/runtime.js
CHANGED
|
@@ -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
|
|
207
|
-
// running
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
|
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`.
|
|
67
|
-
*
|
|
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:
|
|
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
|
|
56
|
-
* is on,
|
|
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
|
|
11
|
-
* is on,
|
|
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.
|
|
98
|
+
this.runFatal(() => this._handlers?.onUncaughtException(error), "uncaughtException");
|
|
95
99
|
});
|
|
96
100
|
case "unhandledRejection":
|
|
97
101
|
return ((reason) => {
|
|
98
|
-
this.
|
|
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.
|
|
3
|
+
"version": "1.2.0",
|
|
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
|
|
59
|
-
"@zudojs/constants": "1.0
|
|
58
|
+
"@zudojs/errors": "1.1.0",
|
|
59
|
+
"@zudojs/constants": "1.1.0"
|
|
60
60
|
},
|
|
61
61
|
"license": "MIT",
|
|
62
62
|
"author": {
|