Skip to content

fix(di): scope the circular-dependency guard per-request, not application - #2344

Merged
bpamiri merged 1 commit into
developfrom
claude/fix-2331-di-circular-race
Apr 27, 2026
Merged

bpamiri merged 1 commit into
developfrom
claude/fix-2331-di-circular-race

Conversation

@bpamiri

@bpamiri bpamiri commented Apr 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

The very first HTTP request to a freshly-created Wheels app rendered a 1MB error page —

```
Wheels.DI.CircularDependency
Circular dependency detected while resolving 'app.controllers.Main'.
Resolution chain: app.controllers.Main -> app.controllers.Main
```

— with HTTP 200. Workaround was `wheels reload` then refresh; the error never reappeared on subsequent boots.

Root cause

`variables.resolving` lived on the Injector instance, which is stored at `application.wheelsdi`. That makes the resolving struct application-scoped — shared across all concurrent request threads.

On a cold framework, when Lucee was still compiling `app/controllers/Main.cfc` and two requests near-simultaneously hit `getInstance("app.controllers.Main")`:

Thread A Thread B
`getInstance("X")` enters
adds `X` to `variables.resolving`
starts `createObject` (slow first compile) `getInstance("X")` enters
checks `variables.resolving` → sees A's entry
throws "Circular dependency: X -> X"

The `X -> X` chain in the error message is exactly what the journal saw — Thread B's resolution chain was just Thread A's in-flight entry being read across the thread boundary.

The `wheels reload` workaround works because reload clears Lucee's CFC class cache, but more importantly the SECOND attempt finds the class already compiled — the compile window closes and the race narrows to nothing. The journal's note that "the error never reappeared" matches.

Fix

Store the resolving stack on `request.$wheelsDIResolving` instead of `variables.resolving`. Each request thread gets its own struct, so concurrent calls don't trip each other's guard.

  • `$getResolvingStack()` lazy-creates `request.$wheelsDIResolving` on first access.
  • The `finally` block still pops the entry on both success and failure, so subsequent `getInstance` calls in the same request work as expected.
  • Within a single request, true circular dependencies (A → B → A) still throw correctly — the guard's intra-request semantics are unchanged.

Tests

Two new `InjectorSpec` regressions:

The existing `throws on circular dependency` spec still passes — intra-request circular detection is unchanged.

Test plan

Closes #2331

…tion

Symptom from fresh-VM tutorial run: the very first HTTP request to a
freshly-created Wheels app rendered a 1MB error page —

    Wheels.DI.CircularDependency
    Circular dependency detected while resolving 'app.controllers.Main'.
    Resolution chain: app.controllers.Main -> app.controllers.Main

— with HTTP 200. Workaround was `wheels reload` then refresh; the
error never reappeared on subsequent boots.

Root cause: `variables.resolving` lived on the Injector instance,
which is stored at `application.wheelsdi`. That makes the resolving
struct application-scoped — shared across all concurrent request
threads. On a cold framework, when Lucee was still compiling
app/controllers/Main.cfc and two requests near-simultaneously hit
`getInstance("app.controllers.Main")`:

  Thread A: getInstance("X") → adds to resolving → starts createObject
            (slow on first cold compile)
  Thread B: getInstance("X") → checks resolving → sees A's entry →
            throws "Circular dependency: X -> X"

The `X -> X` chain is exactly what the journal saw — Thread B's
"chain" was just Thread A's in-flight entry. After `wheels reload`,
the CFC class is cached so the compile window closes and the race
narrows to nothing — matching the journal's observation that the
error never reappeared.

Fix: store the resolving stack on `request.$wheelsDIResolving`
instead of `variables.resolving`. Each request thread gets its own
struct, so concurrent calls don't trip each other's guard. Within a
single request, true circular dependencies still throw correctly —
the guard's intra-request semantics are unchanged.

`$getResolvingStack()` lazy-creates the request key on first access.
The `finally` block still pops the entry on both success and failure
so subsequent getInstance calls in the same request work as expected.

Two new InjectorSpec regressions:
- "resolving stack is request-scoped, not application-scoped (#2331)"
  — verifies request.$wheelsDIResolving is created and that the
  struct stays clean after a successful resolve.
- "resolving stack recovers cleanly after a circular-dep error (#2331)"
  — verifies the finally cleanup runs even when a CircularDependency
  is thrown, and that subsequent unrelated resolves work.

Fixes #2331.

Test plan:
- bash tools/test-local.sh — 3330 passed (was 3328; 2 new specs).
- bash tools/test-cli-local.sh — 452 pass / 3 fail (the 3 failures
  are pre-existing Doctor Service issues #2260, unrelated).
- Existing "throws on circular dependency" spec still passes —
  intra-request circular detection is unchanged.
@bpamiri
bpamiri merged commit 99971cd into develop Apr 27, 2026
3 checks passed
@bpamiri
bpamiri deleted the claude/fix-2331-di-circular-race branch April 27, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(di): Wheels.DI.CircularDependency on very first request to fresh app

1 participant