From 5362803702a1e0ada4627eaffe621d96a2a030c3 Mon Sep 17 00:00:00 2001 From: bebrasmell Date: Sat, 13 Jun 2026 02:18:18 +0300 Subject: [PATCH 1/6] fix: minor consistency issues - get()/produce() return [] for missing multi tokens regardless of optional/skipSelf - bare token declaration after an implementation for the same token is a no-op - LifecycleRef excluded from unused-nodes diagnostics alongside Injector - allocations no longer double-counted; replaced nodes are decremented - pre-resolved nodes (Injector) are pooled on instantiate, fixing get(Injector) Co-Authored-By: Claude Fable 5 --- src/lib/container/container.ts | 18 +- src/lib/container/tests/consistency.spec.ts | 166 ++++++++++++++++++ .../plugins/diagnostics/diagnostics.spec.ts | 4 +- src/lib/provider/proto.ts | 8 + src/lib/provider/tree-node.ts | 29 ++- 5 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 src/lib/container/tests/consistency.spec.ts diff --git a/src/lib/container/container.ts b/src/lib/container/container.ts index 7fadf30..b6c3fac 100644 --- a/src/lib/container/container.ts +++ b/src/lib/container/container.ts @@ -250,7 +250,7 @@ export class NodeContainer extends Illuma implements iDIContainer { .filter((node) => node.allocations === 0) .filter((node) => { if (!(node.proto instanceof ProtoNodeSingle)) return true; - return node.proto.token !== Injector; + return node.proto.token !== Injector && node.proto.token !== LifecycleRef; }); Illuma.onReport({ @@ -318,8 +318,8 @@ export class NodeContainer extends Illuma implements iDIContainer { if (rootSingleton) return rootSingleton.instance; } + if (token instanceof MultiNodeToken) return []; if (optional) return null; - if (!skipSelf && token instanceof MultiNodeToken) return []; throw InjectionError.notFound(token); } @@ -361,7 +361,7 @@ export class NodeContainer extends Illuma implements iDIContainer { if (upstream) return upstream.instance; } - if (!skipSelf && token instanceof MultiNodeToken) return []; + if (token instanceof MultiNodeToken) return []; if (!skipSelf && token instanceof NodeToken && token.opts?.singleton) { const singleton = this._getRootSingleton(token, true); if (singleton) return singleton.instance; @@ -528,7 +528,11 @@ export class NodeContainer extends Illuma implements iDIContainer { /** @internal */ private _registerMultiDeclaration(token: MultiNodeToken): void { - if (this._multiProtoNodes.has(token)) { + const existing = this._multiProtoNodes.get(token); + if (existing) { + // Declaring a token that already has providers is a no-op, + // mirroring declaration-then-implementation order + if (existing.hasProviders()) return; throw InjectionError.duplicate(token); } @@ -537,7 +541,11 @@ export class NodeContainer extends Illuma implements iDIContainer { /** @internal */ private _registerSingleDeclaration(token: NodeToken): void { - if (this._protoNodes.has(token)) { + const existing = this._protoNodes.get(token); + if (existing) { + // Declaring a token that already has a factory is a no-op, + // mirroring declaration-then-implementation order + if (existing.hasFactory()) return; throw InjectionError.duplicate(token); } diff --git a/src/lib/container/tests/consistency.spec.ts b/src/lib/container/tests/consistency.spec.ts new file mode 100644 index 0000000..f91ee4d --- /dev/null +++ b/src/lib/container/tests/consistency.spec.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { MultiNodeToken, NodeToken, nodeInject } from "../../api"; +import { Illuma } from "../../global"; +import type { iDiagnosticsReport } from "../../plugins/diagnostics/types"; +import { Injector } from "../../utils/injector"; +import { NodeContainer } from "../container"; + +describe("multi token retrieval consistency", () => { + it("get() returns [] for an unprovided multi token with skipSelf", () => { + const parent = new NodeContainer(); + parent.bootstrap(); + const child = parent.child() as NodeContainer; + child.bootstrap(); + + const MULTI = new MultiNodeToken("MULTI_SKIP_SELF"); + + expect(child.get(MULTI)).toEqual([]); + expect(child.get(MULTI, { skipSelf: true })).toEqual([]); + }); + + it("get() returns [] for an unprovided multi token with optional", () => { + const container = new NodeContainer(); + container.bootstrap(); + + const MULTI = new MultiNodeToken("MULTI_OPTIONAL"); + + expect(container.get(MULTI, { optional: true })).toEqual([]); + }); + + it("produce() resolves an unprovided multi token to [] regardless of strategy", () => { + const container = new NodeContainer(); + container.bootstrap(); + + const MULTI = new MultiNodeToken("MULTI_PRODUCE"); + + const produced = container.produce(() => ({ + plain: nodeInject(MULTI), + skipSelf: nodeInject(MULTI, { skipSelf: true }), + })); + + expect(produced.plain).toEqual([]); + expect(produced.skipSelf).toEqual([]); + }); +}); + +describe("token declaration after implementation", () => { + it("accepts a bare declaration after a value provider for the same token", () => { + const TOKEN = new NodeToken("DECLARED_AFTER_VALUE"); + + const container = new NodeContainer(); + container.provide(TOKEN.withValue(7)); + expect(() => container.provide(TOKEN)).not.toThrow(); + + container.bootstrap(); + expect(container.get(TOKEN)).toBe(7); + }); + + it("accepts a bare declaration after a provider for the same multi token", () => { + const MULTI = new MultiNodeToken("MULTI_DECLARED_AFTER_VALUE"); + + const container = new NodeContainer(); + container.provide(MULTI.withValue(1)); + expect(() => container.provide(MULTI)).not.toThrow(); + + container.bootstrap(); + expect(container.get(MULTI)).toEqual([1]); + }); + + it("still throws on a duplicate bare declaration", () => { + const TOKEN = new NodeToken("DOUBLE_DECLARATION"); + const MULTI = new MultiNodeToken("DOUBLE_MULTI_DECLARATION"); + + const container = new NodeContainer(); + container.provide(TOKEN); + container.provide(MULTI); + + expect(() => container.provide(TOKEN)).toThrow(); + expect(() => container.provide(MULTI)).toThrow(); + }); +}); + +describe("diagnostics report consistency", () => { + afterEach(() => { + (Illuma as any).__resetPlugins(); + }); + + it("does not report built-in Injector and LifecycleRef as unused", () => { + const reports: iDiagnosticsReport[] = []; + Illuma.extendDiagnostics({ onReport: (r) => reports.push(r) }); + + const container = new NodeContainer(); + container.bootstrap(); + + expect(reports).toHaveLength(1); + expect(reports[0].unusedNodes).toEqual([]); + }); +}); + +describe("allocation counting accuracy", () => { + it("counts a dependency injected twice by the same factory once", () => { + const DEP = new NodeToken("DEP_TWICE"); + const HOST = new NodeToken("HOST_TWICE"); + + const container = new NodeContainer(); + container.provide(DEP.withValue("dep")); + container.provide( + HOST.withFactory(() => { + nodeInject(DEP); + nodeInject(DEP); + return "host"; + }), + ); + container.bootstrap(); + + const rootNode = (container as any)._rootNode; + expect(rootNode.find(DEP).allocations).toBe(1); + }); + + it("does not double-count a node listed twice in a multi token", () => { + const SINGLE = new NodeToken("MULTI_MEMBER"); + const MULTI = new MultiNodeToken("MULTI_DOUBLE_ADD"); + + const container = new NodeContainer(); + container.provide(SINGLE.withValue("member")); + container.provide({ provide: MULTI, alias: SINGLE }); + container.bootstrap(); + + const rootNode = (container as any)._rootNode; + const multiNode = rootNode.find(MULTI); + multiNode.addDependency(rootNode.find(SINGLE)); + multiNode.addDependency(rootNode.find(SINGLE)); + + expect(rootNode.find(SINGLE).allocations).toBe(1); + }); + + it("resolves Injector via get() when a factory also injects it", () => { + const HOST = new NodeToken("INJECTOR_GET_HOST"); + + const container = new NodeContainer(); + container.provide( + HOST.withFactory(() => { + nodeInject(Injector); + return "host"; + }), + ); + container.bootstrap(); + + expect(container.get(Injector)).toBeDefined(); + }); + + it("keeps the Injector allocation count intact", () => { + const HOST = new NodeToken("INJECTOR_HOST"); + + const container = new NodeContainer(); + container.provide( + HOST.withFactory(() => { + nodeInject(Injector); + return "host"; + }), + ); + container.bootstrap(); + + const rootNode = (container as any)._rootNode; + expect(rootNode.find(Injector).allocations).toBe(1); + }); +}); diff --git a/src/lib/plugins/diagnostics/diagnostics.spec.ts b/src/lib/plugins/diagnostics/diagnostics.spec.ts index 3ee330f..5ef086c 100644 --- a/src/lib/plugins/diagnostics/diagnostics.spec.ts +++ b/src/lib/plugins/diagnostics/diagnostics.spec.ts @@ -339,9 +339,9 @@ describe("Plugin: Diagnostics", () => { container.bootstrap(); - // Multi token and LifecycleRef should be unused if not injected + // Only the multi token should be unused; built-ins are excluded expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining("2 were not used"), + expect.stringContaining("1 were not used"), ); expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Multi")); }); diff --git a/src/lib/provider/proto.ts b/src/lib/provider/proto.ts index 28d2b7a..d2ea6f4 100644 --- a/src/lib/provider/proto.ts +++ b/src/lib/provider/proto.ts @@ -64,6 +64,14 @@ export class ProtoNodeMulti { constructor(public readonly token: MultiNodeToken) {} + public hasProviders(): boolean { + return ( + this.singleNodes.size > 0 || + this.multiNodes.size > 0 || + this.transparentNodes.size > 0 + ); + } + public addProvider(retriever: NodeBase | (() => T)): void { if (retriever instanceof NodeToken) { this.singleNodes.add(retriever); diff --git a/src/lib/provider/tree-node.ts b/src/lib/provider/tree-node.ts index f56a6f1..d13bd1c 100644 --- a/src/lib/provider/tree-node.ts +++ b/src/lib/provider/tree-node.ts @@ -130,12 +130,20 @@ export class TreeNodeSingle { public addDependency(node: TreeNode): void { if (node instanceof TreeNodeTransparent) { const token = node.proto.parent.token; + const existing = this._transparentMap.get(token); + if (existing === node) return; + if (existing) existing.allocations--; + this._transparentMap.set(token, node); upsertIndexedDependency(token, node, this._transparentIndex, this._transparentList); this._depsTokens.add(token); } else { const token = node.proto.token; + const existing = this._deps.get(token); + if (existing === node) return; + if (existing) existing.allocations--; + this._deps.set(token, node); upsertIndexedDependency(token, node, this._depsIndex, this._depsList); @@ -164,7 +172,11 @@ export class TreeNodeSingle { } public instantiate(pool?: InjectionPool, middlewares: iMiddleware[] = []): void { - if (this._resolved) return; + if (this._resolved) { + // Pre-resolved nodes (e.g. Injector) still have to be discoverable + if (pool) poolSetOnce(pool, this.proto.token, this); + return; + } for (let i = 0; i < this._depsList.length; i++) { this._depsList[i].instantiate(pool, middlewares); @@ -242,12 +254,20 @@ export class TreeNodeTransparent { public addDependency(node: TreeNode): void { if (node instanceof TreeNodeTransparent) { const token = node.proto.parent.token; + const existing = this._transparentMap.get(token); + if (existing === node) return; + if (existing) existing.allocations--; + this._transparentMap.set(token, node); upsertIndexedDependency(token, node, this._transparentIndex, this._transparentList); this._depsTokens.add(token); } else { const token = node.proto.token; + const existing = this._deps.get(token); + if (existing === node) return; + if (existing) existing.allocations--; + this._deps.set(token, node); upsertIndexedDependency(token, node, this._depsIndex, this._depsList); @@ -353,11 +373,10 @@ export class TreeNodeMulti { public addDependency(...nodes: TreeNode[]): void { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; - if (!this._deps.has(node)) { - this._deps.add(node); - this._depsList.push(node); - } + if (this._deps.has(node)) continue; + this._deps.add(node); + this._depsList.push(node); node.allocations++; } } From e774e10aaa884feda7e11ed8f7142f767195e607 Mon Sep 17 00:00:00 2001 From: bebrasmell Date: Sat, 13 Jun 2026 13:06:17 +0300 Subject: [PATCH 2/6] fix: resolver diamond dedup, context nesting, lifecycle & bootstrap safety Five audit bugs, all fixed internally (no public API change): - resolver: dedup TreeNodes at creation so a diamond-shared provider instantiates once; cycle check runs before the dedup lookup. A re-entrancy guard in tree-node materialization turns a sibling-cycle that escapes build-time detection into a catchable i401 instead of a stack overflow. - context: injection context is now a saved/restored frame stack, so a nested injector.get()/produce() inside a factory no longer clobbers the outer context. scanInto always closes (and unwinds frames a re-entrant scanner leaks) and surfaces scanner errors via the logger. - proto: a bare token declaration scans its opts.factory for dependency tracking (without claiming a factory); setFactory clears injections before re-scanning so an explicit override fully replaces them. - lifecycle: mark destroyed before running hooks (guarded re-entrant destroy is a no-op); each hook is isolated so one throwing hook cannot strand sibling children, and the error still surfaces. - container: bootstrap()/provide() throw if destroyed; destroy() runs teardown in finally and also clears _multiProtoNodes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/container/container.ts | 26 +- src/lib/container/lifecycle.ts | 22 +- src/lib/container/tests/audit-fixes.spec.ts | 524 ++++++++++++++++++++ src/lib/context/context.ts | 92 +++- src/lib/provider/proto.ts | 8 + src/lib/provider/resolver.ts | 17 +- src/lib/provider/tree-node.ts | 46 ++ 7 files changed, 693 insertions(+), 42 deletions(-) create mode 100644 src/lib/container/tests/audit-fixes.spec.ts diff --git a/src/lib/container/container.ts b/src/lib/container/container.ts index b6c3fac..29d6a2e 100644 --- a/src/lib/container/container.ts +++ b/src/lib/container/container.ts @@ -116,6 +116,7 @@ export class NodeContainer extends Illuma implements iDIContainer { * ``` */ public provide(provider: Provider): void { + if (this.destroyed) throw InjectionError.destroyed(); if (this._bootstrapped) { throw InjectionError.bootstrapped(); } @@ -220,6 +221,7 @@ export class NodeContainer extends Illuma implements iDIContainer { } public bootstrap(): void { + if (this.destroyed) throw InjectionError.destroyed(); if (this._bootstrapped) throw InjectionError.doubleBootstrap(); if (this._parent) { if (this._parent.destroyed) throw InjectionError.parentDestroyed(); @@ -388,17 +390,23 @@ export class NodeContainer extends Illuma implements iDIContainer { public destroy(): void { if (this._lifecycle.destroyed) throw InjectionError.destroyed(); - this._lifecycle.destroy(); - if (this._rootNode) { - this._rootNode.destroy(); - this._rootNode = undefined; - } + // Tear the container down even if a destroy hook throws, so a misbehaving + // hook cannot leave the container half-destroyed with no recovery path. + try { + this._lifecycle.destroy(); + } finally { + if (this._rootNode) { + this._rootNode.destroy(); + this._rootNode = undefined; + } - this._unsubParentBootstrap?.(); - this._unsubParentDestroy?.(); - this._bootstrapped = false; - this._protoNodes.clear(); + this._unsubParentBootstrap?.(); + this._unsubParentDestroy?.(); + this._bootstrapped = false; + this._protoNodes.clear(); + this._multiProtoNodes.clear(); + } } public child(): iDIContainer { diff --git a/src/lib/container/lifecycle.ts b/src/lib/container/lifecycle.ts index 6b5345e..076a3f2 100644 --- a/src/lib/container/lifecycle.ts +++ b/src/lib/container/lifecycle.ts @@ -92,15 +92,31 @@ export class LifecycleRefImpl { */ public destroy(): void { if (this._destroyed) throw InjectionError.destroyed(); + // Mark destroyed before running hooks so a hook that (re-entrantly) checks + // `destroyed` observes true; a guarded re-entrant destroy is a safe no-op + // instead of recursing infinitely. + this._destroyed = true; + + // Guard each hook so one throwing callback cannot strand sibling children + // or abort the cascade; surface the first error after every hook has run. + const errors: unknown[] = []; + const run = (cb: () => void): void => { + try { + cb(); + } catch (e) { + errors.push(e); + } + }; - for (const cb of Array.from(this._destroyChildCallbacks).reverse()) cb(); - for (const cb of Array.from(this._destroyCallbacks).reverse()) cb(); + for (const cb of Array.from(this._destroyChildCallbacks).reverse()) run(cb); + for (const cb of Array.from(this._destroyCallbacks).reverse()) run(cb); this._bootstrapCallbacks.clear(); this._bootstrapChildCallbacks.clear(); this._destroyChildCallbacks.clear(); this._destroyCallbacks.clear(); - this._destroyed = true; + + if (errors.length) throw errors[0]; } } diff --git a/src/lib/container/tests/audit-fixes.spec.ts b/src/lib/container/tests/audit-fixes.spec.ts new file mode 100644 index 0000000..92898f9 --- /dev/null +++ b/src/lib/container/tests/audit-fixes.spec.ts @@ -0,0 +1,524 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { MultiNodeToken, NodeInjectable, NodeToken, nodeInject } from "../../api"; +import { InjectionContext } from "../../context/context"; +import { ERR_CODES, InjectionError } from "../../errors"; +import { Illuma } from "../../global"; +import type { iContextScanner } from "../../plugins/context/types"; +import { Injector } from "../../utils/injector"; +import { NodeContainer } from "../container"; +import { LifecycleRef } from "../lifecycle"; + +/** + * Regression coverage for the bugs found in the 2026-06 audit. + * @see audit-2026-06-open-bugs memory. + */ + +describe("diamond dependency dedup (resolver)", () => { + // The bug only reproduces in certain provide orders; pin the documented one. + // The signal is runtime instance identity (a.d === a.b.d), NOT a factory call + // counter (the dry-run scan runs the factory once) and NOT cache membership + // (a membership-only assertion passes even on the buggy code). + it("shares one diamond-shared instance across both consumers (order A,B,D)", () => { + const D = new NodeToken<{ marker: object }>("DIAMOND_D"); + const B = new NodeToken<{ d: { marker: object } }>("DIAMOND_B"); + const A = new NodeToken<{ d: { marker: object }; b: { d: { marker: object } } }>( + "DIAMOND_A", + ); + + const c = new NodeContainer(); + c.provide( + A.withFactory(() => { + const d = nodeInject(D); + const b = nodeInject(B); + return { d, b }; + }), + ); + c.provide(B.withFactory(() => ({ d: nodeInject(D) }))); + c.provide(D.withFactory(() => ({ marker: {} }))); + c.bootstrap(); + + const a = c.get(A); + + // A's direct D and B's D must be the same real instance. + expect(a.d).toBe(a.b.d); + }); + + it("shares the same TreeNode across both parents (allocations === 2)", () => { + const D = new NodeToken<{ id: number }>("DIAMOND_ALLOC_D"); + const B = new NodeToken("DIAMOND_ALLOC_B"); + const A = new NodeToken("DIAMOND_ALLOC_A"); + + const c = new NodeContainer(); + c.provide( + A.withFactory(() => { + nodeInject(D); + nodeInject(B); + return {}; + }), + ); + c.provide( + B.withFactory(() => { + nodeInject(D); + return {}; + }), + ); + c.provide(D.withFactory(() => ({ id: 1 }))); + c.bootstrap(); + + const rootNode = (c as any)._rootNode; + // Referenced by two distinct parents -> exactly two allocations on one node. + expect(rootNode.find(D).allocations).toBe(2); + }); + + it("throws i401 (not stack-overflow) for mutually-referencing siblings", () => { + // P injects [A, B]; A injects B; B injects A. Both A and B are created and + // queued before either is processed, so the resolver's build-time visiting + // check cannot see the back-edge; the materialization guard must catch it. + const P = new NodeToken("SIBLING_CYCLE_P"); + const A = new NodeToken("SIBLING_CYCLE_A"); + const B = new NodeToken("SIBLING_CYCLE_B"); + + const c = new NodeContainer(); + c.provide( + P.withFactory(() => { + nodeInject(A); + nodeInject(B); + return {}; + }), + ); + c.provide(A.withFactory(() => nodeInject(B))); + c.provide(B.withFactory(() => nodeInject(A))); + + let caught: InjectionError | undefined; + try { + c.bootstrap(); + } catch (e) { + caught = e as InjectionError; + } + expect(caught).toBeInstanceOf(InjectionError); + expect(caught?.code).toBe(ERR_CODES.CIRCULAR_DEPENDENCY); + }); + + it("throws i401 for a sibling cycle in lazy (instant:false) mode too", () => { + const P = new NodeToken("LAZY_CYCLE_P"); + const A = new NodeToken("LAZY_CYCLE_A"); + const B = new NodeToken("LAZY_CYCLE_B"); + + const c = new NodeContainer({ instant: false }); + c.provide( + P.withFactory(() => { + nodeInject(A); + nodeInject(B); + return {}; + }), + ); + c.provide(A.withFactory(() => nodeInject(B))); + c.provide(B.withFactory(() => nodeInject(A))); + + let caught: InjectionError | undefined; + try { + c.bootstrap(); + } catch (e) { + caught = e as InjectionError; + } + expect(caught).toBeInstanceOf(InjectionError); + expect(caught?.code).toBe(ERR_CODES.CIRCULAR_DEPENDENCY); + }); + + it("still throws a circular-dependency error for a cycle through a diamond", () => { + const S = new NodeToken("CYCLE_S"); + const B = new NodeToken("CYCLE_B"); + const A = new NodeToken("CYCLE_A"); + + const c = new NodeContainer(); + c.provide( + A.withFactory(() => { + nodeInject(S); + nodeInject(B); + return {}; + }), + ); + c.provide(B.withFactory(() => nodeInject(S))); + c.provide(S.withFactory(() => nodeInject(A))); + + let caught: InjectionError | undefined; + try { + c.bootstrap(); + } catch (e) { + caught = e as InjectionError; + } + expect(caught).toBeInstanceOf(InjectionError); + expect(caught?.code).toBe(ERR_CODES.CIRCULAR_DEPENDENCY); + }); + + it("shares a diamond dep across two bare-declared opts.factory tokens", () => { + // Object identity is dry-run-immune; X and Y must receive the same V. + const V = new NodeToken<{ marker: object }>("DIAMOND_BARE_V", { + factory: () => ({ marker: {} }), + }); + const X = new NodeToken<{ v: { marker: object } }>("DIAMOND_BARE_X", { + factory: () => ({ v: nodeInject(V) }), + }); + const Y = new NodeToken<{ v: { marker: object } }>("DIAMOND_BARE_Y", { + factory: () => ({ v: nodeInject(V) }), + }); + const ROOT = new NodeToken<{ x: { v: object }; y: { v: object } }>( + "DIAMOND_BARE_ROOT", + ); + + const c = new NodeContainer(); + c.provide(ROOT.withFactory(() => ({ x: nodeInject(X), y: nodeInject(Y) }))); + c.provide(X); + c.provide(Y); + c.provide(V); + c.bootstrap(); + + const root = c.get(ROOT); + expect(root.x.v).toBe(root.y.v); + }); +}); + +describe("nested injection context (no clobber)", () => { + it("keeps the outer context open after injector.get() of a lazy singleton", () => { + const SINGLETON = new NodeToken("NESTED_SINGLETON", { + singleton: true, + factory: () => "singleton-value", + }); + const OTHER = new NodeToken("NESTED_OTHER"); + const A = new NodeToken<{ s: string; o: string }>("NESTED_A"); + + const c = new NodeContainer({ instant: false }); + c.provide(OTHER.withValue("other")); + c.provide( + A.withFactory(() => { + const injector = nodeInject(Injector); + const s = injector.get(SINGLETON); + const o = nodeInject(OTHER); + return { s, o }; + }), + ); + c.bootstrap(); + + const a = c.get(A); + expect(a.s).toBe("singleton-value"); + expect(a.o).toBe("other"); + }); + + it("keeps the outer context open after injector.produce()", () => { + const OTHER = new NodeToken("NESTED_PRODUCE_OTHER"); + const A = new NodeToken<{ p: { x: number }; o: string }>("NESTED_PRODUCE_A"); + + const c = new NodeContainer({ instant: false }); + c.provide(OTHER.withValue("other")); + c.provide( + A.withFactory(() => { + const injector = nodeInject(Injector); + const p = injector.produce(() => ({ x: 42 })); + const o = nodeInject(OTHER); + return { p, o }; + }), + ); + c.bootstrap(); + + const a = c.get(A); + expect(a.p.x).toBe(42); + expect(a.o).toBe("other"); + }); + + it("restores the outer context even when a context scanner throws", () => { + const throwingScanner: iContextScanner = { + scan: () => { + throw new Error("scanner boom"); + }, + }; + Illuma.extendContextScanner(throwingScanner); + + try { + // A scanner throw during provide()-time scanInto must not corrupt the + // shared global context: a subsequent independent container must work. + expect(() => { + const c = new NodeContainer(); + c.provide(new NodeToken("SCANNER_X").withFactory(() => 1)); + }).not.toThrow(); + + const c2 = new NodeContainer(); + const T = new NodeToken("SCANNER_T"); + const DEP = new NodeToken("SCANNER_DEP"); + c2.provide(DEP.withValue("dep")); + c2.provide( + T.withFactory(() => { + const d = nodeInject(DEP); + return `t-${d}`; + }), + ); + c2.bootstrap(); + expect(c2.get(T)).toBe("t-dep"); + } finally { + (Illuma as any).__resetPlugins(); + } + }); + + it("restores the context stack when a scanner opens a context then throws", () => { + // A re-entrant scanner that opens but never closes must not leave an + // orphaned frame that corrupts the shared global context. + const reentrantScanner: iContextScanner = { + scan: () => { + (InjectionContext as any).open(); + throw new Error("re-entrant scanner boom"); + }, + }; + Illuma.extendContextScanner(reentrantScanner); + + const state = (globalThis as any)[Symbol.for("@illuma/core/InjectionContextState")]; + const depthBefore = state.stack.length; + + try { + const c = new NodeContainer(); + c.provide(new NodeToken("REENTRANT_X").withFactory(() => 1)); + + // No frame leaked, context fully closed. + expect(state.stack.length).toBe(depthBefore); + expect(InjectionContext.contextOpen).toBe(false); + + // The corruption guard still works: injecting outside a context throws. + expect(() => nodeInject(new NodeToken("OUTSIDE"))).toThrow(InjectionError); + } finally { + (Illuma as any).__resetPlugins(); + } + }); +}); + +describe("bare declaration tracks opts.factory dependencies", () => { + it("resolves a bare-declared token whose opts.factory injects another token", () => { + const VAL = new NodeToken("BARE_VAL"); + const X = new NodeToken("BARE_X", { + factory: () => nodeInject(VAL) + 1, + }); + + const c = new NodeContainer(); + c.provide(VAL.withValue(7)); + c.provide(X); + expect(() => c.bootstrap()).not.toThrow(); + expect(c.get(X)).toBe(8); + }); + + it("lets an explicit factory override fully replace opts.factory deps", () => { + const OPTS_DEP = new NodeToken("OVERRIDE_OPTS_DEP"); + const EXPLICIT_DEP = new NodeToken("OVERRIDE_EXPLICIT_DEP"); + const X = new NodeToken("OVERRIDE_X", { + factory: () => nodeInject(OPTS_DEP) + 1, + }); + + const c = new NodeContainer(); + c.provide(EXPLICIT_DEP.withValue(100)); + c.provide(X); // scans opts.factory deps (OPTS_DEP), no factory claimed + c.provide(X.withFactory(() => nodeInject(EXPLICIT_DEP) + 1)); // override + // OPTS_DEP is intentionally NOT provided: if its deps leaked into the tree + // this would throw i400/i500. It must resolve purely via EXPLICIT_DEP. + expect(() => c.bootstrap()).not.toThrow(); + expect(c.get(X)).toBe(101); + }); + + it("resolves a bare-declared singleton with opts.factory across a tree", () => { + const VAL = new NodeToken("BARE_SINGLETON_VAL", { + singleton: true, + factory: () => 5, + }); + const X = new NodeToken("BARE_SINGLETON_X", { + singleton: true, + factory: () => nodeInject(VAL) * 2, + }); + + const parent = new NodeContainer(); + parent.provide(VAL); + parent.provide(X); + parent.bootstrap(); + const child = parent.child(); + child.bootstrap(); + + expect(child.get(X)).toBe(10); + }); + + it("resolves a bare-declared opts.factory with an absent optional dep", () => { + const MISSING = new NodeToken("BARE_MISSING"); + const X = new NodeToken<{ v: number | null }>("BARE_OPTIONAL_X", { + factory: () => ({ v: nodeInject(MISSING, { optional: true }) }), + }); + + const c = new NodeContainer(); + c.provide(X); + expect(() => c.bootstrap()).not.toThrow(); + expect(c.get(X).v).toBeNull(); + }); + + it("still throws notFound for a bare token with no factory and no impl", () => { + const X = new NodeToken("BARE_NO_FACTORY"); + + const c = new NodeContainer(); + c.provide(X); + expect(() => c.bootstrap()).toThrow(InjectionError); + }); + + it("works with NodeInjectable classes that inject deps", () => { + const DEP = new NodeToken("CLASS_DEP"); + + @NodeInjectable() + class Service { + public readonly dep = nodeInject(DEP); + } + + const c = new NodeContainer(); + c.provide(DEP.withValue("injected")); + c.provide(Service); + c.bootstrap(); + expect(c.get(Service).dep).toBe("injected"); + }); +}); + +describe("re-entrant destroy", () => { + it("treats a guarded re-entrant destroy as a no-op", () => { + const c = new NodeContainer(); + c.bootstrap(); + const lifecycle = (c as any)._lifecycle; + + let observedDestroyed: boolean | undefined; + lifecycle.beforeDestroy(() => { + observedDestroyed = c.destroyed; + if (!c.destroyed) c.destroy(); + }); + + expect(() => c.destroy()).not.toThrow(); + expect(observedDestroyed).toBe(true); + expect(c.destroyed).toBe(true); + expect((c as any)._rootNode).toBeUndefined(); + }); + + it("reports destroyed as true inside a destroy hook", () => { + const c = new NodeContainer(); + c.bootstrap(); + const lifecycle = (c as any)._lifecycle; + + let seen = false; + lifecycle.beforeDestroy(() => { + seen = lifecycle.destroyed; + }); + + c.destroy(); + expect(seen).toBe(true); + }); + + it("throws (not stack-overflow) on an unguarded re-entrant container destroy", () => { + const c = new NodeContainer(); + c.bootstrap(); + const lifecycle = (c as any)._lifecycle; + lifecycle.beforeDestroy(() => c.destroy()); + + expect(() => c.destroy()).toThrow(InjectionError); + }); + + it("completes teardown (and surfaces the error) when a destroy hook throws", () => { + const c = new NodeContainer(); + c.bootstrap(); + const lifecycle = (c as any)._lifecycle; + lifecycle.beforeDestroy(() => { + throw new Error("boom"); + }); + + expect(() => c.destroy()).toThrow("boom"); + // The throwing hook must not leave a half-destroyed zombie. + expect(c.destroyed).toBe(true); + expect(c.bootstrapped).toBe(false); + expect((c as any)._rootNode).toBeUndefined(); + }); + + it("destroys every sibling child even when one child's hook throws", () => { + const parent = new NodeContainer(); + parent.bootstrap(); + const childA = parent.child() as NodeContainer; + childA.bootstrap(); + const childB = parent.child() as NodeContainer; + childB.bootstrap(); + const childC = parent.child() as NodeContainer; + childC.bootstrap(); + + (childB as any)._lifecycle.beforeDestroy(() => { + throw new Error("flush failed"); + }); + + expect(() => parent.destroy()).toThrow("flush failed"); + + // A throwing hook in one child must not strand its siblings or the parent. + expect(childA.destroyed).toBe(true); + expect(childB.destroyed).toBe(true); + expect(childC.destroyed).toBe(true); + expect(parent.destroyed).toBe(true); + }); +}); + +describe("zombie bootstrap / destroyed container hardening", () => { + it("throws when bootstrapping a destroyed container", () => { + const c = new NodeContainer(); + c.provide(new NodeToken("ZOMBIE_T").withValue(1)); + c.bootstrap(); + c.destroy(); + + expect(() => c.bootstrap()).toThrow(InjectionError); + try { + c.bootstrap(); + } catch (e) { + expect((e as InjectionError).code).toBe(ERR_CODES.DESTROYED); + } + expect(c.destroyed).toBe(true); + expect(c.bootstrapped).toBe(false); + }); + + it("throws when bootstrapping after destroy without prior bootstrap", () => { + const c = new NodeContainer(); + c.bootstrap(); + c.destroy(); + expect(() => c.bootstrap()).toThrow(InjectionError); + }); + + it("clears _multiProtoNodes on destroy (even when bootstrapped)", () => { + const MULTI = new MultiNodeToken("ZOMBIE_MULTI"); + const c = new NodeContainer(); + c.provide(MULTI.withValue(1)); + c.bootstrap(); + c.destroy(); + + expect((c as any)._protoNodes.size).toBe(0); + expect((c as any)._multiProtoNodes.size).toBe(0); + }); + + it("throws when providing to a destroyed container", () => { + const c = new NodeContainer(); + c.bootstrap(); + c.destroy(); + expect(() => c.provide(new NodeToken("ZOMBIE_P").withValue(1))).toThrow( + InjectionError, + ); + }); + + it("does not resurrect a destroyed child when the parent bootstraps", () => { + const parent = new NodeContainer(); + const child = parent.child() as NodeContainer; + child.destroy(); + + expect(() => parent.bootstrap()).not.toThrow(); + expect(child.destroyed).toBe(true); + expect(child.bootstrapped).toBe(false); + }); +}); + +describe("LifecycleRef still resolvable after the fixes", () => { + afterEach(() => { + (Illuma as any).__resetPlugins(); + }); + + it("resolves LifecycleRef and Injector built-ins", () => { + const c = new NodeContainer(); + c.bootstrap(); + expect(c.get(LifecycleRef)).toBeDefined(); + expect(c.get(Injector)).toBeDefined(); + }); +}); diff --git a/src/lib/context/context.ts b/src/lib/context/context.ts index 7d7eaf0..833db8a 100644 --- a/src/lib/context/context.ts +++ b/src/lib/context/context.ts @@ -4,12 +4,16 @@ import { Illuma } from "../global/global"; import type { iContextScanner } from "../plugins/context/types"; import type { iInjectionNode } from "./types"; -interface iInjectionContextState { +interface iInjectionContextFrame { contextOpen: boolean; injector: InjectorFn | null; calls: Set>; } +interface iInjectionContextState extends iInjectionContextFrame { + stack: iInjectionContextFrame[]; +} + const INJECTION_CONTEXT_KEY = Symbol.for("@illuma/core/InjectionContext"); const INJECTION_CONTEXT_STATE_KEY = Symbol.for("@illuma/core/InjectionContextState"); @@ -25,10 +29,14 @@ if (!contextGlobal[INJECTION_CONTEXT_STATE_KEY]) { contextOpen: false, injector: null, calls: new Set>(), + stack: [], }; } const injectionContextState = contextGlobal[INJECTION_CONTEXT_STATE_KEY]; +// Backfill if state was created by an older version of @illuma/core sharing +// the same globalThis (npm + jsr, dual-installs, etc.) +injectionContextState.stack ??= []; /** * Internal context manager for tracking dependency injections during factory execution. @@ -77,15 +85,23 @@ abstract class InjectionContextBase { } /** - * Opens a new injection context. - * Resets the calls set and sets the injector if provided. + * Opens a new injection context, suspending the current one. + * The previous context (open flag, injector, and collected calls) is pushed + * onto a stack and restored by the matching {@link close}, so a factory that + * triggers a nested instantiation/scan does not clobber its outer context. * * @param injector - Optional injector function to use for resolving dependencies */ public static open(injector?: InjectorFn): void { - InjectionContextBase._calls.clear(); - InjectionContextBase.contextOpen = true; - InjectionContextBase.injector = injector || null; + injectionContextState.stack.push({ + contextOpen: injectionContextState.contextOpen, + injector: injectionContextState.injector, + calls: injectionContextState.calls, + }); + + injectionContextState.contextOpen = true; + injectionContextState.injector = injector || null; + injectionContextState.calls = new Set>(); } /** @@ -99,27 +115,41 @@ abstract class InjectionContextBase { public static scanInto(factory: any, target: Set>): void { if (typeof factory !== "function") return; InjectionContextBase.open(); + const baseDepth = injectionContextState.stack.length; + // close() must run on every path: a throwing context scanner would + // otherwise orphan the frame opened above and leave the context corrupted + // for every subsequent instantiation sharing this globalThis state. try { - factory(); - } catch { - // No-op - } + try { + factory(); + } catch { + // No-op: dry-run, unresolved injections are expected here + } + + for (const scanner of InjectionContextBase._scanners) { + try { + const scanned = scanner.scan(factory); + for (const node of scanned) InjectionContextBase._calls.add(node); + } catch (err) { + // A misbehaving scanner must not break provide() or corrupt the + // shared context, but the error must not vanish silently. + Illuma.logger.error( + "[Illuma] A context scanner threw during dependency scan; its injections were skipped:", + err, + ); + } + } - const scanners = InjectionContextBase._scanners; - if (!scanners.length) { InjectionContextBase._flushInto(target); + } finally { + // Discard any frames a re-entrant scanner opened without closing, then + // close this scan's own frame, so the stack returns to its prior depth. + while (injectionContextState.stack.length > baseDepth) { + InjectionContextBase.close(); + } InjectionContextBase.close(); - return; - } - - for (const scanner of scanners) { - const scanned = scanner.scan(factory); - for (const node of scanned) InjectionContextBase._calls.add(node); } - - InjectionContextBase._flushInto(target); - InjectionContextBase.close(); } /** @@ -152,11 +182,23 @@ abstract class InjectionContextBase { } } - /** Closes the current injection context. */ + /** + * Closes the current injection context, restoring the one suspended by the + * matching {@link open}. Falls back to a fully-closed state when there is no + * outer context to restore. + */ public static close(): void { - InjectionContextBase.contextOpen = false; - InjectionContextBase._calls.clear(); - InjectionContextBase.injector = null; + const previous = injectionContextState.stack.pop(); + if (previous) { + injectionContextState.contextOpen = previous.contextOpen; + injectionContextState.injector = previous.injector; + injectionContextState.calls = previous.calls; + return; + } + + injectionContextState.contextOpen = false; + injectionContextState.injector = null; + injectionContextState.calls = new Set>(); } /** diff --git a/src/lib/provider/proto.ts b/src/lib/provider/proto.ts index d2ea6f4..7747cd9 100644 --- a/src/lib/provider/proto.ts +++ b/src/lib/provider/proto.ts @@ -19,6 +19,11 @@ export class ProtoNodeSingle { if (factory) { this.factory = factory; InjectionContext.scanInto(factory, this.injections); + } else if (typeof token.opts?.factory === "function") { + // Track the deps of the token's own factory so the resolver wires them, + // without claiming a factory: hasFactory() stays false so an explicit + // provider may still override this bare declaration. + InjectionContext.scanInto(token.opts.factory, this.injections); } } @@ -30,6 +35,9 @@ export class ProtoNodeSingle { if (this.factory) throw InjectionError.duplicateFactory(this.token); this.factory = factory; + // Replace any deps scanned from token.opts.factory so the explicit + // factory's injections fully supersede them. + this.injections.clear(); InjectionContext.scanInto(factory, this.injections); } diff --git a/src/lib/provider/resolver.ts b/src/lib/provider/resolver.ts index 6577a64..d8086d4 100644 --- a/src/lib/provider/resolver.ts +++ b/src/lib/provider/resolver.ts @@ -78,6 +78,9 @@ export function resolveTreeNode( if (inCache) return inCache; const rootNode = createTreeNode(rootProto); + // Register every node in the dedup cache at creation time so a proto that is + // queued (pushed but not yet processed) is reused instead of re-created. + cache.set(rootProto, rootNode); const stack: StackFrame[] = [{ proto: rootProto, node: rootNode, processed: false }]; const visiting = new Set(); @@ -89,7 +92,7 @@ export function resolveTreeNode( if (frame.processed) { stack.pop(); visiting.delete(proto); - cache.set(proto, node); + // node was already registered in `cache` when it was created continue; } @@ -166,17 +169,21 @@ export function resolveTreeNode( const depProto = dep as ProtoNode; + // Cycle detection must run BEFORE the dedup lookup: in-progress ancestors + // on the active DFS path are now present in `cache`, so reusing them first + // would silently wire a cycle instead of reporting it. + if (visiting.has(depProto) && isNotTransparentProto(depProto)) { + throwCircularDependencyCycle(stack, depProto, true); + } + const cached = cache.get(depProto); if (cached) { node.addDependency(cached); continue; } - if (visiting.has(depProto) && isNotTransparentProto(depProto)) { - throwCircularDependencyCycle(stack, depProto, true); - } - const childNode = createTreeNode(depProto); + cache.set(depProto, childNode); node.addDependency(childNode); stack.push({ proto: depProto, node: childNode, processed: false }); } diff --git a/src/lib/provider/tree-node.ts b/src/lib/provider/tree-node.ts index d13bd1c..05f99d2 100644 --- a/src/lib/provider/tree-node.ts +++ b/src/lib/provider/tree-node.ts @@ -91,6 +91,7 @@ export class TreeNodeSingle { private _instance: T | null = null; private _collected = false; private _resolved = false; + private _inProgress = false; public allocations = 0; public get instance(): T { @@ -159,6 +160,14 @@ export class TreeNodeSingle { return; } + // Re-entry before completion means a genuine cycle the resolver's + // build-time check could not see (e.g. mutually-referencing siblings); + // report it instead of overflowing the stack. + if (this._inProgress) { + throw InjectionError.circularDependency(this.proto.token, [this.proto.token]); + } + this._inProgress = true; + for (let i = 0; i < this._depsList.length; i++) { this._depsList[i].collectPool(pool); } @@ -167,6 +176,7 @@ export class TreeNodeSingle { this._transparentList[i].collectPool(pool); } + this._inProgress = false; this._collected = true; poolSetOnce(pool, this.proto.token, this); } @@ -178,6 +188,11 @@ export class TreeNodeSingle { return; } + if (this._inProgress) { + throw InjectionError.circularDependency(this.proto.token, [this.proto.token]); + } + this._inProgress = true; + for (let i = 0; i < this._depsList.length; i++) { this._depsList[i].instantiate(pool, middlewares); } @@ -200,6 +215,7 @@ export class TreeNodeSingle { }); } + this._inProgress = false; this._resolved = true; if (pool) poolSetOnce(pool, this.proto.token, this); @@ -224,6 +240,7 @@ export class TreeNodeTransparent { private _instance: T | null = null; private _collected = false; private _resolved = false; + private _inProgress = false; public allocations = 0; public get instance(): T { @@ -280,6 +297,13 @@ export class TreeNodeTransparent { public collectPool(pool: InjectionPool): void { if (this._collected) return; + if (this._inProgress) { + throw InjectionError.circularDependency(this.proto.parent.token, [ + this.proto.parent.token, + ]); + } + this._inProgress = true; + for (let i = 0; i < this._depsList.length; i++) { this._depsList[i].collectPool(pool); } @@ -288,12 +312,20 @@ export class TreeNodeTransparent { this._transparentList[i].collectPool(pool); } + this._inProgress = false; this._collected = true; } public instantiate(pool?: InjectionPool, middlewares: iMiddleware[] = []): void { if (this._resolved) return; + if (this._inProgress) { + throw InjectionError.circularDependency(this.proto.parent.token, [ + this.proto.parent.token, + ]); + } + this._inProgress = true; + for (let i = 0; i < this._transparentList.length; i++) { this._transparentList[i].instantiate(pool, middlewares); } @@ -316,6 +348,7 @@ export class TreeNodeTransparent { }); } + this._inProgress = false; this._resolved = true; } @@ -332,6 +365,7 @@ export class TreeNodeMulti { private _collected = false; private _resolved = false; + private _inProgress = false; public allocations = 0; constructor(public readonly proto: ProtoNodeMulti) {} @@ -342,10 +376,16 @@ export class TreeNodeMulti { return; } + if (this._inProgress) { + throw InjectionError.circularDependency(this.proto.token, [this.proto.token]); + } + this._inProgress = true; + for (let i = 0; i < this._depsList.length; i++) { this._depsList[i].collectPool(pool); } + this._inProgress = false; this._collected = true; poolSetOnce(pool, this.proto.token, this); } @@ -353,6 +393,11 @@ export class TreeNodeMulti { public instantiate(pool?: InjectionPool, middlewares: iMiddleware[] = []): void { if (this._resolved) return; + if (this._inProgress) { + throw InjectionError.circularDependency(this.proto.token, [this.proto.token]); + } + this._inProgress = true; + for (let i = 0; i < this._depsList.length; i++) { const dep = this._depsList[i]; dep.instantiate(pool, middlewares); @@ -366,6 +411,7 @@ export class TreeNodeMulti { } } + this._inProgress = false; this._resolved = true; if (pool) poolSetOnce(pool, this.proto.token, this); } From 54b6cb8a55b932cbe2f35a3f1a79c834327a9a4d Mon Sep 17 00:00:00 2001 From: bebrasmell Date: Sat, 13 Jun 2026 13:11:19 +0300 Subject: [PATCH 3/6] docs: clarify withCache:false sub-container lifecycle withCache:false intentionally creates a fresh sub-container per call, bound to the parent lifecycle. Document that callers needing eager per-scope disposal should use injectGroupAsync + injector.destroy() or manage a child container, since injectAsync/injectEntryAsync expose no per-call disposal handle. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ASYNC_INJECTION.md | 11 +++++++++++ src/lib/utils/inheritance.ts | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/ASYNC_INJECTION.md b/docs/ASYNC_INJECTION.md index d1c7fdf..be379fc 100644 --- a/docs/ASYNC_INJECTION.md +++ b/docs/ASYNC_INJECTION.md @@ -185,6 +185,17 @@ private readonly getAnalytics = injectAsync( ); ``` +> **Lifecycle note for `withCache: false`.** Each call creates a fresh +> sub-container that is tied to the parent container's lifecycle and is +> destroyed when the parent is destroyed. The parent therefore accumulates one +> sub-container per call until it is destroyed — by design, since you opted out +> of caching. For request-scoped work on a long-lived parent where you need to +> release each scope eagerly, use `injectGroupAsync` (which returns the +> sub-container's `Injector`) and call `injector.destroy()` when the scope ends, +> or create and destroy a child container explicitly. `injectAsync` / +> `injectEntryAsync` return the produced instance only, so they intentionally do +> not expose a per-call disposal handle. + ### Overriding dependencies Provide additional dependencies or override parent container values: diff --git a/src/lib/utils/inheritance.ts b/src/lib/utils/inheritance.ts index 1f636fa..dce4113 100644 --- a/src/lib/utils/inheritance.ts +++ b/src/lib/utils/inheritance.ts @@ -12,7 +12,16 @@ type MaybeAsyncFactory = () => T | Promise; interface iInjectionOptions { /** * Whether to cache the result of the injection function - * Prevents multiple invocations from creating multiple sub-containers or injections + * Prevents multiple invocations from creating multiple sub-containers or injections. + * + * When `false`, every call creates a fresh sub-container bound to the parent's + * lifecycle (destroyed when the parent is destroyed). On a long-lived parent + * these accumulate one per call until the parent is destroyed — by design, + * since caching is opted out. To release request-scoped work eagerly, prefer + * {@link injectGroupAsync} (which returns the sub-container's `Injector`, so + * you can call `injector.destroy()`) or manage a child container explicitly; + * `injectAsync`/`injectEntryAsync` return only the produced instance and do + * not expose a per-call disposal handle. * @default true */ withCache?: boolean; From 7533e629d5f943ba7574a724ce12f8a5a93f748e Mon Sep 17 00:00:00 2001 From: bebrasmell Date: Sat, 13 Jun 2026 14:31:25 +0300 Subject: [PATCH 4/6] chore: trim narration comments from audit fixes and tests Remove inline comments that restated test names/assertions or duplicated code; keep only the non-obvious why-rationale (invariant/ordering notes) that guards against re-introducing the fixed bugs. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ASYNC_INJECTION.md | 21 ++++-------- src/lib/container/tests/audit-fixes.spec.ts | 32 +++---------------- .../plugins/diagnostics/diagnostics.spec.ts | 1 - src/lib/provider/resolver.ts | 1 - 4 files changed, 10 insertions(+), 45 deletions(-) diff --git a/docs/ASYNC_INJECTION.md b/docs/ASYNC_INJECTION.md index be379fc..26c170e 100644 --- a/docs/ASYNC_INJECTION.md +++ b/docs/ASYNC_INJECTION.md @@ -41,11 +41,11 @@ This guide covers advanced dependency injection patterns using `injectAsync`, `i Illuma provides three utilities for advanced async dependency injection: -| Utility | Purpose | Returns | -|---------|---------|---------| -| `injectAsync` | Lazily inject a single dependency | The dependency instance | -| `injectEntryAsync` | Create sub-container and resolve specific entrypoint | The entrypoint instance | -| `injectGroupAsync` | Create isolated sub-container with array of providers | An injector | +| Utility | Purpose | Returns | +| ------------------ | ----------------------------------------------------- | ----------------------- | +| `injectAsync` | Lazily inject a single dependency | The dependency instance | +| `injectEntryAsync` | Create sub-container and resolve specific entrypoint | The entrypoint instance | +| `injectGroupAsync` | Create isolated sub-container with array of providers | An injector | **Note:** For synchronous deferred injection (e.g. to solve circular dependencies without async/await), use [`injectDefer`](./API.md#injectDefer) instead. @@ -185,16 +185,7 @@ private readonly getAnalytics = injectAsync( ); ``` -> **Lifecycle note for `withCache: false`.** Each call creates a fresh -> sub-container that is tied to the parent container's lifecycle and is -> destroyed when the parent is destroyed. The parent therefore accumulates one -> sub-container per call until it is destroyed — by design, since you opted out -> of caching. For request-scoped work on a long-lived parent where you need to -> release each scope eagerly, use `injectGroupAsync` (which returns the -> sub-container's `Injector`) and call `injector.destroy()` when the scope ends, -> or create and destroy a child container explicitly. `injectAsync` / -> `injectEntryAsync` return the produced instance only, so they intentionally do -> not expose a per-call disposal handle. +> **Lifecycle note for `withCache: false`.** Each call creates a fresh sub-container that is tied to the parent container's lifecycle and is destroyed when the parent is destroyed. The parent therefore accumulates one sub-container per call until it is destroyed — by design, since you opted out of caching. For request-scoped work on a long-lived parent where you need to release each scope eagerly, use `injectGroupAsync` (which returns the sub-container's `Injector`) and call `injector.destroy()` when the scope ends, or create and destroy a child container explicitly. `injectAsync` / `injectEntryAsync` return the produced instance only, so they intentionally do not expose a per-call disposal handle. ### Overriding dependencies diff --git a/src/lib/container/tests/audit-fixes.spec.ts b/src/lib/container/tests/audit-fixes.spec.ts index 92898f9..cef7980 100644 --- a/src/lib/container/tests/audit-fixes.spec.ts +++ b/src/lib/container/tests/audit-fixes.spec.ts @@ -8,16 +8,7 @@ import { Injector } from "../../utils/injector"; import { NodeContainer } from "../container"; import { LifecycleRef } from "../lifecycle"; -/** - * Regression coverage for the bugs found in the 2026-06 audit. - * @see audit-2026-06-open-bugs memory. - */ - describe("diamond dependency dedup (resolver)", () => { - // The bug only reproduces in certain provide orders; pin the documented one. - // The signal is runtime instance identity (a.d === a.b.d), NOT a factory call - // counter (the dry-run scan runs the factory once) and NOT cache membership - // (a membership-only assertion passes even on the buggy code). it("shares one diamond-shared instance across both consumers (order A,B,D)", () => { const D = new NodeToken<{ marker: object }>("DIAMOND_D"); const B = new NodeToken<{ d: { marker: object } }>("DIAMOND_B"); @@ -39,7 +30,6 @@ describe("diamond dependency dedup (resolver)", () => { const a = c.get(A); - // A's direct D and B's D must be the same real instance. expect(a.d).toBe(a.b.d); }); @@ -66,14 +56,10 @@ describe("diamond dependency dedup (resolver)", () => { c.bootstrap(); const rootNode = (c as any)._rootNode; - // Referenced by two distinct parents -> exactly two allocations on one node. expect(rootNode.find(D).allocations).toBe(2); }); it("throws i401 (not stack-overflow) for mutually-referencing siblings", () => { - // P injects [A, B]; A injects B; B injects A. Both A and B are created and - // queued before either is processed, so the resolver's build-time visiting - // check cannot see the back-edge; the materialization guard must catch it. const P = new NodeToken("SIBLING_CYCLE_P"); const A = new NodeToken("SIBLING_CYCLE_A"); const B = new NodeToken("SIBLING_CYCLE_B"); @@ -152,7 +138,6 @@ describe("diamond dependency dedup (resolver)", () => { }); it("shares a diamond dep across two bare-declared opts.factory tokens", () => { - // Object identity is dry-run-immune; X and Y must receive the same V. const V = new NodeToken<{ marker: object }>("DIAMOND_BARE_V", { factory: () => ({ marker: {} }), }); @@ -234,8 +219,6 @@ describe("nested injection context (no clobber)", () => { Illuma.extendContextScanner(throwingScanner); try { - // A scanner throw during provide()-time scanInto must not corrupt the - // shared global context: a subsequent independent container must work. expect(() => { const c = new NodeContainer(); c.provide(new NodeToken("SCANNER_X").withFactory(() => 1)); @@ -259,8 +242,6 @@ describe("nested injection context (no clobber)", () => { }); it("restores the context stack when a scanner opens a context then throws", () => { - // A re-entrant scanner that opens but never closes must not leave an - // orphaned frame that corrupts the shared global context. const reentrantScanner: iContextScanner = { scan: () => { (InjectionContext as any).open(); @@ -276,11 +257,8 @@ describe("nested injection context (no clobber)", () => { const c = new NodeContainer(); c.provide(new NodeToken("REENTRANT_X").withFactory(() => 1)); - // No frame leaked, context fully closed. expect(state.stack.length).toBe(depthBefore); expect(InjectionContext.contextOpen).toBe(false); - - // The corruption guard still works: injecting outside a context throws. expect(() => nodeInject(new NodeToken("OUTSIDE"))).toThrow(InjectionError); } finally { (Illuma as any).__resetPlugins(); @@ -311,10 +289,10 @@ describe("bare declaration tracks opts.factory dependencies", () => { const c = new NodeContainer(); c.provide(EXPLICIT_DEP.withValue(100)); - c.provide(X); // scans opts.factory deps (OPTS_DEP), no factory claimed - c.provide(X.withFactory(() => nodeInject(EXPLICIT_DEP) + 1)); // override - // OPTS_DEP is intentionally NOT provided: if its deps leaked into the tree - // this would throw i400/i500. It must resolve purely via EXPLICIT_DEP. + c.provide(X); + c.provide(X.withFactory(() => nodeInject(EXPLICIT_DEP) + 1)); + // OPTS_DEP is deliberately never provided: the override's deps must fully + // replace it, otherwise bootstrap would throw on the leaked dependency. expect(() => c.bootstrap()).not.toThrow(); expect(c.get(X)).toBe(101); }); @@ -425,7 +403,6 @@ describe("re-entrant destroy", () => { }); expect(() => c.destroy()).toThrow("boom"); - // The throwing hook must not leave a half-destroyed zombie. expect(c.destroyed).toBe(true); expect(c.bootstrapped).toBe(false); expect((c as any)._rootNode).toBeUndefined(); @@ -447,7 +424,6 @@ describe("re-entrant destroy", () => { expect(() => parent.destroy()).toThrow("flush failed"); - // A throwing hook in one child must not strand its siblings or the parent. expect(childA.destroyed).toBe(true); expect(childB.destroyed).toBe(true); expect(childC.destroyed).toBe(true); diff --git a/src/lib/plugins/diagnostics/diagnostics.spec.ts b/src/lib/plugins/diagnostics/diagnostics.spec.ts index 5ef086c..679bf75 100644 --- a/src/lib/plugins/diagnostics/diagnostics.spec.ts +++ b/src/lib/plugins/diagnostics/diagnostics.spec.ts @@ -339,7 +339,6 @@ describe("Plugin: Diagnostics", () => { container.bootstrap(); - // Only the multi token should be unused; built-ins are excluded expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining("1 were not used"), ); diff --git a/src/lib/provider/resolver.ts b/src/lib/provider/resolver.ts index d8086d4..fc8f056 100644 --- a/src/lib/provider/resolver.ts +++ b/src/lib/provider/resolver.ts @@ -92,7 +92,6 @@ export function resolveTreeNode( if (frame.processed) { stack.pop(); visiting.delete(proto); - // node was already registered in `cache` when it was created continue; } From 0457d5a57179b12b2557d3514f5183fe605d5f29 Mon Sep 17 00:00:00 2001 From: bebrasmell Date: Sat, 13 Jun 2026 17:48:50 +0300 Subject: [PATCH 5/6] fix: coverage summary json --- .gitignore | 1 + artifacts/coverage/coverage-summary.json | 25 ------------------------ 2 files changed, 1 insertion(+), 25 deletions(-) delete mode 100644 artifacts/coverage/coverage-summary.json diff --git a/.gitignore b/.gitignore index 66f5cbf..fcca843 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules dist .DS_Store .vscode/symbols.json +artifacts diff --git a/artifacts/coverage/coverage-summary.json b/artifacts/coverage/coverage-summary.json deleted file mode 100644 index 3b6c618..0000000 --- a/artifacts/coverage/coverage-summary.json +++ /dev/null @@ -1,25 +0,0 @@ -{"total": {"lines":{"total":797,"covered":763,"skipped":0,"pct":95.73},"statements":{"total":924,"covered":865,"skipped":0,"pct":93.61},"functions":{"total":183,"covered":172,"skipped":0,"pct":93.98},"branches":{"total":478,"covered":424,"skipped":0,"pct":88.7},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/plugins.ts": {"lines":{"total":0,"covered":0,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":0,"covered":0,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/testkit.ts": {"lines":{"total":0,"covered":0,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":0,"covered":0,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/api/decorator.ts": {"lines":{"total":18,"covered":18,"skipped":0,"pct":100},"functions":{"total":13,"covered":13,"skipped":0,"pct":100},"statements":{"total":19,"covered":19,"skipped":0,"pct":100},"branches":{"total":7,"covered":7,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/api/injection.ts": {"lines":{"total":9,"covered":9,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":9,"covered":9,"skipped":0,"pct":100},"branches":{"total":14,"covered":14,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/api/proxy.ts": {"lines":{"total":3,"covered":3,"skipped":0,"pct":100},"functions":{"total":3,"covered":2,"skipped":0,"pct":66.66},"statements":{"total":3,"covered":3,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/api/token-utils.ts": {"lines":{"total":7,"covered":7,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":10,"covered":10,"skipped":0,"pct":100},"branches":{"total":11,"covered":11,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/api/token.ts": {"lines":{"total":19,"covered":19,"skipped":0,"pct":100},"functions":{"total":9,"covered":9,"skipped":0,"pct":100},"statements":{"total":19,"covered":19,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/container/container.ts": {"lines":{"total":220,"covered":205,"skipped":0,"pct":93.18},"functions":{"total":33,"covered":29,"skipped":0,"pct":87.87},"statements":{"total":266,"covered":236,"skipped":0,"pct":88.72},"branches":{"total":194,"covered":163,"skipped":0,"pct":84.02}} -,"/home/zak/dev/opensource/illuma/core/src/lib/container/lifecycle.ts": {"lines":{"total":29,"covered":27,"skipped":0,"pct":93.1},"functions":{"total":11,"covered":8,"skipped":0,"pct":72.72},"statements":{"total":38,"covered":33,"skipped":0,"pct":86.84},"branches":{"total":4,"covered":4,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/context/context.ts": {"lines":{"total":49,"covered":49,"skipped":0,"pct":100},"functions":{"total":14,"covered":14,"skipped":0,"pct":100},"statements":{"total":52,"covered":52,"skipped":0,"pct":100},"branches":{"total":12,"covered":12,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/global/global.ts": {"lines":{"total":32,"covered":30,"skipped":0,"pct":93.75},"functions":{"total":17,"covered":15,"skipped":0,"pct":88.23},"statements":{"total":33,"covered":31,"skipped":0,"pct":93.93},"branches":{"total":6,"covered":6,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/plugins/diagnostics/built-in.ts": {"lines":{"total":6,"covered":6,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":7,"covered":6,"skipped":0,"pct":85.71},"branches":{"total":2,"covered":1,"skipped":0,"pct":50}} -,"/home/zak/dev/opensource/illuma/core/src/lib/plugins/diagnostics/default-impl.ts": {"lines":{"total":5,"covered":5,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":6,"covered":6,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/plugins/middlewares/diagnostics.middleware.ts": {"lines":{"total":9,"covered":9,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":9,"covered":9,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/plugins/middlewares/runner.ts": {"lines":{"total":7,"covered":7,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":9,"covered":8,"skipped":0,"pct":88.88},"branches":{"total":4,"covered":3,"skipped":0,"pct":75}} -,"/home/zak/dev/opensource/illuma/core/src/lib/provider/extractor.ts": {"lines":{"total":5,"covered":5,"skipped":0,"pct":100},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":11,"covered":11,"skipped":0,"pct":100},"branches":{"total":8,"covered":8,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/provider/proto.ts": {"lines":{"total":29,"covered":29,"skipped":0,"pct":100},"functions":{"total":10,"covered":10,"skipped":0,"pct":100},"statements":{"total":30,"covered":30,"skipped":0,"pct":100},"branches":{"total":12,"covered":11,"skipped":0,"pct":91.66}} -,"/home/zak/dev/opensource/illuma/core/src/lib/provider/resolver.ts": {"lines":{"total":88,"covered":85,"skipped":0,"pct":96.59},"functions":{"total":7,"covered":7,"skipped":0,"pct":100},"statements":{"total":100,"covered":97,"skipped":0,"pct":97},"branches":{"total":73,"covered":65,"skipped":0,"pct":89.04}} -,"/home/zak/dev/opensource/illuma/core/src/lib/provider/tree-node.ts": {"lines":{"total":176,"covered":166,"skipped":0,"pct":94.31},"functions":{"total":31,"covered":31,"skipped":0,"pct":100},"statements":{"total":207,"covered":193,"skipped":0,"pct":93.23},"branches":{"total":89,"covered":80,"skipped":0,"pct":89.88}} -,"/home/zak/dev/opensource/illuma/core/src/lib/testkit/helpers.ts": {"lines":{"total":14,"covered":14,"skipped":0,"pct":100},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":15,"covered":15,"skipped":0,"pct":100},"branches":{"total":6,"covered":6,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/utils/defer.ts": {"lines":{"total":24,"covered":24,"skipped":0,"pct":100},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":26,"covered":26,"skipped":0,"pct":100},"branches":{"total":8,"covered":8,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/utils/inheritance.ts": {"lines":{"total":33,"covered":33,"skipped":0,"pct":100},"functions":{"total":11,"covered":11,"skipped":0,"pct":100},"statements":{"total":36,"covered":36,"skipped":0,"pct":100},"branches":{"total":14,"covered":13,"skipped":0,"pct":92.85}} -,"/home/zak/dev/opensource/illuma/core/src/lib/utils/injector.ts": {"lines":{"total":15,"covered":13,"skipped":0,"pct":86.66},"functions":{"total":6,"covered":5,"skipped":0,"pct":83.33},"statements":{"total":19,"covered":16,"skipped":0,"pct":84.21},"branches":{"total":10,"covered":8,"skipped":0,"pct":80}} -} From bd7f7edc7730f2880146b8d1c4f2ed02fad65ae9 Mon Sep 17 00:00:00 2001 From: bebrasmell Date: Tue, 30 Jun 2026 11:35:23 +0300 Subject: [PATCH 6/6] fix: full audit --- CHANGELOG.md | 37 ++ docs/API.md | 11 +- docs/ASYNC_INJECTION.md | 17 +- docs/PLUGINS.md | 47 +- docs/RESOLUTION_MODIFIERS.md | 4 +- docs/TECHNICAL_OVERVIEW.md | 19 +- docs/TESTKIT.md | 10 +- docs/TOKENS.md | 23 + docs/TROUBLESHOOTING.md | 203 ++++++++- src/lib/api/injection.spec.ts | 48 ++ src/lib/api/proxy.ts | 5 +- src/lib/api/token-utils.spec.ts | 28 +- src/lib/api/token-utils.ts | 6 +- src/lib/api/token.spec.ts | 47 ++ src/lib/api/token.ts | 38 +- src/lib/container/container.ts | 144 +++--- src/lib/container/lifecycle.ts | 83 +++- .../container/tests/array-providers.spec.ts | 17 + src/lib/container/tests/audit-fixes.spec.ts | 289 ++++++++++++ .../tests/cross-container-isolation.spec.ts | 125 ++++++ .../tests/hierarchy-resolution.spec.ts | 172 +++++++ src/lib/container/tests/lifecycle.spec.ts | 25 ++ .../tests/resolution-modifiers.spec.ts | 292 ++++++++++++ src/lib/container/tests/singleton.spec.ts | 30 ++ src/lib/errors.spec.ts | 35 +- src/lib/errors.ts | 35 ++ src/lib/global/global.ts | 9 +- src/lib/plugins/diagnostics/built-in.ts | 14 +- .../plugins/diagnostics/diagnostics.spec.ts | 58 +++ .../plugins/middlewares/middlewares.spec.ts | 39 ++ src/lib/plugins/middlewares/runner.ts | 21 +- src/lib/provider/multi-result.spec.ts | 108 +++++ src/lib/provider/proto.ts | 17 +- src/lib/provider/resolver.spec.ts | 2 +- src/lib/provider/resolver.ts | 84 ++-- src/lib/provider/tree-node.ts | 418 +++++++++++++----- src/lib/provider/types.ts | 9 + src/lib/testkit/helpers.ts | 14 +- src/lib/utils/defer.spec.ts | 23 + src/lib/utils/defer.ts | 30 +- src/lib/utils/inheritance.spec.ts | 159 +++++++ src/lib/utils/inheritance.ts | 31 +- 42 files changed, 2503 insertions(+), 323 deletions(-) create mode 100644 src/lib/container/tests/cross-container-isolation.spec.ts create mode 100644 src/lib/container/tests/hierarchy-resolution.spec.ts create mode 100644 src/lib/container/tests/resolution-modifiers.spec.ts create mode 100644 src/lib/provider/multi-result.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 196c1da..00594d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- `iNodeTokenBaseOptions.global` — opt-in token-instance deduplication by name via + a `globalThis` registry (mirrors the existing token-class dedup). Identically-named + global tokens constructed in separately-bundled modules resolve to the same instance, + so the container (which keys providers by reference) treats them as one. Enables a + dynamically-imported plugin to bind a host's seam tokens without sharing a bundle. + +### Changed + +- The three remaining raw `throw new Error` sites now throw a typed `InjectionError` + carrying a stable code, matching the rest of the library: a global-token kind conflict + (`i600`), a middleware calling `next()` more than once (`i700`), and the internal + unknown-`ProtoNode` invariant (`i800`). Messages are unchanged apart from the `[iNNN]` + prefix, and callers can now branch on `error.code` for these cases. + +### Fixed + +- Tree-node resolution no longer leaves a node stranded as "in progress" when a factory + or dependency throws. A failed resolution now resets its guard on every exit path, so a + retried `get()` (lazy `instant: false` mode) or another consumer of a shared dependency + re-runs the factory instead of reporting a bogus circular-dependency (`i401`) error. + `MultiNodeToken` resolution also resets its members per attempt so a retry cannot + accumulate duplicates. + +### Documentation + +- Completed the error reference (`TROUBLESHOOTING.md`): documented `i202`, `i304`, `i305`, + and the new `i600`/`i700`/`i800` codes across the quick-reference table, table of contents, + and detail sections; documented the `singleton` and `global` token options in `TOKENS.md` + and `API.md`. +- Corrected stale examples: plugin imports now use the `@illuma/core/plugins` subpath + (`Illuma`, `iMiddleware`, the diagnostics types, `iContextScanner`); fixed the `nodeInject` + signature and `@NodeInjectable()` usage in `TECHNICAL_OVERVIEW.md`, the `iSpectator` + name and a malformed provider array in `TESTKIT.md`, and a request-scoped `injectGroupAsync` + example in `ASYNC_INJECTION.md`. + ## 2.3.0 - 2026-05-28 ## 2.2.0 - 2026-05-10 diff --git a/docs/API.md b/docs/API.md index f9bd790..74bb9d3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -149,6 +149,7 @@ new NodeToken( options?: { factory?: () => T; singleton?: boolean; + global?: boolean; } ) ``` @@ -158,6 +159,7 @@ new NodeToken( | `name` | `string` | Unique identifier for the token | | `options.factory` | `() => T` | Optional factory for default value | | `options.singleton` | `boolean` | Marks token as root-scoped singleton in parent-child containers | +| `options.global` | `boolean` | Dedupe this token by name in a process-global registry, so an identically-named global token from another bundle is the same instance. Reserve for cross-bundle seam tokens; the name becomes a global identity and must be unique per kind (see [i600](./TROUBLESHOOTING.md#token-errors)) | When `singleton: true`, there's no need to call `provide` for this token. It will be automatically provided as a singleton in the root container when first requested until you want to override it in a child container. @@ -207,7 +209,14 @@ A token that can have multiple providers, returning an array. ### Constructor ```typescript -new MultiNodeToken(name: string, options?: { factory?: () => T }) +new MultiNodeToken( + name: string, + options?: { + factory?: () => T; + singleton?: boolean; + global?: boolean; + } +) ``` ### Usage diff --git a/docs/ASYNC_INJECTION.md b/docs/ASYNC_INJECTION.md index 26c170e..562dcd1 100644 --- a/docs/ASYNC_INJECTION.md +++ b/docs/ASYNC_INJECTION.md @@ -469,11 +469,20 @@ const REQUEST_CONTEXT = new NodeToken('REQUEST_CONTEXT'); @NodeInjectable() class RequestHandler { + // Capture the injector during construction, while the injection context is + // open. `injectGroupAsync` resolves `Injector` eagerly when it is *called*, + // so invoking it later from a request handler (outside the context) would + // otherwise throw an "outside of an injection context" error ([i501]). + private readonly injector = nodeInject(Injector); + public createRequestScope(userId: string, requestId: string) { - return injectGroupAsync(async () => [ - RequestService, - { provide: REQUEST_CONTEXT, value: { userId, requestId } } - ], { withCache: false }); // New scope per request + return injectGroupAsync( + async () => [ + RequestService, + { provide: REQUEST_CONTEXT, value: { userId, requestId } }, + ], + { injector: this.injector, withCache: false }, // new scope per request + ); } public async handleRequest(userId: string, requestId: string) { diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 299ce36..93fef38 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -47,7 +47,7 @@ Illuma provides a plugin system that allows you to extend its core functionality The `Illuma` class is the central hub for managing plugins in Illuma. It provides static methods to register plugins globally, which will then be automatically invoked at the appropriate times during the container lifecycle. ```typescript -import { Illuma } from '@illuma/core'; +import { Illuma } from '@illuma/core/plugins'; // Register a context scanner Illuma.extendContextScanner(myScanner); @@ -136,17 +136,19 @@ Diagnostics modules analyze the container state after bootstrap and provide insi A diagnostics module must implement the `iDiagnosticsModule` interface: ```typescript -import type { TreeNode } from '@illuma/core'; - -interface iDiagnosticsReport { - readonly totalNodes: number; // Total dependency nodes in container - readonly unusedNodes: TreeNode[]; // Nodes that weren't resolved - readonly bootstrapDuration: number; // Bootstrap time in milliseconds -} - -interface iDiagnosticsModule { - readonly onReport: (report: iDiagnosticsReport) => void; -} +import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins'; + +// Both interfaces are exported from '@illuma/core/plugins'. Their shape: +// +// interface iDiagnosticsReport { +// readonly totalNodes: number; // Total dependency nodes in container +// readonly unusedNodes: TreeNode[]; // Nodes never resolved (TreeNode is internal) +// readonly bootstrapDuration: number; // Bootstrap time in milliseconds +// } +// +// interface iDiagnosticsModule { +// readonly onReport: (report: iDiagnosticsReport) => void; +// } ``` **Report fields:** @@ -160,7 +162,7 @@ interface iDiagnosticsModule { #### Example: Custom Performance Reporter ```typescript -import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core'; +import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins'; export class PerformanceReporter implements iDiagnosticsModule { public onReport(report: iDiagnosticsReport): void { @@ -186,7 +188,7 @@ export class PerformanceReporter implements iDiagnosticsModule { Throw an error if any dependencies are unused (strict mode): ```typescript -import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core'; +import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins'; export class StrictUnusedValidator implements iDiagnosticsModule { public onReport(report: iDiagnosticsReport): void { @@ -208,7 +210,7 @@ export class StrictUnusedValidator implements iDiagnosticsModule { Send diagnostics to a logging service: ```typescript -import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core'; +import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins'; export class JsonDiagnosticsLogger implements iDiagnosticsModule { constructor(private readonly loggerService: LoggerService) {} @@ -246,9 +248,9 @@ export class JsonDiagnosticsLogger implements iDiagnosticsModule { Diagnostics modules should be registered before bootstrapping the container. To enable the diagnostics system, you must call `enableIllumaDiagnostics()` from `@illuma/core/plugins`: ```typescript -import { Illuma, NodeContainer } from '@illuma/core'; +import { NodeContainer } from '@illuma/core'; import { PerformanceReporter } from './diagnostics'; -import { enableIllumaDiagnostics } from '@illuma/core/plugins'; +import { Illuma, enableIllumaDiagnostics } from '@illuma/core/plugins'; // 1. Enable diagnostics system enableIllumaDiagnostics(); @@ -334,7 +336,7 @@ type iMiddleware = ( Log every time a dependency is instantiated: ```typescript -import type { iMiddleware } from '@illuma/core'; +import type { iMiddleware } from '@illuma/core/plugins'; export const loggerMiddleware: iMiddleware = (params, next) => { console.log(`[Middleware] Creating instance of: ${params.token.name}`); @@ -354,7 +356,7 @@ export const loggerMiddleware: iMiddleware = (params, next) => { Automatically wrap certain services in a Proxy: ```typescript -import type { iMiddleware } from '@illuma/core'; +import type { iMiddleware } from '@illuma/core/plugins'; export const proxyMiddleware: iMiddleware = (params, next) => { const instance = next(params); @@ -380,7 +382,7 @@ export const proxyMiddleware: iMiddleware = (params, next) => { Affects **all** containers created thereafter. ```typescript -import { Illuma } from '@illuma/core'; +import { Illuma } from '@illuma/core/plugins'; Illuma.registerGlobalMiddleware(loggerMiddleware); ``` @@ -446,7 +448,8 @@ export class OptimizedScanner implements iContextScanner { Support property-based injection using decorators: ```typescript -import type { iContextScanner, NodeToken, iInjectionNode } from '@illuma/core'; +import type { iContextScanner } from '@illuma/core/plugins'; +import type { NodeToken, iInjectionNode } from '@illuma/core'; const PROPERTY_INJECT_KEY = Symbol('di:properties'); @@ -496,7 +499,7 @@ Only report diagnostics in development mode: ```typescript import { enableIllumaDiagnostics } from '@illuma/core/plugins'; -import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core'; +import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins'; export class ConditionalReporter implements iDiagnosticsModule { constructor( diff --git a/docs/RESOLUTION_MODIFIERS.md b/docs/RESOLUTION_MODIFIERS.md index ad51990..e2e73a1 100644 --- a/docs/RESOLUTION_MODIFIERS.md +++ b/docs/RESOLUTION_MODIFIERS.md @@ -34,13 +34,13 @@ Sometimes you need to explicitly constrain this traversal to either: When `self: true` is passed, the container stops traversal and **only looks for the provider in the current (local) container**. -If the provider is not registered locally, a `NotFound` error (`[i101]`) will be thrown (*unless `optional: true` is also provided*). +If the provider is not registered locally, a `NotFound` error (`[i400]`) will be thrown (*unless `optional: true` is also provided*). ### `skipSelf` When `skipSelf: true` is passed, the container **ignores providers in the current container** and immediately delegates the resolution to the parent container. -If the container has no parent, or if none of the parents provide the dependency, a `NotFound` error (`[i101]`) will be thrown (*unless `optional: true` is also provided*). +If the container has no parent, or if none of the parents provide the dependency, a `NotFound` error (`[i400]`) will be thrown (*unless `optional: true` is also provided*). ### `optional` diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index 18557dd..e76afb7 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -590,7 +590,14 @@ These are collected during scanning to build the dependency graph. ### Function Signature ```typescript -function nodeInject(token: N, options?: { optional?: boolean }): ExtractInjectedType; +function nodeInject(token: N, options?: iNodeInjectorOptions): ExtractInjectedType; + +// where: +interface iNodeInjectorOptions { + optional?: boolean; // return null instead of throwing when not found + self?: boolean; // only resolve from the current container + skipSelf?: boolean; // skip the current container, resolve from a parent +} ``` ### Behavior @@ -1088,13 +1095,13 @@ const ConfigToken = new NodeToken('Config'); const UserServiceToken = new NodeToken('UserService'); const PluginToken = new MultiNodeToken('Plugin'); -@Injectable() +@NodeInjectable() class Logger { /* ... */ } -@Injectable() +@NodeInjectable() class Config { /* ... */ } -@Injectable() +@NodeInjectable() class UserService { // Dependencies are injected via nodeInject() in class body private readonly logger = nodeInject(LoggerToken); @@ -1102,10 +1109,10 @@ class UserService { private readonly plugins = nodeInject(PluginToken); } -@Injectable() +@NodeInjectable() class AuthPlugin { /* ... */ } -@Injectable() +@NodeInjectable() class CachePlugin { /* ... */ } ``` diff --git a/docs/TESTKIT.md b/docs/TESTKIT.md index d9698af..3b74865 100644 --- a/docs/TESTKIT.md +++ b/docs/TESTKIT.md @@ -21,7 +21,7 @@ The Illuma testkit provides framework-agnostic utilities for testing components - [API Reference](#api-reference) - [`createTestFactory(config)`](#createtestfactorytconfig) - [`TestFactoryFn`](#testfactoryfnt) - - [`Spectator`](#spectatort) + - [`iSpectator`](#ispectatort) - [Best practices](#best-practices) - [Related documentation](#related-documentation) @@ -134,10 +134,10 @@ class NotificationService { describe('NotificationService', () => { const createTest = createTestFactory({ target: NotificationService, - provide: [ + provide: [{ provide: EmailService, useClass: MockEmailService, - ], + }], }); it('should send notification via email', () => { @@ -366,9 +366,9 @@ Creates a test factory for the specified target. A function that creates a new test instance with a clean DI container. -**Returns:** `Spectator` +**Returns:** `iSpectator` -### `Spectator` +### `iSpectator` The object returned by test factory functions. diff --git a/docs/TOKENS.md b/docs/TOKENS.md index 6ac5c2e..a24ab76 100644 --- a/docs/TOKENS.md +++ b/docs/TOKENS.md @@ -65,6 +65,29 @@ container.bootstrap(); const logger = container.get(LOGGER); ``` +### Root-scoped singletons + +Pass `singleton: true` to mark a token as a root-scoped singleton in a hierarchical (parent–child) container tree. The token is provided automatically in the root container the first time it is requested, and the same instance is shared across the whole tree — you don't need to call `provide()` for it unless a child container should override it. + +```typescript +const CLOCK = new NodeToken('CLOCK', { + singleton: true, + factory: () => new SystemClock(), +}); +``` + +### Global (cross-bundle) tokens + +Pass `global: true` to deduplicate a token by name in a process-global registry. Containers key providers by token _reference_, so two identically-named tokens constructed in separately-bundled modules would otherwise be treated as different providers. With `global: true`, constructing the same-named token again returns the **same** instance — letting a dynamically-imported plugin bind a host's seam tokens without sharing a bundle. + +```typescript +// In the host bundle and in a separately-built plugin bundle: +const SEAM = new NodeToken('host.seam', { global: true }); +// Both constructions resolve to one and the same token instance. +``` + +Reserve `global: true` for cross-bundle seam tokens: the name becomes a global identity, so it must be unique and stable. Reusing one global name for two different token kinds (e.g. a `NodeToken` and a `MultiNodeToken`) throws [`[i600]` Global Token Conflict](./TROUBLESHOOTING.md#token-errors). + ### Provider helper methods Tokens provide convenient methods to create providers: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 70b69a6..c5c6abf 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -6,10 +6,13 @@ This document provides detailed information about all error codes in Illuma and - [Quick Reference](#quick-reference) - [Provider Errors (i100-i103)](#provider-errors) -- [Alias Errors (i200-i201)](#alias-errors) -- [Bootstrap Errors (i300-i302)](#bootstrap-errors) +- [Alias Errors (i200-i202)](#alias-errors) +- [Bootstrap Errors (i300-i305)](#bootstrap-errors) - [Retrieval Errors (i400-i401)](#retrieval-errors) - [Instantiation Errors (i500-i504)](#instantiation-errors) +- [Token Errors (i600)](#token-errors) +- [Middleware Errors (i700)](#middleware-errors) +- [Internal Errors (i800)](#internal-errors) - [Debugging Tips](#debugging-tips) ## Quick Reference @@ -27,6 +30,8 @@ This document provides detailed information about all error codes in Illuma and | i301 | Container Bootstrapped | Provide before `bootstrap()` | | i302 | Double Bootstrap | Only bootstrap once | | i303 | Container destroyed | Container has been destroyed | +| i304 | Parent Destroyed | Keep the parent alive while children exist | +| i305 | Parent Not Bootstrapped | Bootstrap the parent before the child | | i400 | Provider Not Found | Provide the token or use `optional` | | i401 | Circular Dependency | Refactor to break cycle | | i500 | Untracked Injection | Use in class field initializers only | @@ -34,6 +39,9 @@ This document provides detailed information about all error codes in Illuma and | i502 | Called Utils Outside | Use only during instantiation | | i503 | Instance Access Failed | Check factory/constructor logic | | i504 | Access Failed | Check provider configuration | +| i600 | Global Token Conflict | Use one token kind per global name | +| i700 | Middleware Next Reused | Call `next()` at most once per middleware | +| i800 | Unknown ProtoNode | Internal invariant — please report it | --- @@ -337,7 +345,7 @@ container.provide({ }); ``` -**Note:** You don't need to provide the alias target before creating the alias (unlike what you might expect). The target will be resolved when the container is bootstrapped. However, if the alias target is never provided, you'll get an `[i400] Provider Not Found` error when trying to retrieve it. +**Note:** You don't need to provide the alias target before creating the alias (unlike what you might expect). The target will be resolved when the container is bootstrapped. However, if the alias target is never provided, `bootstrap()` itself throws an `[i400] Provider Not Found` error (the whole container build fails, not just a later retrieval of the alias). --- @@ -385,6 +393,45 @@ container.provide({ --- +### [i202] Conflicting Strategies + +**Error Message:** + +``` +Token "TokenName" cannot use both 'self' and 'skipSelf' strategies. +``` + +**Cause:** +You passed both `self: true` and `skipSelf: true` to a single `nodeInject()` call. Their semantics are mutually exclusive — `self` restricts resolution to the current container, while `skipSelf` skips the current container and delegates to the parent — so they cannot be combined. + +**Example:** + +```typescript +@NodeInjectable() +class MyService { + // ❌ This will throw [i202] + private readonly dep = nodeInject(SomeToken, { self: true, skipSelf: true }); +} +``` + +**Solution:** +Pick the one that matches your intent: + +```typescript +@NodeInjectable() +class MyService { + // ✅ Only resolve from the current container + private readonly local = nodeInject(SomeToken, { self: true }); + + // ✅ Or skip the current container and resolve from a parent + private readonly inherited = nodeInject(OtherToken, { skipSelf: true }); +} +``` + +See [Resolution Modifiers](./RESOLUTION_MODIFIERS.md) for details on `self` and `skipSelf`. + +--- + ## Bootstrap Errors ### [i300] Not Bootstrapped @@ -534,7 +581,7 @@ const container2 = createContainer(); // ✅ New instance **Error Message:** ``` -Container has been already destroyed +Container has been already destroyed. ``` **Cause:** @@ -557,6 +604,67 @@ Make sure to only call `destroy()` when you are completely done with the contain --- +### [i304] Parent Destroyed + +**Error Message:** + +``` +Parent container has been destroyed. +``` + +**Cause:** +You tried to create a child container whose parent was already destroyed, or you called `bootstrap()` on a child whose parent was destroyed after the child was created. A destroyed container can no longer parent or bootstrap children. + +**Example:** + +```typescript +const parent = new NodeContainer(); +parent.bootstrap(); +parent.destroy(); + +// ❌ This will throw [i304] - the parent is already destroyed +const child = new NodeContainer({ parent }); +``` + +**Solution:** +Ensure the parent container outlives its children. Create and bootstrap child containers before the parent is destroyed, and when managing lifecycles manually, tear children down before (or together with) the parent. + +--- + +### [i305] Parent Not Bootstrapped + +**Error Message:** + +``` +Parent container has not been bootstrapped. +``` + +**Cause:** +You called `bootstrap()` on a child container before its parent container was bootstrapped. A child cannot complete its own bootstrap until the parent is ready. + +**Example:** + +```typescript +const parent = new NodeContainer(); +const child = new NodeContainer({ parent }); + +// ❌ This will throw [i305] - the parent hasn't been bootstrapped yet +child.bootstrap(); +``` + +**Solution:** +Bootstrap the parent first. A child created before its parent is bootstrapped is bootstrapped automatically when the parent bootstraps, so you usually only need to bootstrap the parent: + +```typescript +const parent = new NodeContainer(); +const child = new NodeContainer({ parent }); + +// ✅ Bootstrapping the parent cascades to the child +parent.bootstrap(); +``` + +--- + ## Retrieval Errors ### [i400] Provider Not Found @@ -951,6 +1059,93 @@ If the issue persists, create a minimal reproduction and [report it on GitHub](h --- +## Token Errors + +### [i600] Global Token Conflict + +**Error Message:** + +``` +Global token "name" is already registered as NodeToken; cannot redeclare it as MultiNodeToken. +``` + +**Cause:** +You constructed two tokens with `{ global: true }` that share the same name but are of different kinds (for example a `NodeToken` and a `MultiNodeToken`). Global tokens are deduplicated by name in a process-wide registry so identically-named tokens from separately-bundled modules resolve to one instance — which only works if every declaration of that name agrees on the token kind. + +**Example:** + +```typescript +// ❌ Same global name, different kinds → throws [i600] +const CONFIG = new NodeToken('seam.config', { global: true }); +const ALSO_CONFIG = new MultiNodeToken('seam.config', { global: true }); +``` + +**Solution:** +Give each global token a unique, stable name and keep its kind consistent everywhere it is declared: + +```typescript +// ✅ Distinct names, one kind each +const CONFIG = new NodeToken('seam.config', { global: true }); +const PLUGINS = new MultiNodeToken('seam.plugins', { global: true }); +``` + +Reserve `global: true` for cross-bundle seam tokens. Tokens used within a single bundle don't need it — ordinary (non-global) tokens are distinct by reference and never collide. See the [Tokens guide](./TOKENS.md) for more on the `global` option. + +--- + +## Middleware Errors + +### [i700] Middleware Next Reused + +**Error Message:** + +``` +Middleware next() was called more than once. +``` + +**Cause:** +An instantiation middleware called its `next()` callback more than once. Each middleware in the chain must call `next()` at most once; a second call is rejected explicitly rather than silently re-running downstream middlewares. + +**Example:** + +```typescript +const badMiddleware: iMiddleware = (params, next) => { + next(params); + return next(params); // ❌ second call throws [i700] +}; +``` + +**Solution:** +Call `next()` exactly once and return its result. To transform the produced instance, capture the result and modify it instead of calling `next()` again: + +```typescript +const goodMiddleware: iMiddleware = (params, next) => { + const instance = next(params); // ✅ called once + // ...inspect or wrap `instance`... + return instance; +}; +``` + +--- + +## Internal Errors + +### [i800] Unknown ProtoNode + +**Error Message:** + +``` +Unknown ProtoNode type. +``` + +**Cause:** +An internal invariant failed: the resolver encountered a provider prototype node of an unrecognized type. This should not occur through normal use of the public API and usually indicates a bug in Illuma or a mismatched/corrupted build. + +**Solution:** +This is an internal error. Please [report it on GitHub](https://github.com/git-illuma/core/issues) with a minimal reproduction, and make sure all `@illuma/*` packages are on compatible versions. + +--- + ## Debugging Tips ### Enable Performance Monitoring diff --git a/src/lib/api/injection.spec.ts b/src/lib/api/injection.spec.ts index c0b4334..5770e80 100644 --- a/src/lib/api/injection.spec.ts +++ b/src/lib/api/injection.spec.ts @@ -409,3 +409,51 @@ describe("nodeInject", () => { }); }); }); + +describe("dependency scan with primitive coercion (#14)", () => { + it("detects deps declared after a factory coerces an injected value to a primitive", () => { + const A = new NodeToken("COERCE_A"); + const B = new NodeToken("COERCE_B"); + const HOST = new NodeToken("COERCE_HOST"); + + const c = new NodeContainer(); + c.provide(A.withValue(1)); + c.provide(B.withValue(2)); + c.provide( + HOST.withFactory(() => { + const a = nodeInject(A); + // Coerce the injected value to a primitive: during the scan dry-run `a` + // is the SHAPE_SHIFTER placeholder. This must NOT abort dependency + // detection, so nodeInject(B) below is still tracked. + const prefix = `value-${a}`; + const b = nodeInject(B); + return `${prefix}-${b}`; + }), + ); + + expect(() => c.bootstrap()).not.toThrow(); + expect(c.get(HOST)).toBe("value-1-2"); + }); + + it("supports numeric and string coercion of placeholders in the scan", () => { + const N = new NodeToken("COERCE_N"); + const M = new NodeToken("COERCE_M"); + const HOST = new NodeToken("COERCE_NUM_HOST"); + + const c = new NodeContainer(); + c.provide(N.withValue(10)); + c.provide(M.withValue(5)); + c.provide( + HOST.withFactory(() => { + const n = nodeInject(N); + // Numeric coercion + comparison during the dry-run must not throw. + const flag = +n > 0 && `${n}`.length >= 0; + const m = nodeInject(M); + return flag ? n + m : m; + }), + ); + + expect(() => c.bootstrap()).not.toThrow(); + expect(c.get(HOST)).toBe(15); + }); +}); diff --git a/src/lib/api/proxy.ts b/src/lib/api/proxy.ts index 52d5735..d55c79c 100644 --- a/src/lib/api/proxy.ts +++ b/src/lib/api/proxy.ts @@ -5,6 +5,9 @@ */ // @ts-nocheck export const SHAPE_SHIFTER = new Proxy(() => {}, { - get: () => SHAPE_SHIFTER, + get: (_target, prop) => { + if (prop === Symbol.toPrimitive) return () => ""; + return SHAPE_SHIFTER; + }, apply: () => SHAPE_SHIFTER, }); diff --git a/src/lib/api/token-utils.spec.ts b/src/lib/api/token-utils.spec.ts index 879c397..08604f6 100644 --- a/src/lib/api/token-utils.spec.ts +++ b/src/lib/api/token-utils.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { InjectionError } from "../errors"; +import { ERR_CODES, InjectionError } from "../errors"; import type { Token } from "../provider/types"; import { NodeInjectable } from "./decorator"; import { MultiNodeToken, NodeToken } from "./token"; @@ -43,3 +43,29 @@ describe("Token Utils", () => { }); }); }); + +describe("extractToken alias target error (#35)", () => { + it("throws invalidAlias (i200) for a non-injectable function used as an alias target", () => { + function NotInjectable() {} + + let code: number | undefined; + try { + extractToken(NotInjectable as unknown as Token, true); + } catch (e) { + code = (e as InjectionError).code; + } + expect(code).toBe(ERR_CODES.INVALID_ALIAS); + }); + + it("still throws invalidCtor (i102) for a non-injectable function NOT used as an alias", () => { + function NotInjectable() {} + + let code: number | undefined; + try { + extractToken(NotInjectable as unknown as Token); + } catch (e) { + code = (e as InjectionError).code; + } + expect(code).toBe(ERR_CODES.INVALID_CTOR); + }); +}); diff --git a/src/lib/api/token-utils.ts b/src/lib/api/token-utils.ts index 8427aac..a9b52e4 100644 --- a/src/lib/api/token-utils.ts +++ b/src/lib/api/token-utils.ts @@ -39,7 +39,11 @@ export function extractToken( if (isNodeBase(provider)) return provider; if (typeof provider === "function") { - if (!isInjectable(provider)) throw InjectionError.invalidCtor(provider); + if (!isInjectable(provider)) { + if (isAlias) throw InjectionError.invalidAlias(provider); + throw InjectionError.invalidCtor(provider); + } + return getInjectableToken(provider); } diff --git a/src/lib/api/token.spec.ts b/src/lib/api/token.spec.ts index 51a9acb..3c7c4bf 100644 --- a/src/lib/api/token.spec.ts +++ b/src/lib/api/token.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { ERR_CODES, InjectionError } from "../errors"; import { MultiNodeToken, NodeBase, NodeToken } from "./token"; describe("Token", () => { @@ -206,3 +207,49 @@ describe("Token", () => { }); }); }); + +describe("global tokens", () => { + it("dedupes a single token by name so independent constructions are identical", () => { + const a = new NodeToken("seam.alpha", { global: true }); + const b = new NodeToken("seam.alpha", { global: true }); + expect(b).toBe(a); + }); + + it("dedupes multi tokens too", () => { + const a = new MultiNodeToken("seam.beta", { global: true }); + const b = new MultiNodeToken("seam.beta", { global: true }); + expect(b).toBe(a); + }); + + it("leaves non-global tokens as distinct instances", () => { + expect(new NodeToken("seam.gamma")).not.toBe(new NodeToken("seam.gamma")); + }); + + it("keeps a non-global token independent of a same-named global one", () => { + const g = new NodeToken("seam.delta", { global: true }); + expect(new NodeToken("seam.delta")).not.toBe(g); + }); + + it("throws when a name is reused for a different token kind", () => { + new NodeToken("seam.epsilon", { global: true }); + + let err: unknown; + try { + new MultiNodeToken("seam.epsilon", { global: true }); + } catch (e) { + err = e; + } + + expect(err).toBeInstanceOf(InjectionError); + expect((err as InjectionError).code).toBe(ERR_CODES.GLOBAL_TOKEN_CONFLICT); + expect((err as InjectionError).message).toMatch(/already registered/); + }); + + it("the first registration's options win for the shared instance", () => { + const factory = () => "x"; + const a = new NodeToken("seam.zeta", { global: true, factory }); + const b = new NodeToken("seam.zeta", { global: true }); + expect(b).toBe(a); + expect(b.opts?.factory).toBe(factory); + }); +}); diff --git a/src/lib/api/token.ts b/src/lib/api/token.ts index e9cb618..9447755 100644 --- a/src/lib/api/token.ts +++ b/src/lib/api/token.ts @@ -1,3 +1,4 @@ +import { InjectionError } from "../errors"; import type { Ctor, ImplementationShape, @@ -11,6 +12,7 @@ import type { } from "../provider/types"; const NODE_TOKEN_CLASSES_KEY = Symbol.for("@illuma/core/NodeTokenClasses"); +const GLOBAL_TOKENS_KEY = Symbol.for("@illuma/core/GlobalTokens"); type iNodeTokenGlobalThis = typeof globalThis & { [NODE_TOKEN_CLASSES_KEY]?: { @@ -18,6 +20,7 @@ type iNodeTokenGlobalThis = typeof globalThis & { NodeToken: typeof NodeTokenImpl; MultiNodeToken: typeof MultiNodeTokenImpl; }; + [GLOBAL_TOKENS_KEY]?: Map>; }; const nodeTokenGlobal = globalThis as iNodeTokenGlobalThis; @@ -31,7 +34,30 @@ abstract class NodeBaseImpl { constructor( public readonly name: string, public readonly opts?: iNodeTokenBaseOptions, - ) {} + ) { + if (!opts?.global) return; + + let registry = nodeTokenGlobal[GLOBAL_TOKENS_KEY]; + if (!registry) { + registry = new Map(); + nodeTokenGlobal[GLOBAL_TOKENS_KEY] = registry; + } + + const existing = registry.get(name); + if (existing) { + if (existing.constructor !== new.target) { + throw InjectionError.globalTokenConflict( + name, + existing.constructor.name, + new.target?.name, + ); + } + + return existing as NodeBaseImpl; + } + + registry.set(name, this); + } /** Provides this token with a value */ public withValue(value: T): iNodeValueProvider { @@ -103,7 +129,10 @@ abstract class NodeBaseImpl { * ``` */ class NodeTokenImpl extends NodeBaseImpl { - public readonly multi = false as const; + public get multi(): false { + return false; + } + public override toString(): string { return `NodeToken[${this.name}]`; } @@ -125,7 +154,10 @@ class NodeTokenImpl extends NodeBaseImpl { * ``` */ class MultiNodeTokenImpl extends NodeBaseImpl { - public readonly multi = true as const; + public get multi(): true { + return true; + } + public override toString(): string { return `MultiNodeToken[${this.name}]`; } diff --git a/src/lib/container/container.ts b/src/lib/container/container.ts index 29d6a2e..93057a8 100644 --- a/src/lib/container/container.ts +++ b/src/lib/container/container.ts @@ -13,6 +13,7 @@ import { extractProvider, ProtoNodeMulti, ProtoNodeSingle, + readNodeInstance, resolveTreeNode, TreeRootNode, } from "../provider"; @@ -46,7 +47,7 @@ export class NodeContainer extends Illuma implements iDIContainer { private _bootstrapped = false; private _rootNode?: TreeRootNode; - private readonly _unsubParentBootstrap?: () => void; + private _unsubParentBootstrap?: () => void; private readonly _unsubParentDestroy?: () => void; private readonly _parent?: iDIContainer; @@ -230,13 +231,36 @@ export class NodeContainer extends Illuma implements iDIContainer { const start = performance.now(); + // Snapshot providers and lifecycle hooks so a build that throws rolls back + // to the pre-bootstrap state instead of leaving cleared maps and stray + // hooks (which would fire on retry or on a teardown that never bootstrapped). + const protoSnapshot = new Map(this._protoNodes); + const multiSnapshot = new Map(this._multiProtoNodes); + const hookSnapshot = this._lifecycle.snapshotHooks(); + this.provide(Injector.withValue(this._injector)); this.provide(LifecycleRef.withValue(this._lifecycle)); - this._rootNode = this._buildInjectionTree(); - this._rootNode.build(); + try { + this._rootNode = this._buildInjectionTree(); + this._rootNode.build(); + } catch (error) { + this._rootNode = undefined; + this._protoNodes.clear(); + this._multiProtoNodes.clear(); + for (const [token, proto] of protoSnapshot) this._protoNodes.set(token, proto); + for (const [token, proto] of multiSnapshot) this._multiProtoNodes.set(token, proto); + this._lifecycle.restoreHooks(hookSnapshot); + throw error; + } + this._bootstrapped = true; + // Drop the parent's bootstrap-cascade hook: it has fired, and keeping it + // retains this child for the parent's lifetime and risks a re-bootstrap. + this._unsubParentBootstrap?.(); + this._unsubParentBootstrap = undefined; + const end = performance.now(); const duration = end - start; if (this._opts?.measurePerformance) { @@ -245,18 +269,22 @@ export class NodeContainer extends Illuma implements iDIContainer { this._lifecycle.runBootstrapHooks(); + // A bootstrap hook may have destroyed the container; skip diagnostics + // rather than dereference the cleared _rootNode. + if (!this._rootNode) return; + // Run diagnostics if enabled or diagnostics modules are registered if (Illuma.hasDiagnostics()) { - const allNodes = this._rootNode.dependencies.size; - const unusedNodes = Array.from(this._rootNode.dependencies) - .filter((node) => node.allocations === 0) - .filter((node) => { - if (!(node.proto instanceof ProtoNodeSingle)) return true; - return node.proto.token !== Injector && node.proto.token !== LifecycleRef; - }); + // Count totals and unused over the same population: user nodes, excluding + // the built-in Injector / LifecycleRef, so the ratio compares like sets. + const userNodes = Array.from(this._rootNode.dependencies).filter((node) => { + if (!(node.proto instanceof ProtoNodeSingle)) return true; + return node.proto.token !== Injector && node.proto.token !== LifecycleRef; + }); + const unusedNodes = userNodes.filter((node) => node.allocations === 0); Illuma.onReport({ - totalNodes: allNodes, + totalNodes: userNodes.length, unusedNodes: unusedNodes, bootstrapDuration: duration, }); @@ -301,15 +329,30 @@ export class NodeContainer extends Illuma implements iDIContainer { } const token = extractToken(provider); + return this._resolve(token, options); + } + + /** + * @internal + * Shared resolution algorithm for {@link get} and {@link produce}'s injector + * retriever, so the two cannot drift on modifiers, hierarchy walking, or + * root-singleton scoping. + */ + private _resolve( + token: NodeBase, + options?: iNodeInjectorOptions, + ): T | T[] | null { + if (!this._rootNode) throw InjectionError.notBootstrapped(); const { optional, self, skipSelf } = options ?? {}; if (self && skipSelf) { - throw InjectionError.conflictingStrategies(token as any); + throw InjectionError.conflictingStrategies(token); } if (!skipSelf) { const treeNode = this._rootNode.obtain(token); - if (treeNode) return treeNode.instance; + // self:true on a multi token yields only this container's own members. + if (treeNode) return readNodeInstance(treeNode, self ?? false); } if (!self) { @@ -342,36 +385,10 @@ export class NodeContainer extends Illuma implements iDIContainer { factory = isConstructor(fn) ? () => new fn() : (fn as () => T); } - const rootNode = this._rootNode; - if (!rootNode) throw InjectionError.notBootstrapped(); - - const retriever: InjectorFn = ( - token, - { optional, self, skipSelf }: iNodeInjectorOptions = {}, - ) => { - if (self && skipSelf) { - throw InjectionError.conflictingStrategies(token as NodeBase); - } - - if (!skipSelf) { - const node = rootNode.obtain(token); - if (node) return node.instance; - } - - if (!self) { - const upstream = this._getFromParent(token); - if (upstream) return upstream.instance; - } - - if (token instanceof MultiNodeToken) return []; - if (!skipSelf && token instanceof NodeToken && token.opts?.singleton) { - const singleton = this._getRootSingleton(token, true); - if (singleton) return singleton.instance; - } + if (!this._rootNode) throw InjectionError.notBootstrapped(); - if (!optional) throw InjectionError.notFound(token); - return null; - }; + // Same algorithm as get(), so produce()'s injections honor scoping identically. + const retriever: InjectorFn = (token, options) => this._resolve(token, options); const middlewares = [...Illuma._middlewares, ...this.collectMiddlewares()]; const contextFactory = () => InjectionContext.instantiate(factory, retriever); @@ -481,29 +498,50 @@ export class NodeContainer extends Illuma implements iDIContainer { /** @internal */ private _getFromParent(token: Token): TreeNode | null { - if (!this._parent) return null; - const parentNode = this._parent as NodeContainer; - - const upstream = parentNode.findNode(token); - if (upstream) return upstream; + if (!(this._parent instanceof NodeContainer)) return null; + const parentNode = this._parent; + + // Walk the whole ancestor chain, not just the immediate parent: a plain + // token on a grandparent must still resolve from a grandchild. + for ( + let ancestor: NodeContainer | undefined = parentNode; + ancestor; + ancestor = ancestor._parent instanceof NodeContainer ? ancestor._parent : undefined + ) { + const upstream = ancestor.findNode(token); + if (upstream) return upstream; + } return this._resolveSingletonFrom(parentNode, token, true); } /** @internal */ private _resolverFromParent(token: Token): TreeNode | null { - if (!this._parent || !(this._parent instanceof NodeContainer)) return null; - - const upstream = this._parent._findNode(token); - if (upstream) return upstream; + if (!(this._parent instanceof NodeContainer)) return null; + + // Build-time wiring mirrors runtime resolution: search every ancestor's + // pool up to the root, regardless of how many levels separate them. + for ( + let ancestor: NodeContainer | undefined = this._parent; + ancestor; + ancestor = ancestor._parent instanceof NodeContainer ? ancestor._parent : undefined + ) { + const upstream = ancestor._findNode(token); + if (upstream) return upstream; + } return this._resolveSingletonFrom(this._parent, token, false); } /** @internal */ private _buildInjectionTree(): TreeRootNode { - const middlewares = [...Illuma._middlewares, ...this.collectMiddlewares()]; - const root = new TreeRootNode(this._opts?.instant, middlewares); + // A thunk, not a frozen array: a node materialized lazily after bootstrap + // reads the CURRENT middleware chain, so lazy get() matches produce() even + // for a global middleware registered post-bootstrap. + const root = new TreeRootNode(this._opts?.instant, () => [ + ...Illuma._middlewares, + ...this.collectMiddlewares(), + ]); const cache = new Map(); const nodes: ProtoNode[] = [ diff --git a/src/lib/container/lifecycle.ts b/src/lib/container/lifecycle.ts index 076a3f2..5961843 100644 --- a/src/lib/container/lifecycle.ts +++ b/src/lib/container/lifecycle.ts @@ -9,6 +9,14 @@ type iLifecycleGlobalThis = typeof globalThis & { const lcrGlobal = globalThis as iLifecycleGlobalThis; +/** @internal Snapshot of every lifecycle hook registration. */ +interface iLifecycleHookSnapshot { + bootstrap: Set<() => void>; + bootstrapChild: Set<() => void>; + destroy: Set<() => void>; + destroyChild: Set<() => void>; +} + /** @internal */ export class LifecycleRefImpl { private readonly _destroyCallbacks = new Set<() => void>(); @@ -56,8 +64,64 @@ export class LifecycleRefImpl { * Should be called by the container after the bootstrap process is complete. */ public runBootstrapHooks(): void { - for (const cb of this._bootstrapCallbacks) cb(); - for (const cb of this._bootstrapChildCallbacks) cb(); + // Isolate each hook so one throwing callback cannot strand sibling children; + // surface the first error after all have run. Snapshot so a hook that + // registers or clears callbacks cannot disturb the walk. + const errors = this._runGuarded([ + Array.from(this._bootstrapCallbacks), + Array.from(this._bootstrapChildCallbacks), + ]); + + if (errors.length) throw errors[0]; + } + + /** + * Runs every callback in each group, isolating throws, and returns the + * collected errors (empty if none threw). Groups run in the order given. + */ + private _runGuarded(groups: Array void>>): unknown[] { + const errors: unknown[] = []; + for (const group of groups) { + for (const cb of group) { + try { + cb(); + } catch (e) { + errors.push(e); + } + } + } + return errors; + } + + /** + * @internal + * Captures all four hook sets so a failing bootstrap can roll them back, + * dropping user hooks and child hooks a factory may have registered. + */ + public snapshotHooks(): iLifecycleHookSnapshot { + return { + bootstrap: new Set(this._bootstrapCallbacks), + bootstrapChild: new Set(this._bootstrapChildCallbacks), + destroy: new Set(this._destroyCallbacks), + destroyChild: new Set(this._destroyChildCallbacks), + }; + } + + /** + * @internal + * Restores the hooks captured by {@link snapshotHooks}, dropping any added + * since (e.g. by a factory in a failed build, or a child spawned during it). + */ + public restoreHooks(snapshot: iLifecycleHookSnapshot): void { + this._restore(this._bootstrapCallbacks, snapshot.bootstrap); + this._restore(this._bootstrapChildCallbacks, snapshot.bootstrapChild); + this._restore(this._destroyCallbacks, snapshot.destroy); + this._restore(this._destroyChildCallbacks, snapshot.destroyChild); + } + + private _restore(target: Set<() => void>, snapshot: Set<() => void>): void { + target.clear(); + for (const cb of snapshot) target.add(cb); } /** @@ -99,17 +163,10 @@ export class LifecycleRefImpl { // Guard each hook so one throwing callback cannot strand sibling children // or abort the cascade; surface the first error after every hook has run. - const errors: unknown[] = []; - const run = (cb: () => void): void => { - try { - cb(); - } catch (e) { - errors.push(e); - } - }; - - for (const cb of Array.from(this._destroyChildCallbacks).reverse()) run(cb); - for (const cb of Array.from(this._destroyCallbacks).reverse()) run(cb); + const errors = this._runGuarded([ + Array.from(this._destroyChildCallbacks).reverse(), + Array.from(this._destroyCallbacks).reverse(), + ]); this._bootstrapCallbacks.clear(); this._bootstrapChildCallbacks.clear(); diff --git a/src/lib/container/tests/array-providers.spec.ts b/src/lib/container/tests/array-providers.spec.ts index 383076a..e90e58a 100644 --- a/src/lib/container/tests/array-providers.spec.ts +++ b/src/lib/container/tests/array-providers.spec.ts @@ -220,3 +220,20 @@ describe("array providers", () => { expect(container.get(tokenB)).toBe("value-b"); }); }); + +describe("multi member declaration order (#24)", () => { + it("resolves members in registration order regardless of provider kind", () => { + const M = new MultiNodeToken("ORDER_MULTI"); + const ALIAS_SRC = new NodeToken("ORDER_ALIAS_SRC"); + + const c = new NodeContainer(); + c.provide(ALIAS_SRC.withValue("alias")); + c.provide(M.withValue("first")); // value -> transparent + c.provide({ provide: M, alias: ALIAS_SRC }); // alias -> single + c.provide(M.withFactory(() => "factory")); // factory -> transparent + c.provide(M.withValue("last")); // value -> transparent + c.bootstrap(); + + expect(c.get(M)).toEqual(["first", "alias", "factory", "last"]); + }); +}); diff --git a/src/lib/container/tests/audit-fixes.spec.ts b/src/lib/container/tests/audit-fixes.spec.ts index cef7980..161b837 100644 --- a/src/lib/container/tests/audit-fixes.spec.ts +++ b/src/lib/container/tests/audit-fixes.spec.ts @@ -498,3 +498,292 @@ describe("LifecycleRef still resolvable after the fixes", () => { expect(c.get(Injector)).toBeDefined(); }); }); + +describe("resolution failure does not strand a node as in-progress", () => { + it("lazy: a retried get() after a factory throw succeeds instead of faking a cycle", () => { + let shouldThrow = true; + const B = new NodeToken("RETRY_B"); + const A = new NodeToken("RETRY_A"); + + const c = new NodeContainer({ instant: false }); + c.provide( + B.withFactory(() => { + if (shouldThrow) throw new Error("transient boom"); + return 42; + }), + ); + c.provide(A.withFactory(() => nodeInject(B) + 1)); + c.bootstrap(); + + expect(() => c.get(A)).toThrow("transient boom"); + + shouldThrow = false; + expect(c.get(A)).toBe(43); + }); + + it("lazy: a sibling sharing a failed dep does not see a bogus circular dependency", () => { + let shouldThrow = true; + const DEP = new NodeToken("SHARED_DEP"); + const A = new NodeToken("SHARED_A"); + const C = new NodeToken("SHARED_C"); + + const c = new NodeContainer({ instant: false }); + c.provide( + DEP.withFactory(() => { + if (shouldThrow) throw new Error("dep boom"); + return 1; + }), + ); + c.provide(A.withFactory(() => nodeInject(DEP))); + c.provide(C.withFactory(() => nodeInject(DEP))); + c.bootstrap(); + + expect(() => c.get(A)).toThrow("dep boom"); + + shouldThrow = false; + let caught: InjectionError | undefined; + try { + c.get(C); + } catch (e) { + caught = e as InjectionError; + } + expect(caught?.code).not.toBe(ERR_CODES.CIRCULAR_DEPENDENCY); + expect(c.get(C)).toBe(1); + }); + + it("lazy multi: a retried get() does not accumulate duplicate members", () => { + let shouldThrow = true; + const M = new MultiNodeToken("RETRY_MULTI"); + const GOOD = new NodeToken("RETRY_MULTI_GOOD"); + const BAD = new NodeToken("RETRY_MULTI_BAD"); + + const c = new NodeContainer({ instant: false }); + c.provide(GOOD.withValue(1)); + c.provide( + BAD.withFactory(() => { + if (shouldThrow) throw new Error("multi member boom"); + return 2; + }), + ); + c.provide({ provide: M, alias: GOOD }); + c.provide({ provide: M, alias: BAD }); + c.bootstrap(); + + expect(() => c.get(M)).toThrow("multi member boom"); + + shouldThrow = false; + expect(c.get(M)).toEqual([1, 2]); + }); +}); + +describe("bootstrap() is atomic (rollback on a failed build)", () => { + it("a retry after a throwing factory keeps every user provider", () => { + const GOOD = new NodeToken<{ v: number }>("ATOMIC_GOOD"); + const BAD = new NodeToken("ATOMIC_BAD"); + let boom = true; + + const c = new NodeContainer(); + c.provide(GOOD.withFactory(() => ({ v: 1 }))); + c.provide( + BAD.withFactory(() => { + if (boom) throw new Error("boom"); + return 2; + }), + ); + + expect(() => c.bootstrap()).toThrow("boom"); + expect(c.bootstrapped).toBe(false); + + boom = false; + expect(() => c.bootstrap()).not.toThrow(); + expect(c.bootstrapped).toBe(true); + expect(c.get(GOOD)).toEqual({ v: 1 }); + expect(c.get(BAD)).toBe(2); + }); + + it("rolls back when an unresolved dependency aborts the build, then succeeds once provided", () => { + const DEP = new NodeToken("ATOMIC_DEP"); + const HOST = new NodeToken("ATOMIC_HOST"); + + const c = new NodeContainer(); + c.provide(HOST.withFactory(() => nodeInject(DEP) + 1)); + + // DEP is missing: the build aborts with notFound. + expect(() => c.bootstrap()).toThrow(InjectionError); + expect(c.bootstrapped).toBe(false); + + // Providing the missing dep and retrying must succeed with HOST intact. + c.provide(DEP.withValue(41)); + expect(() => c.bootstrap()).not.toThrow(); + expect(c.get(HOST)).toBe(42); + }); + + it("does not double-register the Injector/LifecycleRef built-ins across a failed attempt", () => { + const BAD = new NodeToken("ATOMIC_BUILTIN_BAD"); + let boom = true; + + const c = new NodeContainer(); + c.provide( + BAD.withFactory(() => { + if (boom) throw new Error("boom"); + return 1; + }), + ); + + expect(() => c.bootstrap()).toThrow("boom"); + boom = false; + // A clean retry proves the built-ins were rolled back (no duplicate-provider). + expect(() => c.bootstrap()).not.toThrow(); + expect(c.get(Injector)).toBeDefined(); + expect(c.get(LifecycleRef)).toBeDefined(); + expect(c.get(BAD)).toBe(1); + }); + + it("rolls back afterBootstrap hooks registered during a failed build", () => { + const EARLY = new NodeToken("ATOMIC_HOOK_EARLY"); + const BAD = new NodeToken("ATOMIC_HOOK_BAD"); + let boom = true; + let hookRuns = 0; + + const c = new NodeContainer(); + c.provide( + EARLY.withFactory(() => { + nodeInject(LifecycleRef).afterBootstrap(() => { + hookRuns++; + }); + return 1; + }), + ); + c.provide( + BAD.withFactory(() => { + if (boom) throw new Error("boom"); + return 2; + }), + ); + + expect(() => c.bootstrap()).toThrow("boom"); + boom = false; + c.bootstrap(); + + // The hook leaked from the failed attempt must NOT also fire. + expect(hookRuns).toBe(1); + }); + + it("rolls back beforeDestroy hooks registered during a failed build", () => { + const EARLY = new NodeToken("ATOMIC_DESTROY_EARLY"); + const BAD = new NodeToken("ATOMIC_DESTROY_BAD"); + let boom = true; + let destroyRuns = 0; + + const c = new NodeContainer(); + c.provide( + EARLY.withFactory(() => { + nodeInject(LifecycleRef).beforeDestroy(() => { + destroyRuns++; + }); + return 1; + }), + ); + c.provide( + BAD.withFactory(() => { + if (boom) throw new Error("boom"); + return 2; + }), + ); + + expect(() => c.bootstrap()).toThrow("boom"); + boom = false; + c.bootstrap(); + c.destroy(); + + // A beforeDestroy from a never-completed bootstrap must not run on teardown. + expect(destroyRuns).toBe(1); + }); + + it("rolls back child hooks from a child spawned during a failed build", () => { + const EARLY = new NodeToken("ATOMIC_CHILD_EARLY"); + const BAD = new NodeToken("ATOMIC_CHILD_BAD"); + let boom = true; + + const c = new NodeContainer(); + c.provide( + EARLY.withFactory(() => { + // Spawning a child registers onChildBootstrap/onChildDestroy on THIS + // (parent) lifecycle — those must roll back with a failed attempt. + nodeInject(Injector).spawnChild(); + return 1; + }), + ); + c.provide( + BAD.withFactory(() => { + if (boom) throw new Error("boom"); + return 2; + }), + ); + + expect(() => c.bootstrap()).toThrow("boom"); + boom = false; + c.bootstrap(); + + const lifecycle = (c as any)._lifecycle; + // Only the successful attempt's child remains: the failed attempt's child + // hooks were rolled back. The surviving child's bootstrap hook is then + // released once it bootstraps in the cascade (#38), leaving its destroy hook. + expect(lifecycle._bootstrapChildCallbacks.size).toBe(0); + expect(lifecycle._destroyChildCallbacks.size).toBe(1); + }); +}); + +describe("bootstrap hook error isolation (#4) and destroy-in-hook (#22)", () => { + afterEach(() => { + (Illuma as any).__resetPlugins(); + }); + + it("one throwing afterBootstrap hook does not abort sibling hooks", () => { + const c = new NodeContainer(); + c.bootstrap(); + const lifecycle = (c as any)._lifecycle; + + let secondRan = false; + lifecycle.afterBootstrap(() => { + throw new Error("hook boom"); + }); + lifecycle.afterBootstrap(() => { + secondRan = true; + }); + + expect(() => lifecycle.runBootstrapHooks()).toThrow("hook boom"); + expect(secondRan).toBe(true); + }); + + it("a throwing child bootstrap does not strand sibling children in the cascade", () => { + const parent = new NodeContainer(); + const childA = parent.child() as NodeContainer; + const childB = parent.child() as NodeContainer; + const childC = parent.child() as NodeContainer; + + // childB fails to bootstrap (a throwing factory). + childB.provide( + new NodeToken("CASCADE_BAD").withFactory(() => { + throw new Error("child boom"); + }), + ); + + expect(() => parent.bootstrap()).toThrow("child boom"); + + expect(childA.bootstrapped).toBe(true); + expect(childC.bootstrapped).toBe(true); + expect(childB.bootstrapped).toBe(false); + }); + + it("bootstrap does not crash when a hook destroys the container with diagnostics on", () => { + Illuma.extendDiagnostics({ onReport: () => {} }); + + const c = new NodeContainer(); + const lifecycle = (c as any)._lifecycle; + lifecycle.afterBootstrap(() => c.destroy()); + + expect(() => c.bootstrap()).not.toThrow(); + expect(c.destroyed).toBe(true); + }); +}); diff --git a/src/lib/container/tests/cross-container-isolation.spec.ts b/src/lib/container/tests/cross-container-isolation.spec.ts new file mode 100644 index 0000000..0d0b438 --- /dev/null +++ b/src/lib/container/tests/cross-container-isolation.spec.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { MultiNodeToken, NodeToken, nodeInject } from "../../api"; +import { Illuma } from "../../global"; +import type { iMiddleware } from "../../plugins/middlewares"; +import { NodeContainer } from "../container"; + +// Tags an object instance with a flag, so a test can observe which container's +// middleware chain actually wrapped a given node's factory. +const tagWith = + (flag: string): iMiddleware => + (params, next) => { + const instance = next(params) as any; + if (instance && typeof instance === "object") instance[flag] = true; + return instance; + }; + +describe("cross-container pool/middleware isolation", () => { + afterEach(() => { + (Illuma as any).__resetPlugins(); + }); + + it("a lazy parent's node injected by an instant child runs under the PARENT chain (#21)", () => { + const PTOK = new NodeToken>("CC_PTOK"); + let factoryRuns = 0; + + const parent = new NodeContainer({ instant: false }); + parent.registerMiddleware(tagWith("parentMw")); + parent.provide( + PTOK.withFactory(() => { + factoryRuns++; + return {}; + }), + ); + parent.bootstrap(); + // The provide()-time dependency-scan dry-run already executed the factory + // once (a throwaway, no middleware). Reset so we count only the real + // materialization in the tree. + factoryRuns = 0; + + const HOST = new NodeToken<{ p: Record }>("CC_HOST"); + const child = new NodeContainer({ parent, instant: true }); + child.registerMiddleware(tagWith("childMw")); + child.provide(HOST.withFactory(() => ({ p: nodeInject(PTOK) }))); + child.bootstrap(); + + const p = child.get(HOST).p; + expect(p.parentMw).toBe(true); // ran under the parent's middleware + expect(p.childMw).toBeUndefined(); // NOT contaminated by the child's middleware + expect(factoryRuns).toBe(1); // materialized exactly once + + // Same instance across the boundary. + expect(parent.get(PTOK)).toBe(p); + }); + + it("inherited parent multi members are not wrapped in child middleware (#8)", () => { + const M = new MultiNodeToken<{ name: string; childMw?: boolean; parentMw?: boolean }>( + "CC_MULTI", + ); + + const parent = new NodeContainer({ instant: false }); + parent.registerMiddleware(tagWith("parentMw")); + parent.provide(M.withFactory(() => ({ name: "p" }))); + parent.bootstrap(); + + const child = new NodeContainer({ parent, instant: true }); + child.registerMiddleware(tagWith("childMw")); + child.provide(M.withFactory(() => ({ name: "c" }))); + child.bootstrap(); + + const members = child.get(M); + const pMember = members.find((m) => m.name === "p"); + const cMember = members.find((m) => m.name === "c"); + + expect(pMember?.parentMw).toBe(true); + expect(pMember?.childMw).toBeUndefined(); // parent member keeps parent chain + expect(cMember?.childMw).toBe(true); // child member runs under child chain + + // The parent's own view of M is likewise un-contaminated. + expect(parent.get(M).every((m) => m.childMw === undefined)).toBe(true); + }); + + it("a parent token is not leaked into the child's own tree pool", () => { + const PTOK = new NodeToken>("CC_POOL_PTOK"); + const parent = new NodeContainer({ instant: false }); + parent.provide(PTOK.withFactory(() => ({}))); + parent.bootstrap(); + + const HOST = new NodeToken("CC_POOL_HOST"); + const child = new NodeContainer({ parent, instant: true }); + child.provide(HOST.withFactory(() => nodeInject(PTOK))); + child.bootstrap(); + + // The child reaches PTOK upstream; PTOK's node belongs to the parent's pool, + // not the child's own tree pool. + expect((child as any)._findNode(PTOK)).toBeNull(); + }); + + describe("post-bootstrap global middleware consistency (#39)", () => { + it("a global middleware registered after bootstrap applies to lazy get() (matching produce)", () => { + const TOKEN = new NodeToken>("CC_39_LAZY"); + const c = new NodeContainer({ instant: false }); + c.provide(TOKEN.withFactory(() => ({}))); + c.bootstrap(); + + Illuma.registerGlobalMiddleware(tagWith("lateMw")); + + const viaGet = c.get(TOKEN); + const viaProduce = c.produce(() => nodeInject(TOKEN, { optional: true })); + + expect(viaGet.lateMw).toBe(true); + expect((viaProduce as any).lateMw).toBe(true); + }); + + it("a global middleware registered after bootstrap does NOT retroactively wrap an instant node", () => { + const TOKEN = new NodeToken>("CC_39_INSTANT"); + const c = new NodeContainer(); // instant + c.provide(TOKEN.withFactory(() => ({}))); + c.bootstrap(); // node already materialized here + + Illuma.registerGlobalMiddleware(tagWith("lateMw")); + + expect(c.get(TOKEN).lateMw).toBeUndefined(); + }); + }); +}); diff --git a/src/lib/container/tests/hierarchy-resolution.spec.ts b/src/lib/container/tests/hierarchy-resolution.spec.ts new file mode 100644 index 0000000..bb47c9d --- /dev/null +++ b/src/lib/container/tests/hierarchy-resolution.spec.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import { MultiNodeToken, NodeToken, nodeInject } from "../../api"; +import { NodeContainer } from "../container"; + +// Resolution must traverse the full ancestor chain up to the root, not just the +// immediate parent (RESOLUTION_MODIFIERS.md: "traverse up the container +// hierarchy ... until it finds a provider or reaches the root container"). + +describe("multi-level hierarchy: single tokens", () => { + function threeLevels() { + const root = new NodeContainer(); + const ROOT_TOK = new NodeToken("HR_ROOT_TOK"); + root.provide(ROOT_TOK.withValue("from-root")); + root.bootstrap(); + const child = root.child() as NodeContainer; + child.bootstrap(); + const grandchild = child.child() as NodeContainer; + grandchild.bootstrap(); + return { root, child, grandchild, ROOT_TOK }; + } + + it("a grandchild resolves a non-singleton token provided on the root via get()", () => { + const { grandchild, ROOT_TOK } = threeLevels(); + expect(grandchild.get(ROOT_TOK)).toBe("from-root"); + }); + + it("a grandchild factory can depend on a root-provided non-singleton token", () => { + const ROOT_TOK = new NodeToken("HR_DEP_ROOT"); + const LOCAL = new NodeToken("HR_DEP_LOCAL"); + + const root = new NodeContainer(); + root.provide(ROOT_TOK.withValue(40)); + root.bootstrap(); + + const child = root.child() as NodeContainer; + child.bootstrap(); + + const grandchild = child.child() as NodeContainer; + grandchild.provide(LOCAL.withFactory(() => nodeInject(ROOT_TOK) + 2)); + expect(() => grandchild.bootstrap()).not.toThrow(); + expect(grandchild.get(LOCAL)).toBe(42); + }); + + it("the nearest ancestor wins when the token is provided at multiple levels", () => { + const TOK = new NodeToken("HR_NEAREST"); + + const root = new NodeContainer(); + root.provide(TOK.withValue("root")); + root.bootstrap(); + + const child = root.child() as NodeContainer; + child.provide(TOK.withValue("child")); + child.bootstrap(); + + const grandchild = child.child() as NodeContainer; + grandchild.bootstrap(); + + expect(grandchild.get(TOK)).toBe("child"); + }); + + it("still resolves from the immediate parent (regression)", () => { + const TOK = new NodeToken("HR_PARENT_ONLY"); + const parent = new NodeContainer(); + parent.provide(TOK.withValue("parent")); + parent.bootstrap(); + const child = parent.child() as NodeContainer; + child.bootstrap(); + expect(child.get(TOK)).toBe("parent"); + }); + + it("still throws notFound for a token provided nowhere in the chain", () => { + const { grandchild } = threeLevels(); + const MISSING = new NodeToken("HR_MISSING"); + expect(() => grandchild.get(MISSING)).toThrow(); + expect(grandchild.get(MISSING, { optional: true })).toBeNull(); + }); +}); + +describe("multi-level hierarchy: multi tokens", () => { + it("a grandchild get(MULTI) sees ancestor members instead of []", () => { + const M = new MultiNodeToken("HR_MULTI_ROOT"); + + const root = new NodeContainer(); + root.provide(M.withValue("root-member")); + root.bootstrap(); + + const child = root.child() as NodeContainer; + child.bootstrap(); + const grandchild = child.child() as NodeContainer; + grandchild.bootstrap(); + + expect(grandchild.get(M)).toEqual(["root-member"]); + }); + + it("aggregates members across all three levels", () => { + const M = new MultiNodeToken("HR_MULTI_AGG"); + + const root = new NodeContainer(); + root.provide(M.withValue("root")); + root.bootstrap(); + + const child = root.child() as NodeContainer; + child.provide(M.withValue("child")); + child.bootstrap(); + + const grandchild = child.child() as NodeContainer; + grandchild.provide(M.withValue("grandchild")); + grandchild.bootstrap(); + + expect(grandchild.get(M).sort()).toEqual(["child", "grandchild", "root"]); + }); + + it("aggregates across an empty intermediate level", () => { + const M = new MultiNodeToken("HR_MULTI_SKIP"); + + const root = new NodeContainer(); + root.provide(M.withValue("root")); + root.bootstrap(); + + const child = root.child() as NodeContainer; // no members here + child.bootstrap(); + + const grandchild = child.child() as NodeContainer; + grandchild.provide(M.withValue("grandchild")); + grandchild.bootstrap(); + + expect(grandchild.get(M).sort()).toEqual(["grandchild", "root"]); + }); + + it("returns [] for a multi token provided nowhere in the chain", () => { + const M = new MultiNodeToken("HR_MULTI_NONE"); + const root = new NodeContainer(); + root.bootstrap(); + const child = root.child() as NodeContainer; + child.bootstrap(); + const grandchild = child.child() as NodeContainer; + grandchild.bootstrap(); + expect(grandchild.get(M)).toEqual([]); + }); +}); + +describe("multi-level hierarchy: lazy (instant:false) mode", () => { + it("a grandchild resolves a root-provided non-singleton in lazy mode", () => { + const ROOT_TOK = new NodeToken("HR_LAZY_ROOT"); + + const root = new NodeContainer({ instant: false }); + root.provide(ROOT_TOK.withValue("from-root")); + root.bootstrap(); + const child = root.child() as NodeContainer; + child.bootstrap(); + const grandchild = child.child() as NodeContainer; + grandchild.bootstrap(); + + expect(grandchild.get(ROOT_TOK)).toBe("from-root"); + }); + + it("a lazy grandchild factory depends on a root-provided token", () => { + const ROOT_TOK = new NodeToken("HR_LAZY_DEP_ROOT"); + const LOCAL = new NodeToken("HR_LAZY_DEP_LOCAL"); + + const root = new NodeContainer({ instant: false }); + root.provide(ROOT_TOK.withValue(40)); + root.bootstrap(); + const child = root.child() as NodeContainer; + child.bootstrap(); + const grandchild = child.child() as NodeContainer; + grandchild.provide(LOCAL.withFactory(() => nodeInject(ROOT_TOK) + 2)); + grandchild.bootstrap(); + + expect(grandchild.get(LOCAL)).toBe(42); + }); +}); diff --git a/src/lib/container/tests/lifecycle.spec.ts b/src/lib/container/tests/lifecycle.spec.ts index 78972d3..2dd2f17 100644 --- a/src/lib/container/tests/lifecycle.spec.ts +++ b/src/lib/container/tests/lifecycle.spec.ts @@ -62,3 +62,28 @@ describe("Container lifecycle", () => { expect(() => container.destroy()).toThrowError(InjectionError); }); }); + +describe("cascade bootstrap hook cleanup (#38)", () => { + it("releases the parent's bootstrap-cascade hook after the child is bootstrapped", () => { + const parent = new NodeContainer(); + const child = parent.child() as NodeContainer; + const lc = (parent as any)._lifecycle; + + // Created before parent bootstrap → the child registered an onChildBootstrap hook. + expect(lc._bootstrapChildCallbacks.size).toBe(1); + + parent.bootstrap(); + + expect(child.bootstrapped).toBe(true); + // The hook is dropped once the child has bootstrapped (no parent retention). + expect(lc._bootstrapChildCallbacks.size).toBe(0); + }); + + it("still tears down a cascade-bootstrapped child on parent destroy", () => { + const parent = new NodeContainer(); + const child = parent.child() as NodeContainer; + parent.bootstrap(); + parent.destroy(); + expect(child.destroyed).toBe(true); + }); +}); diff --git a/src/lib/container/tests/resolution-modifiers.spec.ts b/src/lib/container/tests/resolution-modifiers.spec.ts new file mode 100644 index 0000000..de15745 --- /dev/null +++ b/src/lib/container/tests/resolution-modifiers.spec.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from "vitest"; +import { MultiNodeToken, NodeToken, nodeInject } from "../../api"; +import { createTestFactory } from "../../testkit/helpers"; +import { injectDefer } from "../../utils/defer"; +import { NodeContainer } from "../container"; + +describe("self:true on a multi token returns local-only members (#3)", () => { + function parentChild() { + const M = new MultiNodeToken("RM_SELF_MULTI"); + const parent = new NodeContainer(); + parent.provide(M.withValue("parent")); + parent.bootstrap(); + const child = parent.child() as NodeContainer; + child.provide(M.withValue("child")); + child.bootstrap(); + return { M, parent, child }; + } + + it("get(M, { self: true }) returns only the child's own members", () => { + const { M, child } = parentChild(); + expect(child.get(M, { self: true })).toEqual(["child"]); + }); + + it("get(M) without self still returns inherited + local members", () => { + const { M, child } = parentChild(); + expect(child.get(M).sort()).toEqual(["child", "parent"]); + }); + + it("factory nodeInject(M, { self: true }) is local-only", () => { + const M = new MultiNodeToken("RM_SELF_FACT_MULTI"); + const HOST = new NodeToken("RM_SELF_FACT_HOST"); + + const parent = new NodeContainer(); + parent.provide(M.withValue("parent")); + parent.bootstrap(); + + const child = parent.child() as NodeContainer; + child.provide(M.withValue("child")); + child.provide(HOST.withFactory(() => nodeInject(M, { self: true }))); + child.bootstrap(); + + expect(child.get(HOST)).toEqual(["child"]); + }); + + it("produce(() => nodeInject(M, { self: true })) is local-only", () => { + const { M, child } = parentChild(); + expect(child.produce(() => nodeInject(M, { self: true }))).toEqual(["child"]); + }); + + it("self:true returns [] when the child has no local members", () => { + const M = new MultiNodeToken("RM_SELF_EMPTY"); + const parent = new NodeContainer(); + parent.provide(M.withValue("parent")); + parent.bootstrap(); + const child = parent.child() as NodeContainer; + child.bootstrap(); + expect(child.get(M, { self: true })).toEqual([]); + }); + + it("skipSelf still returns ancestor-only members (regression)", () => { + const { M, child } = parentChild(); + expect(child.get(M, { skipSelf: true })).toEqual(["parent"]); + }); + + it("3-level: grandchild self returns only its own members", () => { + const M = new MultiNodeToken("RM_SELF_3LVL"); + const root = new NodeContainer(); + root.provide(M.withValue("root")); + root.bootstrap(); + const child = root.child() as NodeContainer; + child.provide(M.withValue("child")); + child.bootstrap(); + const grandchild = child.child() as NodeContainer; + grandchild.provide(M.withValue("grandchild")); + grandchild.bootstrap(); + + expect(grandchild.get(M, { self: true })).toEqual(["grandchild"]); + expect(grandchild.get(M).sort()).toEqual(["child", "grandchild", "root"]); + }); +}); + +describe("same token injected in two scopes in one factory (#20)", () => { + it("plain and skipSelf injections of the same token resolve to different scopes", () => { + const A = new NodeToken("RM_TWOSCOPE_A"); + const HOST = new NodeToken<{ local: string; parent: string }>("RM_TWOSCOPE_HOST"); + + const parent = new NodeContainer(); + parent.provide(A.withValue("parent")); + parent.bootstrap(); + + const child = parent.child() as NodeContainer; + child.provide(A.withValue("child")); + child.provide( + HOST.withFactory(() => ({ + local: nodeInject(A), + parent: nodeInject(A, { skipSelf: true }), + })), + ); + child.bootstrap(); + + const host = child.get(HOST); + expect(host.local).toBe("child"); + expect(host.parent).toBe("parent"); + }); + + it("self and skipSelf injections of the same token do not collide", () => { + const A = new NodeToken("RM_TWOSCOPE_SELF_A"); + const HOST = new NodeToken<{ self: string; parent: string }>("RM_TWOSCOPE_SELF_HOST"); + + const parent = new NodeContainer(); + parent.provide(A.withValue("parent")); + parent.bootstrap(); + + const child = parent.child() as NodeContainer; + child.provide(A.withValue("child")); + child.provide( + HOST.withFactory(() => ({ + self: nodeInject(A, { self: true }), + parent: nodeInject(A, { skipSelf: true }), + })), + ); + child.bootstrap(); + + const host = child.get(HOST); + expect(host.self).toBe("child"); + expect(host.parent).toBe("parent"); + }); + + it("multi token: plain (aggregated) and skipSelf (ancestor-only) in one factory", () => { + const M = new MultiNodeToken("RM_TWOSCOPE_MULTI"); + const HOST = new NodeToken<{ all: string[]; parent: string[] }>( + "RM_TWOSCOPE_MULTI_HOST", + ); + + const parent = new NodeContainer(); + parent.provide(M.withValue("parent")); + parent.bootstrap(); + + const child = parent.child() as NodeContainer; + child.provide(M.withValue("child")); + child.provide( + HOST.withFactory(() => ({ + all: nodeInject(M), + parent: nodeInject(M, { skipSelf: true }), + })), + ); + child.bootstrap(); + + const host = child.get(HOST); + expect(host.all.sort()).toEqual(["child", "parent"]); + expect(host.parent).toEqual(["parent"]); + }); + + it("a token injected only via skipSelf still resolves (no plain slot needed)", () => { + const A = new NodeToken("RM_SKIPONLY_A"); + const HOST = new NodeToken("RM_SKIPONLY_HOST"); + + const parent = new NodeContainer(); + parent.provide(A.withValue("parent")); + parent.bootstrap(); + + const child = parent.child() as NodeContainer; + child.provide(A.withValue("child")); + child.provide(HOST.withFactory(() => nodeInject(A, { skipSelf: true }))); + child.bootstrap(); + + expect(child.get(HOST)).toBe("parent"); + }); +}); + +describe("produce()/get() parity on skipSelf + root singleton (#36)", () => { + it("produce resolves a root singleton under skipSelf, matching get", () => { + const SINGLETON = new NodeToken("RM_SINGLETON", { + singleton: true, + factory: () => "singleton-value", + }); + + const c = new NodeContainer(); + c.provide(SINGLETON); + c.bootstrap(); + + const viaGet = c.get(SINGLETON, { skipSelf: true }); + const viaProduce = c.produce(() => nodeInject(SINGLETON, { skipSelf: true })); + + expect(viaGet).toBe("singleton-value"); + expect(viaProduce).toBe(viaGet); + }); + + it("produce and get agree on a plain skipSelf miss (both behave the same)", () => { + const PLAIN = new NodeToken("RM_PLAIN"); + const c = new NodeContainer(); + c.provide(PLAIN.withValue("local")); + c.bootstrap(); + + // skipSelf on a root container with no parent: neither resolves the local value. + const getThrew = (() => { + try { + c.get(PLAIN, { skipSelf: true }); + return false; + } catch { + return true; + } + })(); + const produceThrew = (() => { + try { + c.produce(() => nodeInject(PLAIN, { skipSelf: true })); + return false; + } catch { + return true; + } + })(); + + expect(produceThrew).toBe(getThrew); + }); +}); + +describe("injectDefer forwards skipSelf/self modifiers (#18)", () => { + it("a deferred skipSelf injection resolves from the parent, not self", () => { + const TOK = new NodeToken("RM_DEFER_TOK"); + const HOST = new NodeToken<() => string>("RM_DEFER_HOST"); + + const parent = new NodeContainer(); + parent.provide(TOK.withValue("parent")); + parent.bootstrap(); + + const child = parent.child() as NodeContainer; + child.provide(TOK.withValue("child")); + child.provide(HOST.withFactory(() => injectDefer(TOK, { skipSelf: true }))); + child.bootstrap(); + + const deferred = child.get(HOST); + expect(deferred()).toBe("parent"); + }); + + it("a deferred self injection resolves locally only", () => { + const TOK = new NodeToken("RM_DEFER_SELF_TOK"); + const HOST = new NodeToken<() => string>("RM_DEFER_SELF_HOST"); + + const parent = new NodeContainer(); + parent.provide(TOK.withValue("parent")); + parent.bootstrap(); + + const child = parent.child() as NodeContainer; + child.provide(TOK.withValue("child")); + child.provide(HOST.withFactory(() => injectDefer(TOK, { self: true }))); + child.bootstrap(); + + expect(child.get(HOST)()).toBe("child"); + }); + + it("deferred optional still returns null when unprovided", () => { + const MISSING = new NodeToken("RM_DEFER_MISSING"); + const HOST = new NodeToken<() => string | null>("RM_DEFER_OPT_HOST"); + + const c = new NodeContainer(); + c.provide(HOST.withFactory(() => injectDefer(MISSING, { optional: true }))); + c.bootstrap(); + + expect(c.get(HOST)()).toBeNull(); + }); +}); + +describe("testkit Spectator forwards modifiers (#27)", () => { + it("spectator.nodeInject honors skipSelf (standalone container has no parent)", () => { + const TARGET = new NodeToken("RM_TK_TARGET", { factory: () => "target" }); + const DEP = new NodeToken("RM_TK_DEP"); + + const factory = createTestFactory({ + target: TARGET, + provide: [DEP.withValue("dep")], + }); + const spectator = factory(); + + expect(spectator.nodeInject(DEP)).toBe("dep"); + // skipSelf is now forwarded: a standalone container resolves nothing upstream. + expect(() => spectator.nodeInject(DEP, { skipSelf: true })).toThrow(); + }); + + it("spectator.nodeInject still honors optional", () => { + const TARGET = new NodeToken("RM_TK_OPT_TARGET", { + factory: () => "target", + }); + const MISSING = new NodeToken("RM_TK_MISSING"); + + const factory = createTestFactory({ + target: TARGET, + }); + const spectator = factory(); + + expect(spectator.nodeInject(MISSING, { optional: true })).toBeNull(); + }); +}); diff --git a/src/lib/container/tests/singleton.spec.ts b/src/lib/container/tests/singleton.spec.ts index ac1ed7e..431b5e2 100644 --- a/src/lib/container/tests/singleton.spec.ts +++ b/src/lib/container/tests/singleton.spec.ts @@ -488,3 +488,33 @@ describe("singletons", () => { }); }); }); + +describe("on-demand singleton factory failure (#11)", () => { + it("does not strand dead nodes in the root when the singleton factory throws", () => { + let boom = true; + const SING = new NodeToken("STRAND_SINGLETON", { + singleton: true, + factory: () => { + if (boom) throw new Error("sing boom"); + return 42; + }, + }); + + const c = new NodeContainer(); // SING resolved on-demand, not provided + c.bootstrap(); + + const root = (c as any)._rootNode; + const depsBefore = root.dependencies.size; + + expect(() => c.get(SING)).toThrow("sing boom"); + expect(() => c.get(SING)).toThrow("sing boom"); + expect(() => c.get(SING)).toThrow("sing boom"); + + // Failed attempts must not accumulate dead nodes in the long-lived root. + expect(root.dependencies.size).toBe(depsBefore); + + // Once the transient failure clears, resolution succeeds. + boom = false; + expect(c.get(SING)).toBe(42); + }); +}); diff --git a/src/lib/errors.spec.ts b/src/lib/errors.spec.ts index a773a22..fb6dd01 100644 --- a/src/lib/errors.spec.ts +++ b/src/lib/errors.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { MultiNodeToken, NodeInjectable, NodeToken, nodeInject } from "./api"; import { NodeContainer } from "./container"; -import { InjectionError } from "./errors"; +import { ERR_CODES, InjectionError } from "./errors"; describe("error handling", () => { it("should throw on duplicate token provider", () => { @@ -146,3 +146,36 @@ describe("error handling", () => { expect(() => container.bootstrap()).toThrow(InjectionError.notFound(missing)); }); }); + +describe("InjectionError factories", () => { + it("builds a global token conflict error", () => { + const error = InjectionError.globalTokenConflict( + "seam.alpha", + "NodeTokenImpl", + "MultiNodeTokenImpl", + ); + + expect(error).toBeInstanceOf(InjectionError); + expect(error.code).toBe(ERR_CODES.GLOBAL_TOKEN_CONFLICT); + expect(error.message).toContain('Global token "seam.alpha" is already registered'); + expect(error.message).toContain("[i600]"); + }); + + it("builds a middleware next() reuse error", () => { + const error = InjectionError.middlewareNextReused(); + + expect(error).toBeInstanceOf(InjectionError); + expect(error.code).toBe(ERR_CODES.MIDDLEWARE_NEXT_REUSED); + expect(error.message).toContain("next() was called more than once"); + expect(error.message).toContain("[i700]"); + }); + + it("builds an unknown ProtoNode error", () => { + const error = InjectionError.unknownProtoNode(); + + expect(error).toBeInstanceOf(InjectionError); + expect(error.code).toBe(ERR_CODES.UNKNOWN_PROTO_NODE); + expect(error.message).toContain("Unknown ProtoNode type"); + expect(error.message).toContain("[i800]"); + }); +}); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 198a8a3..0305f13 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -31,6 +31,15 @@ export const ERR_CODES = { CALLED_UTILS_OUTSIDE_CONTEXT: 502, INSTANCE_ACCESS_FAILED: 503, ACCESS_FAILED: 504, + + // Token errors + GLOBAL_TOKEN_CONFLICT: 600, + + // Middleware errors + MIDDLEWARE_NEXT_REUSED: 700, + + // Internal invariant errors + UNKNOWN_PROTO_NODE: 800, } as const; /** @@ -201,6 +210,32 @@ export class InjectionError extends Error { "Failed to access the requested instance due to an unknown error.", ); } + + // Token errors + public static globalTokenConflict( + name: string, + existing: string, + attempted: string | undefined, + ): InjectionError { + return new InjectionError( + ERR_CODES.GLOBAL_TOKEN_CONFLICT, + `Global token "${name}" is already registered as ${existing}; ` + + `cannot redeclare it as ${attempted}.`, + ); + } + + // Middleware errors + public static middlewareNextReused(): InjectionError { + return new InjectionError( + ERR_CODES.MIDDLEWARE_NEXT_REUSED, + "Middleware next() was called more than once.", + ); + } + + // Internal invariant errors + public static unknownProtoNode(): InjectionError { + return new InjectionError(ERR_CODES.UNKNOWN_PROTO_NODE, "Unknown ProtoNode type."); + } } /** diff --git a/src/lib/global/global.ts b/src/lib/global/global.ts index 52211b5..75664d5 100644 --- a/src/lib/global/global.ts +++ b/src/lib/global/global.ts @@ -153,7 +153,9 @@ abstract class IllumaBase { } protected static onReport(report: iDiagnosticsReport): void { - for (const diag of IllumaBase._diagnostics) diag.onReport(report); + // Iterate a snapshot so a reporter that registers another module during + // onReport doesn't mutate the set mid-iteration. + for (const diag of Array.from(IllumaBase._diagnostics)) diag.onReport(report); } /** @@ -173,6 +175,11 @@ abstract class IllumaBase { IllumaBase._scanners.length = 0; IllumaBase._middlewares.length = 0; illumaState.logger = defaultLogger; + // Clear the built-in diagnostics idempotency flag too (referenced by its + // shared symbol to avoid a circular import) so a reset fully re-arms it. + (globalThis as Record)[ + Symbol.for("@illuma/core/DiagnosticsEnabled") + ] = false; } } diff --git a/src/lib/plugins/diagnostics/built-in.ts b/src/lib/plugins/diagnostics/built-in.ts index 2118fa8..8d0f515 100644 --- a/src/lib/plugins/diagnostics/built-in.ts +++ b/src/lib/plugins/diagnostics/built-in.ts @@ -2,12 +2,18 @@ import { Illuma } from "../../global"; import { performanceDiagnostics } from "../middlewares/diagnostics.middleware"; import { DiagnosticsDefaultReporter } from "./default-impl"; -const state = { enabled: false }; +// The idempotency flag lives on globalThis (like the registries it guards) so a +// dual-installed copy of @illuma/core can't double-register the reporter + +// middleware, and a global plugin reset clears it too. +export const DIAGNOSTICS_ENABLED_KEY = Symbol.for("@illuma/core/DiagnosticsEnabled"); +const diagGlobal = globalThis as typeof globalThis & { + [DIAGNOSTICS_ENABLED_KEY]?: boolean; +}; /** @internal */ export function enableIllumaDiagnostics() { - if (state.enabled) return; - state.enabled = true; + if (diagGlobal[DIAGNOSTICS_ENABLED_KEY]) return; + diagGlobal[DIAGNOSTICS_ENABLED_KEY] = true; Illuma.extendDiagnostics(new DiagnosticsDefaultReporter()); Illuma.registerGlobalMiddleware(performanceDiagnostics); @@ -18,5 +24,5 @@ export function enableIllumaDiagnostics() { * Reset diagnostics state (for testing only) */ export function __resetDiagnosticsState() { - state.enabled = false; + diagGlobal[DIAGNOSTICS_ENABLED_KEY] = false; } diff --git a/src/lib/plugins/diagnostics/diagnostics.spec.ts b/src/lib/plugins/diagnostics/diagnostics.spec.ts index 679bf75..08bdcb3 100644 --- a/src/lib/plugins/diagnostics/diagnostics.spec.ts +++ b/src/lib/plugins/diagnostics/diagnostics.spec.ts @@ -401,3 +401,61 @@ describe("Plugin: Diagnostics", () => { expect(typeof report.bootstrapDuration).toBe("number"); }); }); + +describe("diagnostics idempotency across a plugin reset (#30)", () => { + afterEach(() => { + (Illuma as any).__resetPlugins(); + }); + + it("re-enables diagnostics after __resetPlugins (no stale idempotency flag)", () => { + builtIn.enableIllumaDiagnostics(); + expect(Illuma.hasDiagnostics()).toBe(true); + + (Illuma as any).__resetPlugins(); + expect(Illuma.hasDiagnostics()).toBe(false); + + // Before the fix this was a no-op (module-local flag stayed true), leaving + // diagnostics silently disabled. + builtIn.enableIllumaDiagnostics(); + expect(Illuma.hasDiagnostics()).toBe(true); + }); +}); + +describe("diagnostics report consistency (#37 / #34)", () => { + afterEach(() => { + (Illuma as any).__resetPlugins(); + }); + + it("totalNodes excludes the Injector/LifecycleRef built-ins, matching the unused population (#37)", () => { + const reports: any[] = []; + Illuma.extendDiagnostics({ onReport: (r: any) => reports.push(r) }); + + const T = new NodeToken("DIAG_TOTAL_T"); + const c = new NodeContainer(); + c.provide(T.withValue("v")); // one user node, unused + c.bootstrap(); + + expect(reports).toHaveLength(1); + // Only the user node is counted (not Injector + LifecycleRef → would be 3). + expect(reports[0].totalNodes).toBe(1); + expect(reports[0].unusedNodes).toHaveLength(1); + }); + + it("a reporter registering another module during onReport is not pulled into the same pass (#34)", () => { + const calls: string[] = []; + const second = { onReport: () => calls.push("second") }; + const first = { + onReport: () => { + calls.push("first"); + Illuma.extendDiagnostics(second); + }, + }; + Illuma.extendDiagnostics(first); + + const c = new NodeContainer(); + c.bootstrap(); + + // The snapshot iteration must not run `second` during the pass that registered it. + expect(calls).toEqual(["first"]); + }); +}); diff --git a/src/lib/plugins/middlewares/middlewares.spec.ts b/src/lib/plugins/middlewares/middlewares.spec.ts index 23677ca..f8427c6 100644 --- a/src/lib/plugins/middlewares/middlewares.spec.ts +++ b/src/lib/plugins/middlewares/middlewares.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { NodeToken } from "../../api/token"; import { NodeContainer } from "../../container/container"; +import { InjectionError } from "../../errors"; import { Illuma } from "../../global"; import type { iMiddleware } from "./types"; @@ -185,3 +186,41 @@ describe("Plugin: Middlewares", () => { expect(logs).toContain("deferred"); }); }); + +describe("middleware next() called more than once (#31)", () => { + it("rejects a middleware that calls next() twice instead of silently skipping downstream", () => { + const Token = new NodeToken("MW_DOUBLE_NEXT"); + let downstreamRuns = 0; + + const container = new NodeContainer(); + const doubleNext: iMiddleware = (params, next) => { + next(params); + return next(params); // second call must throw, not silently skip downstream + }; + const counter: iMiddleware = (params, next) => { + downstreamRuns++; + return next(params); + }; + + container.registerMiddleware(doubleNext); + container.registerMiddleware(counter); + container.provide(Token.withValue("v")); + + expect(() => container.bootstrap()).toThrow(InjectionError.middlewareNextReused()); + // The downstream middleware ran from the FIRST next() (not skipped). + expect(downstreamRuns).toBeGreaterThanOrEqual(1); + }); + + it("composes a well-behaved chain in registration order", () => { + const Token = new NodeToken("MW_ORDER"); + + const container = new NodeContainer(); + // First-registered is outermost; each wraps the inner result. + container.registerMiddleware((p, next) => `${next(p)}-a`); + container.registerMiddleware((p, next) => `${next(p)}-b`); + container.provide(Token.withValue("base")); + container.bootstrap(); + + expect(container.get(Token)).toBe("base-b-a"); + }); +}); diff --git a/src/lib/plugins/middlewares/runner.ts b/src/lib/plugins/middlewares/runner.ts index d8b19f3..8f408fd 100644 --- a/src/lib/plugins/middlewares/runner.ts +++ b/src/lib/plugins/middlewares/runner.ts @@ -1,3 +1,4 @@ +import { InjectionError } from "../../errors"; import type { iInstantiationParams, iMiddleware } from "./types"; /** @internal */ @@ -8,11 +9,21 @@ export function runMiddlewares( const ms = middlewares as iMiddleware[]; if (ms.length === 0) return params.factory(); - let i = 0; - const next = (current: iInstantiationParams): T => { - if (i >= ms.length) return current.factory(); - return ms[i++](current, next); + // Dispatch by index so each middleware's `next` continues from its own + // position, and reject a double next() explicitly rather than letting it + // silently skip downstream middlewares. + const dispatch = (index: number, current: iInstantiationParams): T => { + if (index >= ms.length) return current.factory(); + + let called = false; + return ms[index](current, (forwarded) => { + if (called) { + throw InjectionError.middlewareNextReused(); + } + called = true; + return dispatch(index + 1, forwarded); + }); }; - return next(params); + return dispatch(0, params); } diff --git a/src/lib/provider/multi-result.spec.ts b/src/lib/provider/multi-result.spec.ts new file mode 100644 index 0000000..6b616ed --- /dev/null +++ b/src/lib/provider/multi-result.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { MultiNodeToken, NodeToken, nodeInject } from "../api"; +import { NodeContainer } from "../container"; + +describe("multi token result is a defensive copy (#7)", () => { + it("mutating one consumer's array does not corrupt other consumers", () => { + const M = new MultiNodeToken("MR_SHARED"); + const A = new NodeToken("MR_A"); + const B = new NodeToken("MR_B"); + + const c = new NodeContainer(); + c.provide(M.withValue(1)); + c.provide(M.withValue(2)); + c.provide(A.withFactory(() => nodeInject(M))); + c.provide(B.withFactory(() => nodeInject(M))); + c.bootstrap(); + + const a = c.get(A); + a.push(999); + a.sort((x, y) => y - x); + + expect(c.get(B)).toEqual([1, 2]); + }); + + it("direct container.get(MULTI) hands out independent arrays", () => { + const M = new MultiNodeToken("MR_GET"); + const c = new NodeContainer(); + c.provide(M.withValue(1)); + c.provide(M.withValue(2)); + c.bootstrap(); + + const first = c.get(M); + first.push(3); + expect(c.get(M)).toEqual([1, 2]); + }); + + it("members themselves are still shared instances (shallow copy)", () => { + const M = new MultiNodeToken<{ id: number }>("MR_IDENTITY"); + const obj = { id: 1 }; + const c = new NodeContainer(); + c.provide(M.withValue(obj)); + c.bootstrap(); + + const a = c.get(M); + const b = c.get(M); + expect(a).not.toBe(b); // different array containers + expect(a[0]).toBe(b[0]); // same member instance + expect(a[0]).toBe(obj); + }); +}); + +describe("optional multi injection resolves to [] not null (#9/#10)", () => { + it("optional nodeInject of an unprovided multi returns [] (spreadable)", () => { + const M = new MultiNodeToken("MR_OPT"); + const HOST = new NodeToken("MR_OPT_HOST"); + + const c = new NodeContainer(); + c.provide( + HOST.withFactory(() => { + const items = nodeInject(M, { optional: true }); + return [...items]; + }), + ); + c.bootstrap(); + + expect(c.get(HOST)).toEqual([]); + }); + + it("optional multi inside a multi-token factory member (transparent retriever) returns []", () => { + const OUTER = new MultiNodeToken("MR_OUTER"); + const INNER = new MultiNodeToken("MR_INNER"); // never provided + + const c = new NodeContainer(); + c.provide( + OUTER.withFactory(() => { + const inner = nodeInject(INNER, { optional: true }); + return inner.length; // must not throw on null.length + }), + ); + c.bootstrap(); + + expect(c.get(OUTER)).toEqual([0]); + }); + + it("optional multi with members still returns the members", () => { + const M = new MultiNodeToken("MR_OPT_PRESENT"); + const HOST = new NodeToken("MR_OPT_PRESENT_HOST"); + + const c = new NodeContainer(); + c.provide(M.withValue(5)); + c.provide(M.withValue(6)); + c.provide(HOST.withFactory(() => [...nodeInject(M, { optional: true })])); + c.bootstrap(); + + expect(c.get(HOST)).toEqual([5, 6]); + }); + + it("optional single injection still returns null when unprovided", () => { + const SINGLE = new NodeToken("MR_OPT_SINGLE"); + const HOST = new NodeToken("MR_OPT_SINGLE_HOST"); + + const c = new NodeContainer(); + c.provide(HOST.withFactory(() => nodeInject(SINGLE, { optional: true }))); + c.bootstrap(); + + expect(c.get(HOST)).toBeNull(); + }); +}); diff --git a/src/lib/provider/proto.ts b/src/lib/provider/proto.ts index 7747cd9..3c99f96 100644 --- a/src/lib/provider/proto.ts +++ b/src/lib/provider/proto.ts @@ -70,24 +70,31 @@ export class ProtoNodeMulti { public readonly multiNodes: Set> = new Set(); public readonly transparentNodes: Set> = new Set(); + // Providers in declaration order across all kinds, so resolved members follow + // the order they were registered rather than grouping aliases before factories. + public readonly orderedProviders: Array< + NodeToken | MultiNodeToken | ProtoNodeTransparent + > = []; + constructor(public readonly token: MultiNodeToken) {} public hasProviders(): boolean { - return ( - this.singleNodes.size > 0 || - this.multiNodes.size > 0 || - this.transparentNodes.size > 0 - ); + return this.orderedProviders.length > 0; } public addProvider(retriever: NodeBase | (() => T)): void { if (retriever instanceof NodeToken) { + if (this.singleNodes.has(retriever)) return; this.singleNodes.add(retriever); + this.orderedProviders.push(retriever); } else if (retriever instanceof MultiNodeToken) { + if (this.multiNodes.has(retriever)) return; this.multiNodes.add(retriever); + this.orderedProviders.push(retriever); } else if (typeof retriever === "function") { const transparentProto = new ProtoNodeTransparent(this, retriever); this.transparentNodes.add(transparentProto); + this.orderedProviders.push(transparentProto); } } diff --git a/src/lib/provider/resolver.spec.ts b/src/lib/provider/resolver.spec.ts index 09e3e54..e52b8ef 100644 --- a/src/lib/provider/resolver.spec.ts +++ b/src/lib/provider/resolver.spec.ts @@ -274,7 +274,7 @@ describe("resolveTreeNode", () => { it("should throw on unknown ProtoNode type", () => { const fakeProto = { token: new NodeToken("fake") } as any; expect(() => resolveTreeNode(fakeProto, new Map(), new Map(), new Map())).toThrow( - "Unknown ProtoNode type", + InjectionError.unknownProtoNode(), ); }); diff --git a/src/lib/provider/resolver.ts b/src/lib/provider/resolver.ts index fc8f056..e036e85 100644 --- a/src/lib/provider/resolver.ts +++ b/src/lib/provider/resolver.ts @@ -102,7 +102,7 @@ export function resolveTreeNode( visiting.add(proto); frame.processed = true; - const deps: (ProtoNode | TreeNode)[] = []; + const deps: { dep: ProtoNode | TreeNode; skipSelf: boolean }[] = []; if (proto instanceof ProtoNodeSingle || proto instanceof ProtoNodeTransparent) { for (const injection of proto.injections) { @@ -114,55 +114,59 @@ export function resolveTreeNode( upstreamGetter, ); - if (resolvedDep) deps.push(resolvedDep); + // Carry the injection's skipSelf so the wired node lands in the right + // scope: plain and skipSelf injections of one token resolve to different nodes. + if (resolvedDep) deps.push({ dep: resolvedDep, skipSelf: !!injection.skipSelf }); } } if (proto instanceof ProtoNodeMulti) { const parentNodes = upstreamGetter?.(proto.token); - if (parentNodes instanceof TreeNodeMulti) { - node.addDependency(parentNodes); + if (parentNodes instanceof TreeNodeMulti && node instanceof TreeNodeMulti) { + // Record the ancestor's members as the inherited tail (excluded by self:true). + node.setInherited(parentNodes); } - for (const single of proto.singleNodes) { - let p = singleNodes.get(single); - if (!p) { - if (single.opts?.singleton) { - const rootSingleton = upstreamGetter?.(single); - if (rootSingleton) { - deps.push(rootSingleton); - continue; + // Iterate in declaration order so multi members keep their registration + // order regardless of provider kind (token/alias vs factory/value/class). + for (const provider of proto.orderedProviders) { + if (provider instanceof NodeToken) { + let p = singleNodes.get(provider); + if (!p) { + if (provider.opts?.singleton) { + const rootSingleton = upstreamGetter?.(provider); + if (rootSingleton) { + deps.push({ dep: rootSingleton, skipSelf: false }); + continue; + } } - } - - p = new ProtoNodeSingle(single); - singleNodes.set(single, p); - } - deps.push(p); - } + p = new ProtoNodeSingle(provider); + singleNodes.set(provider, p); + } - for (const multi of proto.multiNodes) { - let p = multiNodes.get(multi); - if (!p) { - p = new ProtoNodeMulti(multi); - multiNodes.set(multi, p); + deps.push({ dep: p, skipSelf: false }); + } else if (provider instanceof MultiNodeToken) { + let p = multiNodes.get(provider); + if (!p) { + p = new ProtoNodeMulti(provider); + multiNodes.set(provider, p); + } + deps.push({ dep: p, skipSelf: false }); + } else { + // ProtoNodeTransparent (factory / value / useClass member) + deps.push({ dep: provider, skipSelf: false }); } - deps.push(p); - } - - for (const transparent of proto.transparentNodes) { - deps.push(transparent); } } - for (const dep of deps) { + for (const { dep, skipSelf } of deps) { if ( dep instanceof TreeNodeSingle || dep instanceof TreeNodeMulti || dep instanceof TreeNodeTransparent ) { - node.addDependency(dep); + wireDependency(node, dep, skipSelf); continue; } @@ -177,13 +181,15 @@ export function resolveTreeNode( const cached = cache.get(depProto); if (cached) { - node.addDependency(cached); + // A proto-resolved dependency is always plain scope: skipSelf injections + // resolve to an upstream TreeNode, handled by the branch above. + wireDependency(node, cached, false); continue; } const childNode = createTreeNode(depProto); cache.set(depProto, childNode); - node.addDependency(childNode); + wireDependency(node, childNode, false); stack.push({ proto: depProto, node: childNode, processed: false }); } } @@ -195,7 +201,17 @@ function createTreeNode(p: ProtoNode): TreeNode { if (p instanceof ProtoNodeSingle) return new TreeNodeSingle(p); if (p instanceof ProtoNodeMulti) return new TreeNodeMulti(p); if (p instanceof ProtoNodeTransparent) return new TreeNodeTransparent(p); - throw new Error("Unknown ProtoNode type"); + throw InjectionError.unknownProtoNode(); +} + +/** + * Wires `dep` onto `node`, passing the injection scope where it matters. Multi + * nodes aggregate positionally (no per-token scope); single/transparent nodes + * slot deps by (token, scope) and take the skipSelf flag. + */ +function wireDependency(node: TreeNode, dep: TreeNode, skipSelf: boolean): void { + if (node instanceof TreeNodeMulti) node.addDependency(dep); + else node.addDependency(dep, skipSelf); } function throwCircularDependencyCycle( diff --git a/src/lib/provider/tree-node.ts b/src/lib/provider/tree-node.ts index 05f99d2..c2e4311 100644 --- a/src/lib/provider/tree-node.ts +++ b/src/lib/provider/tree-node.ts @@ -21,9 +21,16 @@ export class TreeRootNode { constructor( public readonly instant = true, - protected readonly middlewares: iMiddleware[] = [], + // A thunk, not a frozen array: a node materialized after bootstrap reads the + // CURRENT chain, so lazy get() matches produce() for post-bootstrap middleware. + private readonly _middlewaresProvider: () => iMiddleware[] = () => [], ) {} + /** The owning container's current middleware chain. */ + public get middlewares(): iMiddleware[] { + return this._middlewaresProvider(); + } + public get dependencies(): Set> { return this._deps; } @@ -33,23 +40,25 @@ export class TreeRootNode { } public registerDependency(node: TreeNode): void { - this._deps.add(node); - - if ("token" in node.proto) { - const existing = this._treePool.get(node.proto.token); - if (existing) return; + const alreadyPooled = + "token" in node.proto && this._treePool.get(node.proto.token) !== undefined; + + // Materialize BEFORE recording: if the factory throws, the half-built node + // must not be stranded in this long-lived root's _deps (a retry would add one). + if (!alreadyPooled) { + if (this.instant) node.instantiate(this._treePool, this); + else node.collectPool(this._treePool, this); } - if (this.instant) node.instantiate(this._treePool, this.middlewares); - else node.collectPool(this._treePool); + this._deps.add(node); } public build(): void { for (const dep of this._deps) { if ("token" in dep.proto) this._treePool.set(dep.proto.token, dep); - if (this.instant) dep.instantiate(this._treePool, this.middlewares); - else dep.collectPool(this._treePool); + if (this.instant) dep.instantiate(this._treePool, this); + else dep.collectPool(this._treePool, this); } } @@ -57,7 +66,7 @@ export class TreeRootNode { const node = this._treePool.get(token); if (!node) return null; - if (!this.instant) node.instantiate(this._treePool, this.middlewares); + if (!this.instant) node.instantiate(this._treePool, this); return node as TreeNode; } @@ -77,6 +86,40 @@ export class TreeRootNode { } } +/** + * @internal + * Binds a tree node to its OWNING root's pool + middleware chain. A parent + * bootstraps before any child that consumes its nodes, so the owner touches the + * node first and the first capture wins. Cross-container consumers then + * materialize an upstream node through its owner's pool + chain, not their own, + * so a child's pool/middleware never contaminates a parent-provided instance. + */ +class HomeBinding { + private _pool: InjectionPool | null = null; + private _root: TreeRootNode | null = null; + private _homed = false; + + public capture(pool: InjectionPool | undefined, root: TreeRootNode | undefined): void { + if (this._homed || !root) return; + this._homed = true; + this._pool = pool ?? null; + this._root = root; + } + + public poolFor(pool?: InjectionPool): InjectionPool | undefined { + return this._pool ?? pool; + } + + public rootFor(root?: TreeRootNode): TreeRootNode | undefined { + return this._root ?? root; + } + + public middlewaresFor(root?: TreeRootNode): iMiddleware[] { + const owner = this._root ?? root; + return owner ? owner.middlewares : []; + } +} + /** @internal */ export class TreeNodeSingle { private readonly _transparentMap: Map, TreeNodeTransparent> = new Map(); @@ -88,6 +131,13 @@ export class TreeNodeSingle { private readonly _depsIndex = new Map, number>(); private readonly _depsTokens = new Set>(); + // skipSelf injections resolve to a different (upstream) node than a plain/self + // injection of the same token, so they need their own keyed-by-token storage. + private readonly _skipSelfDeps: DependencyPool = new Map(); + private readonly _skipSelfList: TreeNode[] = []; + private readonly _skipSelfIndex = new Map, number>(); + + private readonly _home = new HomeBinding(); private _instance: T | null = null; private _collected = false; private _resolved = false; @@ -107,17 +157,20 @@ export class TreeNodeSingle { options?: iNodeInjectorOptions, ): any => { const optional = options ? options.optional : false; - const depNode = this._deps.get(token); + const self = options?.self ?? false; + const skipSelf = options?.skipSelf ?? false; - if (!depNode && !optional) { - const transparent = this._transparentMap.get(token); - if (transparent) return transparent.instance; - if (token instanceof MultiNodeToken) return [] as unknown as any; + const depNode = (skipSelf ? this._skipSelfDeps : this._deps).get(token); + if (depNode) return readNodeInstance(depNode, self); - throw InjectionError.untracked(token, this.proto.token); - } + const transparent = this._transparentMap.get(token); + if (transparent) return transparent.instance; + + // A multi token always resolves to an array, never null (even when optional). + if (token instanceof MultiNodeToken) return [] as unknown as any; - return depNode ? depNode.instance : null; + if (optional) return null; + throw InjectionError.untracked(token, this.proto.token); }; constructor(public readonly proto: ProtoNodeSingle) { @@ -128,7 +181,7 @@ export class TreeNodeSingle { } } - public addDependency(node: TreeNode): void { + public addDependency(node: TreeNode, skipSelf = false): void { if (node instanceof TreeNodeTransparent) { const token = node.proto.parent.token; const existing = this._transparentMap.get(token); @@ -141,12 +194,16 @@ export class TreeNodeSingle { this._depsTokens.add(token); } else { const token = node.proto.token; - const existing = this._deps.get(token); + const deps = skipSelf ? this._skipSelfDeps : this._deps; + const index = skipSelf ? this._skipSelfIndex : this._depsIndex; + const list = skipSelf ? this._skipSelfList : this._depsList; + + const existing = deps.get(token); if (existing === node) return; if (existing) existing.allocations--; - this._deps.set(token, node); - upsertIndexedDependency(token, node, this._depsIndex, this._depsList); + deps.set(token, node); + upsertIndexedDependency(token, node, index, list); this._depsTokens.add(token); } @@ -154,9 +211,13 @@ export class TreeNodeSingle { node.allocations++; } - public collectPool(pool: InjectionPool): void { + public collectPool(pool: InjectionPool, root?: TreeRootNode): void { + this._home.capture(pool, root); + const homePool = this._home.poolFor(pool) ?? pool; + const homeRoot = this._home.rootFor(root); + if (this._collected) { - poolSetOnce(pool, this.proto.token, this); + poolSetOnce(homePool, this.proto.token, this); return; } @@ -168,23 +229,36 @@ export class TreeNodeSingle { } this._inProgress = true; - for (let i = 0; i < this._depsList.length; i++) { - this._depsList[i].collectPool(pool); - } + // Reset the guard on every exit so a throwing dependency can't fake a later + // cycle (e.g. a lazy get() retried after the first attempt threw). + try { + for (let i = 0; i < this._depsList.length; i++) { + this._depsList[i].collectPool(homePool, homeRoot); + } - for (let i = 0; i < this._transparentList.length; i++) { - this._transparentList[i].collectPool(pool); + for (let i = 0; i < this._skipSelfList.length; i++) { + this._skipSelfList[i].collectPool(homePool, homeRoot); + } + + for (let i = 0; i < this._transparentList.length; i++) { + this._transparentList[i].collectPool(homePool, homeRoot); + } + } finally { + this._inProgress = false; } - this._inProgress = false; this._collected = true; - poolSetOnce(pool, this.proto.token, this); + poolSetOnce(homePool, this.proto.token, this); } - public instantiate(pool?: InjectionPool, middlewares: iMiddleware[] = []): void { + public instantiate(pool?: InjectionPool, root?: TreeRootNode): void { + this._home.capture(pool, root); + const homePool = this._home.poolFor(pool); + const homeRoot = this._home.rootFor(root); + if (this._resolved) { // Pre-resolved nodes (e.g. Injector) still have to be discoverable - if (pool) poolSetOnce(pool, this.proto.token, this); + if (homePool) poolSetOnce(homePool, this.proto.token, this); return; } @@ -193,32 +267,44 @@ export class TreeNodeSingle { } this._inProgress = true; - for (let i = 0; i < this._depsList.length; i++) { - this._depsList[i].instantiate(pool, middlewares); - } + // Materialize through this node's OWN middleware chain, never a cross-container + // consumer's, so a parent-provided instance isn't wrapped in a child's middleware. + const middlewares = this._home.middlewaresFor(root); - for (let i = 0; i < this._transparentList.length; i++) { - this._transparentList[i].instantiate(pool, middlewares); - } + try { + for (let i = 0; i < this._depsList.length; i++) { + this._depsList[i].instantiate(homePool, homeRoot); + } + + for (let i = 0; i < this._skipSelfList.length; i++) { + this._skipSelfList[i].instantiate(homePool, homeRoot); + } - const factory = this.proto.factory ?? this.proto.token.opts?.factory; - if (!factory) throw InjectionError.notFound(this.proto.token); + for (let i = 0; i < this._transparentList.length; i++) { + this._transparentList[i].instantiate(homePool, homeRoot); + } - if (!middlewares.length) { - this._instance = InjectionContext.instantiate(factory, this._retriever); - } else { - const contextFactory = () => InjectionContext.instantiate(factory, this._retriever); - this._instance = runMiddlewares(middlewares, { - token: this.proto.token, - factory: contextFactory, - deps: new Set(this._depsTokens), - }); + const factory = this.proto.factory ?? this.proto.token.opts?.factory; + if (!factory) throw InjectionError.notFound(this.proto.token); + + if (!middlewares.length) { + this._instance = InjectionContext.instantiate(factory, this._retriever); + } else { + const contextFactory = () => + InjectionContext.instantiate(factory, this._retriever); + this._instance = runMiddlewares(middlewares, { + token: this.proto.token, + factory: contextFactory, + deps: new Set(this._depsTokens), + }); + } + } finally { + this._inProgress = false; } - this._inProgress = false; this._resolved = true; - if (pool) poolSetOnce(pool, this.proto.token, this); + if (homePool) poolSetOnce(homePool, this.proto.token, this); } public toString(): string { @@ -237,6 +323,13 @@ export class TreeNodeTransparent { private readonly _depsIndex = new Map, number>(); private readonly _depsTokens = new Set>(); + // skipSelf injections resolve to a different (upstream) node than a plain/self + // injection of the same token; keep them in their own keyed-by-token storage. + private readonly _skipSelfDeps: DependencyPool = new Map(); + private readonly _skipSelfList: TreeNode[] = []; + private readonly _skipSelfIndex = new Map, number>(); + + private readonly _home = new HomeBinding(); private _instance: T | null = null; private _collected = false; private _resolved = false; @@ -253,22 +346,25 @@ export class TreeNodeTransparent { options?: iNodeInjectorOptions, ): any => { const optional = options ? options.optional : false; - const depNode = this._deps.get(token); + const self = options?.self ?? false; + const skipSelf = options?.skipSelf ?? false; - if (!depNode && !optional) { - const transparent = this._transparentMap.get(token); - if (transparent) return transparent.instance; - if (token instanceof MultiNodeToken) return [] as unknown as any; + const depNode = (skipSelf ? this._skipSelfDeps : this._deps).get(token); + if (depNode) return readNodeInstance(depNode, self); - throw InjectionError.untracked(token, this.proto.parent.token); - } + const transparent = this._transparentMap.get(token); + if (transparent) return transparent.instance; + + // A multi token always resolves to an array, never null (even when optional). + if (token instanceof MultiNodeToken) return [] as unknown as any; - return depNode ? depNode.instance : null; + if (optional) return null; + throw InjectionError.untracked(token, this.proto.parent.token); }; constructor(public readonly proto: ProtoNodeTransparent) {} - public addDependency(node: TreeNode): void { + public addDependency(node: TreeNode, skipSelf = false): void { if (node instanceof TreeNodeTransparent) { const token = node.proto.parent.token; const existing = this._transparentMap.get(token); @@ -281,12 +377,16 @@ export class TreeNodeTransparent { this._depsTokens.add(token); } else { const token = node.proto.token; - const existing = this._deps.get(token); + const deps = skipSelf ? this._skipSelfDeps : this._deps; + const index = skipSelf ? this._skipSelfIndex : this._depsIndex; + const list = skipSelf ? this._skipSelfList : this._depsList; + + const existing = deps.get(token); if (existing === node) return; if (existing) existing.allocations--; - this._deps.set(token, node); - upsertIndexedDependency(token, node, this._depsIndex, this._depsList); + deps.set(token, node); + upsertIndexedDependency(token, node, index, list); this._depsTokens.add(token); } @@ -294,7 +394,11 @@ export class TreeNodeTransparent { node.allocations++; } - public collectPool(pool: InjectionPool): void { + public collectPool(pool: InjectionPool, root?: TreeRootNode): void { + this._home.capture(pool, root); + const homePool = this._home.poolFor(pool) ?? pool; + const homeRoot = this._home.rootFor(root); + if (this._collected) return; if (this._inProgress) { @@ -304,19 +408,31 @@ export class TreeNodeTransparent { } this._inProgress = true; - for (let i = 0; i < this._depsList.length; i++) { - this._depsList[i].collectPool(pool); - } + // Reset the guard on every exit so a throwing dependency can't fake a later cycle. + try { + for (let i = 0; i < this._depsList.length; i++) { + this._depsList[i].collectPool(homePool, homeRoot); + } - for (let i = 0; i < this._transparentList.length; i++) { - this._transparentList[i].collectPool(pool); + for (let i = 0; i < this._skipSelfList.length; i++) { + this._skipSelfList[i].collectPool(homePool, homeRoot); + } + + for (let i = 0; i < this._transparentList.length; i++) { + this._transparentList[i].collectPool(homePool, homeRoot); + } + } finally { + this._inProgress = false; } - this._inProgress = false; this._collected = true; } - public instantiate(pool?: InjectionPool, middlewares: iMiddleware[] = []): void { + public instantiate(pool?: InjectionPool, root?: TreeRootNode): void { + this._home.capture(pool, root); + const homePool = this._home.poolFor(pool); + const homeRoot = this._home.rootFor(root); + if (this._resolved) return; if (this._inProgress) { @@ -326,29 +442,41 @@ export class TreeNodeTransparent { } this._inProgress = true; - for (let i = 0; i < this._transparentList.length; i++) { - this._transparentList[i].instantiate(pool, middlewares); - } + const middlewares = this._home.middlewaresFor(root); - for (let i = 0; i < this._depsList.length; i++) { - this._depsList[i].instantiate(pool, middlewares); - } + try { + for (let i = 0; i < this._transparentList.length; i++) { + this._transparentList[i].instantiate(homePool, homeRoot); + } - if (!middlewares.length) { - this._instance = InjectionContext.instantiate(this.proto.factory, this._retriever); - } else { - const refFactory = () => { - return InjectionContext.instantiate(this.proto.factory, this._retriever); - }; - - this._instance = runMiddlewares(middlewares, { - token: this.proto.parent.token, - factory: refFactory, - deps: new Set(this._depsTokens), - }); + for (let i = 0; i < this._depsList.length; i++) { + this._depsList[i].instantiate(homePool, homeRoot); + } + + for (let i = 0; i < this._skipSelfList.length; i++) { + this._skipSelfList[i].instantiate(homePool, homeRoot); + } + + if (!middlewares.length) { + this._instance = InjectionContext.instantiate( + this.proto.factory, + this._retriever, + ); + } else { + const refFactory = () => { + return InjectionContext.instantiate(this.proto.factory, this._retriever); + }; + + this._instance = runMiddlewares(middlewares, { + token: this.proto.parent.token, + factory: refFactory, + deps: new Set(this._depsTokens), + }); + } + } finally { + this._inProgress = false; } - this._inProgress = false; this._resolved = true; } @@ -361,8 +489,12 @@ export class TreeNodeTransparent { export class TreeNodeMulti { private readonly _deps = new Set>(); private readonly _depsList: TreeNode[] = []; - public readonly instance: T[] = []; + private readonly _members: T[] = []; + // The nearest ancestor's multi node, aggregated as the inherited tail. Kept + // separate from local members so a `self: true` read can exclude it. + private _inherited: TreeNodeMulti | null = null; + private readonly _home = new HomeBinding(); private _collected = false; private _resolved = false; private _inProgress = false; @@ -370,9 +502,39 @@ export class TreeNodeMulti { constructor(public readonly proto: ProtoNodeMulti) {} - public collectPool(pool: InjectionPool): void { + /** + * Resolved members: inherited ancestor members then this container's own. + * Returns a fresh copy each access so a consumer that mutates the result can't + * corrupt the shared member list seen by others. + */ + public get instance(): T[] { + if (this._inherited) return [...this._inherited.instance, ...this._members]; + return [...this._members]; + } + + /** + * Only this container's own members, excluding anything inherited from an + * ancestor. Backs `self: true` resolution. + */ + public get localInstance(): T[] { + return [...this._members]; + } + + /** @internal Record the nearest ancestor's multi node as the inherited tail. */ + public setInherited(node: TreeNodeMulti): void { + if (this._inherited === node) return; + if (this._inherited) this._inherited.allocations--; + this._inherited = node; + node.allocations++; + } + + public collectPool(pool: InjectionPool, root?: TreeRootNode): void { + this._home.capture(pool, root); + const homePool = this._home.poolFor(pool) ?? pool; + const homeRoot = this._home.rootFor(root); + if (this._collected) { - poolSetOnce(pool, this.proto.token, this); + poolSetOnce(homePool, this.proto.token, this); return; } @@ -381,16 +543,26 @@ export class TreeNodeMulti { } this._inProgress = true; - for (let i = 0; i < this._depsList.length; i++) { - this._depsList[i].collectPool(pool); + // Reset the guard on every exit so a throwing dependency can't fake a later cycle. + try { + for (let i = 0; i < this._depsList.length; i++) { + this._depsList[i].collectPool(homePool, homeRoot); + } + // The inherited node belongs to an ancestor and uses its own captured home. + this._inherited?.collectPool(homePool, homeRoot); + } finally { + this._inProgress = false; } - this._inProgress = false; this._collected = true; - poolSetOnce(pool, this.proto.token, this); + poolSetOnce(homePool, this.proto.token, this); } - public instantiate(pool?: InjectionPool, middlewares: iMiddleware[] = []): void { + public instantiate(pool?: InjectionPool, root?: TreeRootNode): void { + this._home.capture(pool, root); + const homePool = this._home.poolFor(pool); + const homeRoot = this._home.rootFor(root); + if (this._resolved) return; if (this._inProgress) { @@ -398,22 +570,33 @@ export class TreeNodeMulti { } this._inProgress = true; - for (let i = 0; i < this._depsList.length; i++) { - const dep = this._depsList[i]; - dep.instantiate(pool, middlewares); - - if (dep instanceof TreeNodeSingle) { - this.instance.push(dep.instance); - } else if (dep instanceof TreeNodeMulti) { - this.instance.push(...dep.instance); - } else if (dep instanceof TreeNodeTransparent) { - this.instance.push(dep.instance); + // Start each attempt from an empty array: a prior attempt that threw + // mid-loop would otherwise leave partial members to be duplicated on retry. + this._members.length = 0; + + try { + for (let i = 0; i < this._depsList.length; i++) { + const dep = this._depsList[i]; + dep.instantiate(homePool, homeRoot); + + if (dep instanceof TreeNodeSingle) { + this._members.push(dep.instance); + } else if (dep instanceof TreeNodeMulti) { + this._members.push(...dep.instance); + } else if (dep instanceof TreeNodeTransparent) { + this._members.push(dep.instance); + } } + // Inherited members are read lazily via the `instance` getter, but the + // ancestor node must still be materialized (through its own home) so + // its `.instance` is ready. + this._inherited?.instantiate(homePool, homeRoot); + } finally { + this._inProgress = false; } - this._inProgress = false; this._resolved = true; - if (pool) poolSetOnce(pool, this.proto.token, this); + if (homePool) poolSetOnce(homePool, this.proto.token, this); } public addDependency(...nodes: TreeNode[]): void { @@ -437,6 +620,17 @@ export type TreeNode = | TreeNodeMulti | TreeNodeTransparent; +/** + * @internal + * Reads a resolved node's instance, honoring `self: true` for multi nodes by + * returning only their local members (excluding inherited ancestor members). + * `self` is a no-op for single/transparent nodes. + */ +export function readNodeInstance(node: TreeNode, localOnly: boolean): any { + if (localOnly && node instanceof TreeNodeMulti) return node.localInstance; + return node.instance; +} + function poolSetOnce(pool: InjectionPool, token: NodeBase, node: TreeNode): void { if (pool.get(token) === node) return; if (pool.has(token)) return; diff --git a/src/lib/provider/types.ts b/src/lib/provider/types.ts index 7045812..d34a9fb 100644 --- a/src/lib/provider/types.ts +++ b/src/lib/provider/types.ts @@ -26,6 +26,15 @@ export interface iNodeTokenBaseOptions { * Marks token as root-scoped singleton in hierarchical containers. */ singleton?: boolean; + + /** + * Dedupe this token by name in a process-global registry, so an identically + * named token declared in another bundle is the SAME object. Containers key + * providers by token reference, so cross-bundle sharing needs identical + * instances. Reserve for cross-bundle seam tokens: the name becomes a global + * identity and must be unique and stable. + */ + global?: boolean; } /** diff --git a/src/lib/testkit/helpers.ts b/src/lib/testkit/helpers.ts index 6e6e175..28359f8 100644 --- a/src/lib/testkit/helpers.ts +++ b/src/lib/testkit/helpers.ts @@ -1,7 +1,6 @@ import type { NodeInjectFn } from "../api/injection"; import type { iNodeInjectorOptions } from "../api/types"; import { NodeContainer } from "../container/container"; -import { isNotFoundError } from "../errors"; import type { Provider, Token } from "../provider/types"; /** @@ -56,17 +55,8 @@ export function createTestFactory(cfg: iTestFactoryConfig): TestFactoryFn< container.bootstrap(); const instance = container.get(cfg.target as any); - const nodeInject = (token: Token, opts?: iNodeInjectorOptions) => { - try { - return container.get(token as any); - } catch (e) { - if (isNotFoundError(e) && opts?.optional) { - return null as any; - } - - throw e; - } - }; + const nodeInject = (token: Token, opts?: iNodeInjectorOptions) => + container.get(token as any, opts); return { instance, diff --git a/src/lib/utils/defer.spec.ts b/src/lib/utils/defer.spec.ts index 7899d56..d82c7eb 100644 --- a/src/lib/utils/defer.spec.ts +++ b/src/lib/utils/defer.spec.ts @@ -286,3 +286,26 @@ describe("injectDefer", () => { expect(factorySpy).toHaveBeenCalledTimes(2); }); }); + +describe("injectDefer lifecycle callback leak (#17)", () => { + it("does not accumulate a beforeDestroy hook per produced instance", () => { + const parent = new NodeContainer(); + + const DEP = new NodeToken("DEFER_LEAK_DEP"); + parent.provide(DEP.withValue(1)); + + @NodeInjectable() + class Service { + public readonly dep = injectDefer(DEP); + } + parent.provide(Service); + parent.bootstrap(); + + const destroyCallbacks = (parent as any)._lifecycle._destroyCallbacks as Set; + const before = destroyCallbacks.size; + + for (let i = 0; i < 25; i++) parent.produce(Service); + + expect(destroyCallbacks.size).toBe(before); + }); +}); diff --git a/src/lib/utils/defer.ts b/src/lib/utils/defer.ts index ca33461..f0fafed 100644 --- a/src/lib/utils/defer.ts +++ b/src/lib/utils/defer.ts @@ -4,7 +4,7 @@ import type { MultiNodeToken, NodeToken } from "../api/token"; import { extractToken } from "../api/token-utils"; import type { ExtractInjectedType, iNodeInjectorOptions } from "../api/types"; import { LifecycleRef } from "../container/lifecycle"; -import { InjectionError, isNotFoundError } from "../errors"; +import { InjectionError } from "../errors"; import type { Token } from "../provider/types"; import { Injector } from "./injector"; @@ -57,35 +57,19 @@ export function injectDefer< const token = extractToken(provider as Token); + // No beforeDestroy hook resets this state: the getter already refuses to + // resolve once the container is destroyed, and a per-call hook on the shared + // lifecycle would leak one callback per produce()d instance. let resolved = false; let instance: ExtractInjectedType | typeof SHAPE_SHIFTER | null = SHAPE_SHIFTER; - lifecycle.beforeDestroy(() => { - resolved = false; - instance = null; - }); - return () => { if (lifecycle.destroyed) throw InjectionError.destroyed(); - if (resolved) return instance; - if (options?.optional) { - try { - instance = injector.get(token as any) as ExtractInjectedType; - resolved = true; - return instance; - } catch (e) { - if (isNotFoundError(e)) { - resolved = true; - instance = null; - return instance; - } - - throw e; - } - } - instance = injector.get(token as any) as ExtractInjectedType; + // Forward every modifier: get() honors them natively, so deferred resolution + // resolves from the same scope an eager nodeInject(token, options) would. + instance = injector.get(token as any, options) as ExtractInjectedType; resolved = true; return instance; }; diff --git a/src/lib/utils/inheritance.spec.ts b/src/lib/utils/inheritance.spec.ts index abb71ea..6cc8ba4 100644 --- a/src/lib/utils/inheritance.spec.ts +++ b/src/lib/utils/inheritance.spec.ts @@ -665,3 +665,162 @@ describe("injectEntryAsync", () => { expect(logSpy).toHaveBeenCalledWith("Generating report..."); }); }); + +describe("async injection failure handling (#5 / #29)", () => { + it("evicts a cached rejection so a later call retries with a fresh attempt (#5)", async () => { + const parent = new NodeContainer(); + + @NodeInjectable() + class TestService { + public readonly value = "ok"; + } + parent.provide(TestService); + + let attempt = 0; + + @NodeInjectable() + class ParentService { + public readonly load = injectAsync(() => { + attempt++; + if (attempt === 1) throw new Error("transient"); + return TestService; + }); + } + parent.provide(ParentService); + parent.bootstrap(); + + const ps = parent.get(ParentService); + + await expect(ps.load()).rejects.toThrow("transient"); + // The poisoned attempt must not be cached: a retry succeeds. + const svc = await ps.load(); + expect(svc).toBeInstanceOf(TestService); + expect(attempt).toBe(2); + }); + + it("caches a successful result after a failed-then-retried attempt (#5)", async () => { + const parent = new NodeContainer(); + + @NodeInjectable() + class TestService {} + parent.provide(TestService); + + let attempt = 0; + + @NodeInjectable() + class ParentService { + public readonly load = injectAsync(() => { + attempt++; + if (attempt === 1) throw new Error("transient"); + return TestService; + }); + } + parent.provide(ParentService); + parent.bootstrap(); + + const ps = parent.get(ParentService); + await expect(ps.load()).rejects.toThrow("transient"); + + const a = await ps.load(); + const b = await ps.load(); + expect(a).toBe(b); // second success is cached + expect(attempt).toBe(2); + }); + + it("rejects cleanly (no crash) when the parent is destroyed mid-flight (#29)", async () => { + const parent = new NodeContainer(); + let release: (() => void) | undefined; + const gate = new Promise((r) => { + release = r; + }); + + @NodeInjectable() + class TestService {} + parent.provide(TestService); + + @NodeInjectable() + class ParentService { + public readonly load = injectAsync(async () => { + await gate; + return TestService; + }); + } + parent.provide(ParentService); + parent.bootstrap(); + + const ps = parent.get(ParentService); + const pending = ps.load(); // starts the sub-container build, suspends at gate + parent.destroy(); // tears the sub-container down underneath the factory + release?.(); + + await expect(pending).rejects.toThrow(InjectionError); + }); +}); + +describe("lifecycle callback leak (#16)", () => { + it("does not accumulate a beforeDestroy hook per produced instance using injectAsync", () => { + const parent = new NodeContainer(); + + @NodeInjectable() + class Dep {} + parent.provide(Dep); + + @NodeInjectable() + class Service { + public readonly load = injectAsync(() => Dep); + } + parent.provide(Service); + parent.bootstrap(); + + const destroyCallbacks = (parent as any)._lifecycle._destroyCallbacks as Set; + const before = destroyCallbacks.size; + + for (let i = 0; i < 25; i++) parent.produce(Service); + + expect(destroyCallbacks.size).toBe(before); + }); +}); + +// The runtime contract the (fixed) overloads must encode: a NodeToken +// entrypoint resolves to a single value, a MultiNodeToken entrypoint to an +// array (#15). NOTE: spec files are excluded from tsc, so this guards the +// runtime contract; the overload return TYPES are kept consistent with this. +describe("injectEntryAsync entrypoint kind (#15)", () => { + it("a NodeToken entrypoint resolves to a single value", async () => { + const parent = new NodeContainer(); + const SINGLE = new NodeToken("ET_SINGLE_TOK"); + + @NodeInjectable() + class Host { + public readonly get = injectEntryAsync(() => ({ + entrypoint: SINGLE, + providers: [SINGLE.withValue("only")], + })); + } + parent.provide(Host); + parent.bootstrap(); + + const result = await parent.get(Host).get(); + expect(Array.isArray(result)).toBe(false); + expect(result).toBe("only"); + }); + + it("a MultiNodeToken entrypoint resolves to an array of all members", async () => { + const parent = new NodeContainer(); + const MULTI = new MultiNodeToken("ET_MULTI_TOK"); + + @NodeInjectable() + class Host { + public readonly get = injectEntryAsync(() => ({ + entrypoint: MULTI, + providers: [MULTI.withValue("a"), MULTI.withValue("b")], + })); + } + parent.provide(Host); + parent.bootstrap(); + + const result = await parent.get(Host).get(); + expect(Array.isArray(result)).toBe(true); + expect(result).toEqual(["a", "b"]); + }); +}); diff --git a/src/lib/utils/inheritance.ts b/src/lib/utils/inheritance.ts index dce4113..d45b346 100644 --- a/src/lib/utils/inheritance.ts +++ b/src/lib/utils/inheritance.ts @@ -122,7 +122,7 @@ export interface iEntrypointConfig> { export function injectEntryAsync( fn: MaybeAsyncFactory>>, opts?: iInjectionOptions, -): () => Promise; +): () => Promise; export function injectEntryAsync( fn: MaybeAsyncFactory>>, opts?: iInjectionOptions, @@ -130,7 +130,7 @@ export function injectEntryAsync( export function injectEntryAsync( fn: MaybeAsyncFactory>>, opts?: iInjectionOptions, -): () => Promise; +): () => Promise; export function injectEntryAsync( fn: MaybeAsyncFactory>>, opts?: iInjectionOptions, @@ -154,10 +154,15 @@ function createSubContainerCache( : nodeInject(LifecycleRef); const withCache = opts?.withCache ?? true; - const factory = () => { + const factory = (): Promise => { const subContainer = new NodeContainer({ parent }); if (opts?.config) subContainer.provide(opts.config); - return factoryFn(subContainer); + // Tear down the sub-container if the async build rejects, so repeated + // attempts don't accumulate dead scopes on the parent's lifecycle. + return factoryFn(subContainer).catch((err) => { + if (!subContainer.destroyed) subContainer.destroy(); + throw err; + }); }; if (!withCache) { @@ -167,15 +172,23 @@ function createSubContainerCache( }; } + // No beforeDestroy hook clears this cache: the getter already refuses to + // resolve once the parent is destroyed, and a per-call hook on the shared + // lifecycle would leak one callback per produce()d instance. let cache: Promise | null = null; - lifecycle.beforeDestroy(() => { - cache = null; - }); - return () => { if (lifecycle.destroyed) throw InjectionError.destroyed(); - cache ??= factory(); + if (cache) return cache; + + const pending = factory(); + cache = pending; + // Evict a rejected attempt so a later call retries with a fresh sub-container + // instead of replaying the cached rejection. This also marks the promise as + // handled, so a fire-and-forget rejection isn't reported as unhandled. + pending.catch(() => { + if (cache === pending) cache = null; + }); return cache; }; }