fix(di): singleton locking and keying, SemVer space parsing, lazy provider boot - #2943
Conversation
…vider boot
Addresses the di-container package of the 2026-06-09 framework review:
- DI3 (Injector.cfc): key the singleton cache by mapping name — the same
key the asSingleton() flag uses — so flag and cache can never disagree,
and wrap singleton construction in a double-checked named lock so
concurrent first resolutions construct exactly once. Re-binding an alias
to a different component path invalidates the cached instance; the same
path keeps it (dev-reload pattern).
- DI13 (Injector.cfc): memoize init() parameter names per component path
(getMetaData + scan run once, not per transient resolution) and walk the
extends chain so an inherited init() participates in constructor
auto-wiring as advertised.
- DI2 (SemVer.cfc): merge an operator-only token with the next token so
">= 1.0.0" / "^ 1.2.3" keep range semantics instead of degrading to an
exact match or becoming unsatisfiable; an operator with an empty target
now fails closed.
- DI9 (PackageLoader.cfc, Global.cfc): lazy packages whose manifest hints
provides.services are instantiated into the ServiceProvider lifecycle at
register() time, and an unhinted lazy provider instantiated after boot
gets register()/boot() invoked late. The lifecycle gate in
$loadPackages now asks $hasServiceProviderWork() so a vendor tree with
only lazy service packages still runs the lifecycle.
- DI1 (Global.cfc, plugins.cfm, debug.cfm): normalize plugin-shaped
mixin-collision records ({existingPlugin, overridingPlugin}) to the
shared {firstProvider, secondProvider, acknowledged, source} shape at
the merge point; both debug surfaces now consume one shape.
- DI6 (ModuleGraph.cfc): distinguish true cycle members from downstream
dependents in cycle diagnostics (members get a real closed arrow chain,
dependents get a depends-on-cycle error) and drop the dead inDegree
bookkeeping from $buildAdjacencyList.
- DI11 (ModuleGraph.cfc): process replaces declarations in sorted order
and skip declarations from already-excluded packages, so mutual
replaces deterministically keeps one package instead of silently
removing both.
- DI5 (Bindings.cfc): remove the dead InjectorInterface binding that
could never resolve and would clobber the live container if it did.
Specs: red-verified against pre-fix code (semverSpec 5 fail, InjectorSpec
1 fail + 1 error, ModuleGraphSpec 4 fail, LazyServiceProviderSpec 2 fail +
1 error), green post-fix on Lucee 7 + SQLite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: Eight tightly-scoped DI container bug fixes, each with a documented finding reference, a failing spec on develop before the fix, and cross-engine notes. The implementation is correct and consistent with prior art in the codebase. Verdict: comment — one defensive-coding observation and a docs note, neither blocking.
Correctness
$normalizeMixinCollisions — no guard on the legacy-key fallback
vendor/wheels/Global.cfc ~line 3086:
firstProvider = StructKeyExists(local.c, "firstProvider") ? local.c.firstProvider : local.c.existingPlugin,
secondProvider = StructKeyExists(local.c, "secondProvider") ? local.c.secondProvider : local.c.overridingPlugin,The fallback arms reach local.c.existingPlugin / local.c.overridingPlugin without verifying those keys exist first. In the current codebase this function is called only with records that are guaranteed to have one or the other shape, so it cannot panic at runtime. But the function is public (correctly so — it needs to be mixed in), meaning any future test or caller could pass an unexpected record shape and get a cryptic "key [EXISTINGPLUGIN] doesn't exist" error rather than a useful diagnostic. A StructKeyExists guard with a safe default ("") would future-proof it:
firstProvider = StructKeyExists(local.c, "firstProvider")
? local.c.firstProvider
: (StructKeyExists(local.c, "existingPlugin") ? local.c.existingPlugin : ""),Not blocking — the controlled call site makes this safe today.
Singleton outer-check memory visibility
vendor/wheels/Injector.cfc line 169:
if (!structKeyExists(variables.singletons, arguments.name)) {
lock name="..." type="exclusive" timeout="30" {
if (!structKeyExists(variables.singletons, arguments.name)) { ... }
}
}
return variables.singletons[arguments.name];The outer structKeyExists is a read with no memory barrier, so on a strict JMM reading a thread could theoretically observe a stale cache miss and enter the lock unnecessarily. The inner check corrects this before construction, so correctness is preserved — a thread that observes a stale miss will take the lock, find the instance already present, and return it. No partial-object risk, because the write to variables.singletons occurs strictly after full construction inside the lock. The pattern matches existing prior art at Global.cfc:31 and TenantMigrator, and on the x86 hardware typical for CFML servers the strong memory ordering makes the outer check reliable in practice. Not a blocking issue.
Cross-engine
All cross-engine obligations are met:
$findCyclePathusesDuplicate(local.frame.path)before struct-literal insertion, with an inline comment citing invariant 6 (Adobe CF copies arrays by value in struct literals). ✓$normalizeMixinCollisionsis declaredpublicwith a$prefix — required for a helper mixed via$integrateComponentsinGlobal.cfc(invariant 7). ✓- No
Left(str, 0)patterns introduced (invariant 8). ✓ - No
argumentspassed asattributeCollection(invariant 10). ✓ - No
local.Xreads aftercatchbodies (invariant 11). ✓ - The named-lock prefix
"wheelsDISingleton_" & CreateUUID() & "_"follows the script-syntax lock pattern fromGlobal.cfc. ✓
Tests
Coverage is thorough:
InjectorSpec.cfcadds singleton-cache-by-alias, extends-chain auto-wiring, late-registration memoization, and rebind-invalidation specs — all described as red ondevelop.semverSpec.cfcadds 5 specs for space-separated operators and fail-closed empty targets.ModuleGraphSpec.cfcadds member-vs-dependent classification, closed-cycle-path, and mutual-replaces determinism specs.LazyServiceProviderSpec.cfccovers the unhinted post-boot path and the service-hinting eager-lifecycle path.mixinCollisionShapeSpec.cfcpins the normalized record shape.InjectorInterfaceSpec.cfcupdated to assert the binding's absence.
One observation in LazyServiceProviderSpec.cfc: the FakeContainer fixture is sourced from "wheels.tests._assets.plugins.serviceprovider.FakeContainer" — a path rooted in the plugins fixture tree, not the new packages_lazy_sp tree. This works correctly (the file exists), but the cross-tree reference is slightly surprising for a package-subsystem spec. Worth noting for someone who later refactors the plugins fixtures.
Docs
CHANGELOG: The PR body explains the omission ("consolidated at campaign end"). Acceptable as a stated team practice for review-wave PRs.
.ai/wheels/ docs: No DI-specific doc exists under .ai/wheels/ — the container API is documented inline in CLAUDE.md's "DI Container Quick Reference" section, which is not changed by this fix PR. No update required.
Commits
Single commit fix(di): singleton locking and keying, SemVer space parsing, lazy provider boot (79 chars, under the 100-char header limit). Subject is sentence-case, type is fix, scope di is valid (unrestricted per commitlint.config.js). DCO Signed-off-by: Peter Amiri <peter@alurium.com> is present. Commit body is detailed and explains the why for each finding.
Resolves the one conflict (vendor/wheels/events/onapplicationstart.cfc) by composing both sides: keep #2930's helperFileCache/layoutFileCache struct caches from develop (consumed by Controller.cfc and controller/layouts.cfc) and keep this PR's struct-typed existingObjectFiles/nonExistingObjectFiles existence caches (consumed by Global.cfc $objectFileName). The obsolete existing/nonExisting helper- and layout-file list initializers are dropped; no consumers remain repo-wide. Auto-merged files verified semantically: Mapper.cfc (dead instance-level staticRoutes removal vs #2924 comment update — disjoint), model/miscellaneous.cfc ($parseSlashDate call vs #2931 $stampTimestampProperty helper — disjoint), Global.cfc (PR hunks at ~633-3007 vs #2912/#2943 hunks at ~3063-3181 — disjoint; both sides present in the merge). Verified locally on Lucee 7 + SQLite (dir-only docker mount): global 133/0/0, dispatch 104/0/0, mapper dir 89/0/0, mapperSpec 64/0/0, mapperModernSpec 27/0/0, routingSpec 5/0/0, events 36/0/0, controller 456/0/0 (1 skip), model 869/0/0 (11 skip). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
…ntegration-and-refle Resolves the CHANGELOG.md conflict by integrating the PR's entry as a new "### Performance" subsection inside develop's populated "## [Unreleased]" section (discarding the PR hunk's malformed single-hash "# [Unreleased]" header). vendor/wheels/Global.cfc auto-merged cleanly: develop's sibling changes since the merge base (#2933 cache culls + $objectFileName struct memoization, #2939 $deprecated registry, #2912/#2943 provider isolation + mixin-collision helpers, staticRoutes clear in $lockedLoadRoutes) touch regions orthogonal to this PR's $cachedModelLookup/$cachedControllerLookup helpers and the model()/controller() fast paths; both sides' semantics verified to survive — the fast path reads the same application.wheels.models/controllers structs that develop's unchanged $cachedModelClassExists/$createModelClass write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
Summary
Fixes the eight findings of the di-container package from the 2026-06-09 framework review: singleton-cache keying/locking and init-param memoization in the DI container, SemVer space-separated operator parsing, lazy-package ServiceProvider lifecycle participation, mixin-collision record-shape normalization, module-graph cycle diagnostics and deterministic mutual
replaces, and removal of a deadInjectorInterfacebinding.Findings addressed
asSingleton()flags are keyed by mapping name @vendor/wheels/Injector.cfc:121-206. Cache re-keyed by mapping name (same key as the flags, so two aliases to one path get distinct instances), singleton construction wrapped in a double-checked named lock with a per-container UUID prefix (Injector.cfc:170), and construction extracted into$constructInstance(Injector.cfc:206) shared by singleton/request-scoped/transient paths.to()invalidates a cached instance only when the alias re-binds to a different path (dev-reload keeps it).init()metadata scanned on every transient resolution and inheritedinit()ignored @vendor/wheels/Injector.cfc:356-374. Init-param names are memoized per component path, and$scanInitParameterNameswalks theextendschain so an inheritedinit()participates in constructor auto-wiring as advertised. Late-registered mappings are still honored (names, not resolved instances, are cached).vendor/wheels/SemVer.cfc:93-156.satisfiesAllmerges an operator-only token (^(>=|<=|[><=^~])$) with the following token (SemVer.cfc:151), so">= 1.0.0"keeps range semantics; an operator with an empty target now fails closed across all seven operators including the^/~early-returns (SemVer.cfc:93-106).vendor/wheels/PackageLoader.cfc:908-951+vendor/wheels/Global.cfc:3220-3224. Service-hinted lazy packages are instantiated in$invokeServiceProviderRegisterbefore the provider snapshot, joining both register and boot loops; an unhinted lazy provider instantiated post-boot getsregister()/boot()invoked late via the captured lifecycle context, with failures routed tofailedPackages+$rollbackPackage. The$loadPackagesgate now asks$hasServiceProviderWork()so a vendor tree with only lazy service packages still runs the lifecycle.vendor/wheels/Global.cfc:3077($normalizeMixinCollisions),vendor/wheels/public/views/plugins.cfm:89,vendor/wheels/events/onrequestend/debug.cfm:404. Plugin-shaped records ({existingPlugin, overridingPlugin}) are normalized to the shared{firstProvider, secondProvider, acknowledged, source}shape at the merge point; both debug surfaces now read one shape.Plugins.cfc's owngetMixinCollisions()API keeps the legacy shape (pinned bypluginsModernSpec).vendor/wheels/ModuleGraph.cfc:310($classifyCycleNodes, self-reachability DFS within the unprocessed set) andvendor/wheels/ModuleGraph.cfc:393($findCyclePath). Members get a real closed arrow chain (first == last asserted in spec); dependents get a distinct "depends on package(s) involved in a circular dependency" message. The deadinDegreebookkeeping was dropped from$buildAdjacencyList($topologicalSortrecomputes it locally; no other consumer).replacesnon-deterministically removes both packages @vendor/wheels/ModuleGraph.cfc:107($processReplacements). Declarations are processed in sorted directory order and declarations from already-excluded packages are skipped, so mutual replaces deterministically keep the lexicographically-first package.InjectorInterfacebinding that could never resolve @vendor/wheels/Bindings.cfc:52. Removed with an explanatory comment; grep confirms zero resolvers of"InjectorInterface"anywhere invendor/orcli/.Findings verified already-fixed
None — all eight findings were still present on
origin/develop(spot-checked during review: the path-keyed singleton cache, the unmergedsatisfiesAlltokenization, and theBindings.cfcInjectorInterfacebinding were all confirmed in the pre-image).Source
Internal multi-agent framework review, 2026-06-09 — wave 2, package di-container.
Tests
New/updated specs (WheelsTest BDD, red-verified against pre-fix code):
vendor/wheels/tests/specs/di/InjectorSpec.cfc— singleton cache keyed by mapping name (fails pre-fix); inherited-init()auto-wiring (errors pre-fix); late-registered mapping still honored after memoization. Fixtures undervendor/wheels/tests/_assets/di/.vendor/wheels/tests/specs/semverSpec.cfc— 5 specs fail pre-fix (space-separated operators, fail-closed empty targets).vendor/wheels/tests/specs/packages/ModuleGraphSpec.cfc— 4 specs fail pre-fix (member vs dependent classification, closed cycle path, deterministic mutual replaces).vendor/wheels/tests/specs/packages/LazyServiceProviderSpec.cfc— 2 fail + 1 error pre-fix ($hasServiceProviderWorkmissing). Fixtures undervendor/wheels/tests/_assets/packages_lazy_sp/.vendor/wheels/tests/specs/global/mixinCollisionShapeSpec.cfc— pins the normalized record shape.vendor/wheels/tests/specs/interfaces/InjectorInterfaceSpec.cfc— updated to assert the binding's absence.Local verification: implementation run green on Lucee 7 + SQLite. Reviewer independently re-traced red-on-develop for every claimed red spec (counts matched exactly). Full engine × DB matrix deferred to CI, which is the real gate for Adobe/BoxLang coverage.
Cross-engine notes
Injector.cfcfollows existing prior art (Global.cfc:31,TenantMigrator).$findCyclePathexplicitlyDuplicate()s path arrays before struct-literal insertion (Adobe CF copies arrays by value in struct literals — invariant 6), with a docstring noting why.local.Xreads aftercatchbodies (BoxLang invariant 11), noLeft(str, 0)(Lucee 7), noargumentspassed asattributeCollection.Global.cfchelper follows the public$-prefix mixin convention (invariant 7)./wheels/pluginsJSON debug payload key rename (existingPlugin→firstProvider) is a dev-surface contract change; the old shape crashed on any package-sourced record, so nothing functional could have depended on it.Changelog
Entry deliberately omitted; consolidated at campaign end.
🤖 Generated with Claude Code