@znight/core 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -153,7 +153,11 @@ token; keys may be tokens or classes. Override values that look like a provider
153
153
  (start listening), then providers' and plugins' `onReady`. Signal handlers are installed when the start
154
154
  begins. If a step fails, what was started is stopped, everything is disposed and the error is rethrown.
155
155
 
156
- **`app.stop()`** is idempotent and waits for an in-progress start. It runs three phases:
156
+ **`app.stop()`** is idempotent and waits for an in-progress start, at most `hookTimeoutMs` (a start
157
+ hook that never settles cannot block the shutdown: past that budget, a `Waiting for start() timed out`
158
+ failure is recorded, the remaining start hooks are skipped and what completed its `onStart` is
159
+ stopped; a plugin whose `onStart` is still in flight is stopped and disposed when that hook settles,
160
+ not before). It runs three phases:
157
161
 
158
162
  1. plugins' `onStop`, in reverse order (stop ingress, drain in-flight work);
159
163
  2. for each provider in reverse resolution order: `onStop` (if it was started), then disposal;
@@ -271,7 +275,8 @@ active scope (`get`, `set`, `has`). Plugins (HTTP, messaging) open the scopes fo
271
275
  `container.openScope(values?)` opens the same scope without a callback, for hot paths that stay
272
276
  synchronous as long as their work is (the HTTP pipeline): `run(fn, ...args)` calls `fn` in the scope
273
277
  and returns its result (its continuations stay in the scope), `close()` disposes the instances created
274
- in it. `close()` returns `undefined` when there is nothing to dispose, otherwise a promise that
278
+ in it. `close()` returns `undefined` when there is nothing to dispose (no instance created in the scope
279
+ has a dispose method, `[Symbol.asyncDispose]` or `[Symbol.dispose]`), otherwise a promise that
275
280
  rejects with `ZNIGHT021` when a disposal fails; it must be called once the work is over.
276
281
 
