Skip to content

fix(di): singleton locking and keying, SemVer space parsing, lazy provider boot - #2943

Merged
bpamiri merged 1 commit into
developfrom
peter/review-w2-review-di-container-fixes
Jun 10, 2026
Merged

fix(di): singleton locking and keying, SemVer space parsing, lazy provider boot#2943
bpamiri merged 1 commit into
developfrom
peter/review-w2-review-di-container-fixes

Conversation

@bpamiri

@bpamiri bpamiri commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

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 dead InjectorInterface binding.

Findings addressed

  • DI3 — singleton cache keyed by component path while 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).
  • DI13 — init() metadata scanned on every transient resolution and inherited init() ignored @ vendor/wheels/Injector.cfc:356-374. Init-param names are memoized per component path, and $scanInitParameterNames walks the extends chain so an inherited init() participates in constructor auto-wiring as advertised. Late-registered mappings are still honored (names, not resolved instances, are cached).
  • DI2 — space-separated SemVer operators degrade to exact match / unsatisfiable @ vendor/wheels/SemVer.cfc:93-156. satisfiesAll merges 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).
  • DI9 — lazy packages never join the ServiceProvider lifecycle @ vendor/wheels/PackageLoader.cfc:908-951 + vendor/wheels/Global.cfc:3220-3224. Service-hinted lazy packages are instantiated in $invokeServiceProviderRegister before the provider snapshot, joining both register and boot loops; an unhinted lazy provider instantiated post-boot gets register()/boot() invoked late via the captured lifecycle context, with failures routed to failedPackages + $rollbackPackage. The $loadPackages gate now asks $hasServiceProviderWork() so a vendor tree with only lazy service packages still runs the lifecycle.
  • DI1 — mixin-collision records have two incompatible shapes @ 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 own getMixinCollisions() API keeps the legacy shape (pinned by pluginsModernSpec).
  • DI6 — cycle diagnostics conflate true cycle members with downstream dependents @ vendor/wheels/ModuleGraph.cfc:310 ($classifyCycleNodes, self-reachability DFS within the unprocessed set) and vendor/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 dead inDegree bookkeeping was dropped from $buildAdjacencyList ($topologicalSort recomputes it locally; no other consumer).
  • DI11 — mutual replaces non-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.
  • DI5 — dead InjectorInterface binding that could never resolve @ vendor/wheels/Bindings.cfc:52. Removed with an explanatory comment; grep confirms zero resolvers of "InjectorInterface" anywhere in vendor/ or cli/.

Findings verified already-fixed

None — all eight findings were still present on origin/develop (spot-checked during review: the path-keyed singleton cache, the unmerged satisfiesAll tokenization, and the Bindings.cfc InjectorInterface binding 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 under vendor/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 ($hasServiceProviderWork missing). Fixtures under vendor/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

  • The script-syntax named lock in Injector.cfc follows existing prior art (Global.cfc:31, TenantMigrator).
  • $findCyclePath explicitly Duplicate()s path arrays before struct-literal insertion (Adobe CF copies arrays by value in struct literals — invariant 6), with a docstring noting why.
  • No local.X reads after catch bodies (BoxLang invariant 11), no Left(str, 0) (Lucee 7), no arguments passed as attributeCollection.
  • The new Global.cfc helper follows the public $-prefix mixin convention (invariant 7).
  • /wheels/plugins JSON debug payload key rename (existingPluginfirstProvider) 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

…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>

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • $findCyclePath uses Duplicate(local.frame.path) before struct-literal insertion, with an inline comment citing invariant 6 (Adobe CF copies arrays by value in struct literals). ✓
  • $normalizeMixinCollisions is declared public with a $ prefix — required for a helper mixed via $integrateComponents in Global.cfc (invariant 7). ✓
  • No Left(str, 0) patterns introduced (invariant 8). ✓
  • No arguments passed as attributeCollection (invariant 10). ✓
  • No local.X reads after catch bodies (invariant 11). ✓
  • The named-lock prefix "wheelsDISingleton_" & CreateUUID() & "_" follows the script-syntax lock pattern from Global.cfc. ✓

Tests

Coverage is thorough:

  • InjectorSpec.cfc adds singleton-cache-by-alias, extends-chain auto-wiring, late-registration memoization, and rebind-invalidation specs — all described as red on develop.
  • semverSpec.cfc adds 5 specs for space-separated operators and fail-closed empty targets.
  • ModuleGraphSpec.cfc adds member-vs-dependent classification, closed-cycle-path, and mutual-replaces determinism specs.
  • LazyServiceProviderSpec.cfc covers the unhinted post-boot path and the service-hinting eager-lifecycle path.
  • mixinCollisionShapeSpec.cfc pins the normalized record shape.
  • InjectorInterfaceSpec.cfc updated 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.

@github-actions github-actions Bot added enhancement dependencies Pull requests that update a dependency file javascript Pull requests that update javascript code labels Jun 10, 2026
@bpamiri
bpamiri merged commit 8e4f507 into develop Jun 10, 2026
7 checks passed
@bpamiri
bpamiri deleted the peter/review-w2-review-di-container-fixes branch June 10, 2026 07:36
bpamiri added a commit that referenced this pull request Jun 10, 2026
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>
bpamiri added a commit that referenced this pull request Jun 10, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file enhancement javascript Pull requests that update javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant