@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@znight/core",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Znight core runtime: compile-time DI container, lifecycle, plugins and marker decorators",
5
5
  "license": "MIT",
6
6
  "author": "Ugho STEPHAN",
@@ -116,6 +116,13 @@ export class Application {
116
116
  #startAborted = false
117
117
  /** Releases a `stop()` waiting for the start, when a start hook asks to stop in turn. */
118
118
  #releaseStopWait: (() => void) | undefined
119
+ /** Owner of the `onStart` hook in progress. */
120
+ #starting: StartOwner | undefined
121
+ /**
122
+ * Owner of the `onStart` hook still in flight when the wait of `stop()` timed out: the shutdown
123
+ * skips its `onDispose`, and it is stopped and disposed when that hook settles.
124
+ */
125
+ #lateStart: StartOwner | undefined
119
126
 
120
127
  private constructor(options: ApplicationOptions) {
121
128
  const plugins = [...(options.plugins ?? [])]
@@ -292,11 +299,11 @@ export class Application {
292
299
  async #runStartHooks(): Promise<boolean> {
293
300
  const providers = this.container.instances()
294
301
  for (const provider of providers) {
295
- if (!(await this.#startHook(() => this.#callProvider(provider, 'onStart')))) return false
302
+ if (!(await this.#startHook(() => this.#callProvider(provider, 'onStart'), { provider }))) return false
296
303
  this.#startedProviders.add(provider.token)
297
304
  }
298
305
  for (const plugin of this.#plugins) {
299
- if (!(await this.#startHook(() => this.#callPlugin(plugin, 'onStart')))) return false
306
+ if (!(await this.#startHook(() => this.#callPlugin(plugin, 'onStart'), { plugin }))) return false
300
307
  this.#startedPlugins.push(plugin)
301
308
  }
302
309
  for (const provider of providers) {
@@ -311,18 +318,51 @@ export class Application {
311
318
  /**
312
319
  * Runs one start hook in an async context carrying a fresh marker, so that a `stop()` called from
313
320
  * it (or from work it awaits) is recognised. Returns `false` when the hook stopped the application.
321
+ * `owner` is given for `onStart` hooks: one that settles after `stop()` stopped waiting for it is
322
+ * stopped and disposed then (see `#stopLateStart`).
314
323
  */
315
- async #startHook(hook: () => Promise<void>): Promise<boolean> {
324
+ async #startHook(hook: () => Promise<void>, owner?: StartOwner): Promise<boolean> {
316
325
  const marker = {}
317
326
  this.#startHookMarker = marker
327
+ this.#starting = owner
328
+ let started = false
318
329
  try {
319
330
  await startHookContext.run(marker, hook)
331
+ started = true
320
332
  } finally {
321
333
  this.#startHookMarker = undefined
334
+ this.#starting = undefined
335
+ if (owner !== undefined && this.#lateStart === owner) {
336
+ await this.#stopLateStart(owner, started)
337
+ }
322
338
  }
323
339
  return !this.#startAborted
324
340
  }
325
341
 
342
+ /**
343
+ * Stops what an `onStart` hook settling after the shutdown started (best effort, failures logged):
344
+ * a plugin gets `onStop` (when its `onStart` succeeded) then the `onDispose` the shutdown skipped;
345
+ * a provider, already disposed by the shutdown, gets `onStop` when its `onStart` succeeded.
346
+ */
347
+ async #stopLateStart(owner: StartOwner, started: boolean): Promise<void> {
348
+ const { plugin, provider } = owner
349
+ const label = plugin === undefined ? `${provider?.displayName}.onStart()` : `Plugin "${plugin.name}" onStart()`
350
+ this.#logger.warn(`${label} settled after the shutdown stopped waiting for it: stopping it now`)
351
+ const errors: unknown[] = []
352
+ const step = this.#stepRunner(errors)
353
+ if (plugin !== undefined) {
354
+ if (started && typeof plugin.onStop === 'function') {
355
+ await step(`Plugin "${plugin.name}" onStop()`, () => plugin.onStop?.(this.#context))
356
+ }
357
+ if (typeof plugin.onDispose === 'function') {
358
+ await step(`Plugin "${plugin.name}" onDispose()`, () => plugin.onDispose?.(this.#context))
359
+ }
360
+ } else if (provider !== undefined && started && hasLifecycleHook(provider.instance, 'onStop')) {
361
+ await step(`${provider.displayName}.onStop()`, () => callLifecycleHook(provider.instance, 'onStop'))
362
+ }
363
+ for (const error of errors) this.#logger.error('Failed to stop a late start', { err: error })
364
+ }
365
+
326
366
  /** Whether the caller runs in the async context of the start hook in progress. */
327
367
  #calledFromStartHook(): boolean {
328
368
  const marker = this.#startHookMarker
@@ -339,11 +379,12 @@ export class Application {
339
379
  }
340
380
 
341
381
  /**
342
- * Stops the application (idempotent; waits for an in-progress `start()`):
343
- * plugins' `onStop` (reverse) → providers' `onStop` + disposal (reverse resolution order) →
344
- * plugins' `onDispose` (reverse). Every step is attempted, each within `hookTimeoutMs` and all
345
- * within `timeoutMs`; failures are collected into a ZnightAggregateError (ZNIGHT021). Signal
346
- * handlers are removed.
382
+ * Stops the application (idempotent; waits for an in-progress `start()`, as a step bounded like
383
+ * the others: past it, the remaining start hooks are skipped and what completed its `onStart` is
384
+ * stopped): plugins' `onStop` (reverse) → providers' `onStop` + disposal (reverse resolution
385
+ * order) → plugins' `onDispose` (reverse). Every step is attempted, each within `hookTimeoutMs`
386
+ * and all within `timeoutMs`; failures are collected into a ZnightAggregateError (ZNIGHT021).
387
+ * Signal handlers are removed.
347
388
  *
348
389
  * Called from a start hook (`onStart`/`onReady` of a provider or plugin, or work it awaits), it
349
390
  * does not wait for the start, which waits for that hook: the shutdown runs right away, stopping
@@ -359,13 +400,25 @@ export class Application {
359
400
  }
360
401
 
361
402
  async #runStop(): Promise<void> {
362
- if (this.#state === 'starting' && !this.#startAborted) await this.#waitForStart()
403
+ const errors: unknown[] = []
404
+ const step = this.#stepRunner(errors)
405
+ if (this.#state === 'starting' && !this.#startAborted) {
406
+ // a start hook may never settle: waiting for the start is a step of the shutdown
407
+ await step('Waiting for start()', () => this.#waitForStart())
408
+ if (this.#state === 'starting') {
409
+ // timed out: the remaining start hooks are skipped, what completed its onStart is stopped,
410
+ // and the owner of the onStart still in flight is stopped and disposed once it settles
411
+ this.#startAborted = true
412
+ this.#releaseStopWait = undefined
413
+ this.#lateStart = this.#starting
414
+ }
415
+ }
363
416
  if (this.#state === 'failed' || this.#state === 'stopped') {
364
417
  this.#removeSignals()
365
418
  return
366
419
  }
367
420
  this.#state = 'stopping'
368
- const errors = await this.#shutdown()
421
+ await this.#shutdown(errors, step)
369
422
  this.#removeSignals()
370
423
  this.#state = 'stopped'
371
424
  if (errors.length > 0) {
@@ -395,11 +448,9 @@ export class Application {
395
448
 
396
449
  /**
397
450
  * The three shutdown phases (also used to roll back a failed create/start: only what was started
398
- * is stopped). Never throws: returns the errors of the failed steps.
451
+ * is stopped). Never throws: returns the errors of the failed steps (appended to `errors`).
399
452
  */
400
- async #shutdown(): Promise<unknown[]> {
401
- const errors: unknown[] = []
402
- const step = this.#stepRunner(errors)
453
+ async #shutdown(errors: unknown[] = [], step = this.#stepRunner(errors)): Promise<unknown[]> {
403
454
  for (const plugin of [...this.#startedPlugins].reverse()) {
404
455
  if (typeof plugin.onStop === 'function') {
405
456
  await step(`Plugin "${plugin.name}" onStop()`, () => plugin.onStop?.(this.#context))
@@ -421,7 +472,8 @@ export class Application {
421
472
  errors.push(...flatten(error))
422
473
  }
423
474
  for (const plugin of [...this.#registered].reverse()) {
424
- if (typeof plugin.onDispose === 'function') {
475
+ // a plugin whose onStart is still in flight is disposed when it settles (#stopLateStart)
476
+ if (typeof plugin.onDispose === 'function' && plugin !== this.#lateStart?.plugin) {
425
477
  await step(`Plugin "${plugin.name}" onDispose()`, () => plugin.onDispose?.(this.#context))
426
478
  }
427
479
  }
@@ -500,6 +552,12 @@ export class Application {
500
552
  }
501
553
  }
502
554
 
555
+ /** The plugin or provider an `onStart` hook belongs to. */
556
+ interface StartOwner {
557
+ readonly plugin?: Plugin
558
+ readonly provider?: ManagedInstance
559
+ }
560
+
503
561
  function entriesOf(values: ProviderValues): Iterable<readonly [ProviderKey, unknown]> {
504
562
  if (typeof (values as Partial<Iterable<unknown>>)[Symbol.iterator] === 'function') {
505
563
  return values as Iterable<readonly [ProviderKey, unknown]>
package/src/container.ts CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  isMissing,
32
32
  missingProvidersError,
33
33
  } from './graph.ts'
34
- import { disposeInstance } from './lifecycle.ts'
34
+ import { disposeInstance, isDisposable } from './lifecycle.ts'
35
35
  import {
36
36
  type ExternalDefinition,
37
37
  type Lazy,
@@ -160,6 +160,11 @@ export class Container {
160
160
  readonly #storage = new AsyncLocalStorage<RequestScope>()
161
161
  /** Instantiated singletons, in creation order. */
162
162
  readonly #created: Slot[] = []
163
+ /**
164
+ * Request-scoped and transient slots whose factory is running. Creation is synchronous, so a slot
165
+ * found here again was requested by its own construction (a `Lazy<T>.get()` in a constructor).
166
+ */
167
+ readonly #constructing = new Set<Slot>()
163
168
  #order: readonly Slot[] = []
164
169
  #names: Map<string, Slot[]> | undefined
165
170
  #status: Status = 'open'
@@ -574,22 +579,31 @@ export class Container {
574
579
  /** Instantiates a request-scoped or transient provider (synchronous factories only). */
575
580
  #create(slot: Slot): unknown {
576
581
  const definition = slot.definition as ProviderDefinition
582
+ const constructing = this.#constructing
583
+ if (constructing.has(slot)) throw reentrantCreationError(slot)
584
+ constructing.add(slot)
577
585
  let deps: unknown[]
578
- try {
579
- deps = this.#resolveDeps(slot)
580
- } catch (error) {
581
- throw error instanceof InstantiationError ? error.withParents([slot.token]) : error
582
- }
583
586
  let instance: unknown
584
587
  try {
585
- instance = definition.factory(deps)
586
- } catch (error) {
587
- throw new InstantiationError(slot.token, [slot.token], error, definition.source)
588
+ try {
589
+ deps = this.#resolveDeps(slot)
590
+ } catch (error) {
591
+ throw error instanceof InstantiationError ? error.withParents([slot.token]) : error
592
+ }
593
+ try {
594
+ instance = definition.factory(deps)
595
+ } catch (error) {
596
+ throw new InstantiationError(slot.token, [slot.token], error, definition.source)
597
+ }
598
+ } finally {
599
+ constructing.delete(slot)
588
600
  }
589
601
  if (definition.interceptors !== undefined) {
590
602
  try {
591
603
  applyInterceptors(instance as object, definition.interceptors, deps, slot.displayName)
592
604
  } catch (error) {
605
+ // the instance is never handed out nor tracked by a scope: release it now
606
+ disposeQuietly(instance, definition.dispose)
593
607
  throw new InstantiationError(slot.token, [slot.token], error, definition.source)
594
608
  }
595
609
  }
@@ -606,7 +620,9 @@ export class Container {
606
620
  this.#assertResolvable(slot)
607
621
  const instance = this.#create(slot)
608
622
  values.set(slot.token, instance)
609
- scope.created.push({ token: slot.token, instance, dispose: slot.definition?.dispose })
623
+ // only what has something to dispose is tracked: a scope without any closes synchronously
624
+ const dispose = slot.definition?.dispose
625
+ if (isDisposable(instance, dispose)) scope.created.push({ token: slot.token, instance, dispose })
610
626
  return instance
611
627
  }
612
628
 
package/src/scope.ts CHANGED
@@ -17,7 +17,10 @@ export interface ScopeContext {
17
17
  readonly closed: boolean
18
18
  }
19
19
 
20
- /** A request-scoped instance created by the container, disposed when its scope closes. */
20
+ /**
21
+ * A request-scoped instance created by the container that has something to dispose (a dispose
22
+ * method or `[Symbol.asyncDispose]`/`[Symbol.dispose]`), disposed when its scope closes.
23
+ */
21
24
  export interface ScopedInstance {
22
25
  readonly token: Token
23
26
  readonly instance: unknown