277
282
  ```ts
package/dist/index.d.mts CHANGED
@@ -1167,11 +1167,12 @@ export declare class Application {
1167
1167
  */
1168
1168
  start(): Promise<void>;
1169
1169
  /**
1170
- * Stops the application (idempotent; waits for an in-progress `start()`):
1171
- * plugins' `onStop` (reverse) → providers' `onStop` + disposal (reverse resolution order) →
1172
- * plugins' `onDispose` (reverse). Every step is attempted, each within `hookTimeoutMs` and all
1173
- * within `timeoutMs`; failures are collected into a ZnightAggregateError (ZNIGHT021). Signal
1174
- * handlers are removed.
1170
+ * Stops the application (idempotent; waits for an in-progress `start()`, as a step bounded like
1171
+ * the others: past it, the remaining start hooks are skipped and what completed its `onStart` is
1172
+ * stopped): plugins' `onStop` (reverse) → providers' `onStop` + disposal (reverse resolution
1173
+ * order) → plugins' `onDispose` (reverse). Every step is attempted, each within `hookTimeoutMs`
1174
+ * and all within `timeoutMs`; failures are collected into a ZnightAggregateError (ZNIGHT021).
1175
+ * Signal handlers are removed.
1175
1176
  *
1176
1177
  * Called from a start hook (`onStart`/`onReady` of a provider or plugin, or work it awaits), it
1177
1178
  * does not wait for the start, which waits for that hook: the shutdown runs right away, stopping
package/dist/index.mjs CHANGED
@@ -938,6 +938,11 @@ var Container = class Container {
938
938
  #storage = new AsyncLocalStorage();
939
939
  /** Instantiated singletons, in creation order. */
940
940
  #created = [];
941
+ /**
942
+ * Request-scoped and transient slots whose factory is running. Creation is synchronous, so a slot
943
+ * found here again was requested by its own construction (a `Lazy<T>.get()` in a constructor).
944
+ */
945
+ #constructing = /* @__PURE__ */ new Set();
941
946
  #order = [];
942
947
  #names;
943
948
  #status = "open";
@@ -1261,21 +1266,29 @@ var Container = class Container {
1261
1266
  /** Instantiates a request-scoped or transient provider (synchronous factories only). */
1262
1267
  #create(slot) {
1263
1268
  const definition = slot.definition;
1269
+ const constructing = this.#constructing;
1270
+ if (constructing.has(slot)) throw reentrantCreationError(slot);
1271
+ constructing.add(slot);
1264
1272
  let deps;
1265
- try {
1266
- deps = this.#resolveDeps(slot);
1267
- } catch (error) {
1268
- throw error instanceof InstantiationError ? error.withParents([slot.token]) : error;
1269
- }
1270
1273
  let instance;
1271
1274
  try {
1272
- instance = definition.factory(deps);
1273
- } catch (error) {
1274
- throw new InstantiationError(slot.token, [slot.token], error, definition.source);
1275
+ try {
1276
+ deps = this.#resolveDeps(slot);
1277
+ } catch (error) {
1278
+ throw error instanceof InstantiationError ? error.withParents([slot.token]) : error;
1279
+ }
1280
+ try {
1281
+ instance = definition.factory(deps);
1282
+ } catch (error) {
1283
+ throw new InstantiationError(slot.token, [slot.token], error, definition.source);
1284
+ }
1285
+ } finally {
1286
+ constructing.delete(slot);
1275
1287
  }
1276
1288
  if (definition.interceptors !== void 0) try {
1277
1289
  applyInterceptors(instance, definition.interceptors, deps, slot.displayName);
1278
1290
  } catch (error) {
1291
+ disposeQuietly(instance, definition.dispose);
1279
1292
  throw new InstantiationError(slot.token, [slot.token], error, definition.source);
1280
1293
  }
1281
1294
  return instance;
@@ -1290,10 +1303,11 @@ var Container = class Container {
1290
1303
  this.#assertResolvable(slot);
1291
1304
  const instance = this.#create(slot);
1292
1305
  values.set(slot.token, instance);
1293
- scope.created.push({
1306
+ const dispose = slot.definition?.dispose;
1307
+ if (isDisposable(instance, dispose)) scope.created.push({
1294
1308
  token: slot.token,
1295
1309
  instance,
1296
- dispose: slot.definition?.dispose
1310
+ dispose
1297
1311
  });
1298
1312
  return instance;
1299
1313
  }
@@ -1942,6 +1956,13 @@ var Application = class Application {
1942
1956
  #startAborted = false;
1943
1957
  /** Releases a `stop()` waiting for the start, when a start hook asks to stop in turn. */
1944
1958
  #releaseStopWait;
1959
+ /** Owner of the `onStart` hook in progress. */
1960
+ #starting;
1961
+ /**
1962
+ * Owner of the `onStart` hook still in flight when the wait of `stop()` timed out: the shutdown
1963
+ * skips its `onDispose`, and it is stopped and disposed when that hook settles.
1964
+ */
1965
+ #lateStart;
1945
1966
  constructor(options) {
1946
1967
  const plugins = [...options.plugins ?? []];
1947
1968
  const byName = /* @__PURE__ */ new Map();
@@ -2075,11 +2096,11 @@ var Application = class Application {
2075
2096
  async #runStartHooks() {
2076
2097
  const providers = this.container.instances();
2077
2098
  for (const provider of providers) {
2078
- if (!await this.#startHook(() => this.#callProvider(provider, "onStart"))) return false;
2099
+ if (!await this.#startHook(() => this.#callProvider(provider, "onStart"), { provider })) return false;
2079
2100
  this.#startedProviders.add(provider.token);
2080
2101
  }
2081
2102
  for (const plugin of this.#plugins) {
2082
- if (!await this.#startHook(() => this.#callPlugin(plugin, "onStart"))) return false;
2103
+ if (!await this.#startHook(() => this.#callPlugin(plugin, "onStart"), { plugin })) return false;
2083
2104
  this.#startedPlugins.push(plugin);
2084
2105
  }
2085
2106
  for (const provider of providers) if (!await this.#startHook(() => this.#callProvider(provider, "onReady"))) return false;
@@ -2089,17 +2110,41 @@ var Application = class Application {
2089
2110
  /**
2090
2111
  * Runs one start hook in an async context carrying a fresh marker, so that a `stop()` called from
2091
2112
  * it (or from work it awaits) is recognised. Returns `false` when the hook stopped the application.
2113
+ * `owner` is given for `onStart` hooks: one that settles after `stop()` stopped waiting for it is
2114
+ * stopped and disposed then (see `#stopLateStart`).
2092
2115
  */
2093
- async #startHook(hook) {
2116
+ async #startHook(hook, owner) {
2094
2117
  const marker = {};
2095
2118
  this.#startHookMarker = marker;
2119
+ this.#starting = owner;
2120
+ let started = false;
2096
2121
  try {
2097
2122
  await startHookContext.run(marker, hook);
2123
+ started = true;
2098
2124
  } finally {
2099
2125
  this.#startHookMarker = void 0;
2126
+ this.#starting = void 0;
2127
+ if (owner !== void 0 && this.#lateStart === owner) await this.#stopLateStart(owner, started);
2100
2128
  }
2101
2129
  return !this.#startAborted;
2102
2130
  }
2131
+ /**
2132
+ * Stops what an `onStart` hook settling after the shutdown started (best effort, failures logged):
2133
+ * a plugin gets `onStop` (when its `onStart` succeeded) then the `onDispose` the shutdown skipped;
2134
+ * a provider, already disposed by the shutdown, gets `onStop` when its `onStart` succeeded.
2135
+ */
2136
+ async #stopLateStart(owner, started) {
2137
+ const { plugin, provider } = owner;
2138
+ const label = plugin === void 0 ? `${provider?.displayName}.onStart()` : `Plugin "${plugin.name}" onStart()`;
2139
+ this.#logger.warn(`${label} settled after the shutdown stopped waiting for it: stopping it now`);
2140
+ const errors = [];
2141
+ const step = this.#stepRunner(errors);
2142
+ if (plugin !== void 0) {
2143
+ if (started && typeof plugin.onStop === "function") await step(`Plugin "${plugin.name}" onStop()`, () => plugin.onStop?.(this.#context));
2144
+ if (typeof plugin.onDispose === "function") await step(`Plugin "${plugin.name}" onDispose()`, () => plugin.onDispose?.(this.#context));
2145
+ } else if (provider !== void 0 && started && hasLifecycleHook(provider.instance, "onStop")) await step(`${provider.displayName}.onStop()`, () => callLifecycleHook(provider.instance, "onStop"));
2146
+ for (const error of errors) this.#logger.error("Failed to stop a late start", { err: error });
2147
+ }
2103
2148
  /** Whether the caller runs in the async context of the start hook in progress. */
2104
2149
  #calledFromStartHook() {
2105
2150
  const marker = this.#startHookMarker;
@@ -2112,11 +2157,12 @@ var Application = class Application {
2112
2157
  } catch {}
2113
2158
  }
2114
2159
  /**
2115
- * Stops the application (idempotent; waits for an in-progress `start()`):
2116
- * plugins' `onStop` (reverse) → providers' `onStop` + disposal (reverse resolution order) →
2117
- * plugins' `onDispose` (reverse). Every step is attempted, each within `hookTimeoutMs` and all
2118
- * within `timeoutMs`; failures are collected into a ZnightAggregateError (ZNIGHT021). Signal
2119
- * handlers are removed.
2160
+ * Stops the application (idempotent; waits for an in-progress `start()`, as a step bounded like
2161
+ * the others: past it, the remaining start hooks are skipped and what completed its `onStart` is
2162
+ * stopped): plugins' `onStop` (reverse) → providers' `onStop` + disposal (reverse resolution
2163
+ * order) → plugins' `onDispose` (reverse). Every step is attempted, each within `hookTimeoutMs`
2164
+ * and all within `timeoutMs`; failures are collected into a ZnightAggregateError (ZNIGHT021).
2165
+ * Signal handlers are removed.
2120
2166
  *
2121
2167
  * Called from a start hook (`onStart`/`onReady` of a provider or plugin, or work it awaits), it
2122
2168
  * does not wait for the start, which waits for that hook: the shutdown runs right away, stopping
@@ -2131,13 +2177,22 @@ var Application = class Application {
2131
2177
  return this.#stopPromise;
2132
2178
  }
2133
2179
  async #runStop() {
2134
- if (this.#state === "starting" && !this.#startAborted) await this.#waitForStart();
2180
+ const errors = [];
2181
+ const step = this.#stepRunner(errors);
2182
+ if (this.#state === "starting" && !this.#startAborted) {
2183
+ await step("Waiting for start()", () => this.#waitForStart());
2184
+ if (this.#state === "starting") {
2185
+ this.#startAborted = true;
2186
+ this.#releaseStopWait = void 0;
2187
+ this.#lateStart = this.#starting;
2188
+ }
2189
+ }
2135
2190
  if (this.#state === "failed" || this.#state === "stopped") {
2136
2191
  this.#removeSignals();
2137
2192
  return;
2138
2193
  }
2139
2194
  this.#state = "stopping";
2140
- const errors = await this.#shutdown();
2195
+ await this.#shutdown(errors, step);
2141
2196
  this.#removeSignals();
2142
2197
  this.#state = "stopped";
2143
2198
  if (errors.length > 0) throw new ZnightAggregateError(ErrorCode.LifecycleFailed, `The application stopped with ${errors.length} error(s)`, errors);
@@ -2158,11 +2213,9 @@ var Application = class Application {
2158
2213
  }
2159
2214
  /**
2160
2215
  * The three shutdown phases (also used to roll back a failed create/start: only what was started
2161
- * is stopped). Never throws: returns the errors of the failed steps.
2216
+ * is stopped). Never throws: returns the errors of the failed steps (appended to `errors`).
2162
2217
  */
2163
- async #shutdown() {
2164
- const errors = [];
2165
- const step = this.#stepRunner(errors);
2218
+ async #shutdown(errors = [], step = this.#stepRunner(errors)) {
2166
2219
  for (const plugin of [...this.#startedPlugins].reverse()) if (typeof plugin.onStop === "function") await step(`Plugin "${plugin.name}" onStop()`, () => plugin.onStop?.(this.#context));
2167
2220
  for (const provider of this.container.instances().reverse()) {
2168
2221
  if (this.#startedProviders.has(provider.token) && hasLifecycleHook(provider.instance, "onStop")) await step(`${provider.displayName}.onStop()`, () => callLifecycleHook(provider.instance, "onStop"));
@@ -2174,7 +2227,7 @@ var Application = class Application {
2174
2227
  } catch (error) {
2175
2228
  errors.push(...flatten(error));
2176
2229
  }
2177
- for (const plugin of [...this.#registered].reverse()) if (typeof plugin.onDispose === "function") await step(`Plugin "${plugin.name}" onDispose()`, () => plugin.onDispose?.(this.#context));
2230
+ for (const plugin of [...this.#registered].reverse()) if (typeof plugin.onDispose === "function" && plugin !== this.#lateStart?.plugin) await step(`Plugin "${plugin.name}" onDispose()`, () => plugin.onDispose?.(this.#context));
2178
2231
  this.#startedPlugins.length = 0;
2179
2232
  this.#startedProviders.clear();
2180
2233
  return errors;