Repository navigation
fix(modules): a module's scoped service is built in a module scope its caller owns (#6584) - #6586
Conversation
…s caller owns (#6584) Every road the root reaches a module service by resolved it in the module container's own root, so a module's AddScoped<T>() was one instance per generation shared by every Blazor circuit, built against a root child scope no circuit initialised (Radzen ThemeService shared across users; DialogService throwing 'RemoteNavigationManager has not been initialized' on first use). A resolve now carries its caller (the root-side circuit/request/hub scope). A non-singleton registration is resolved in a module scope owned by that caller: begun on first use, disposed with the caller (or with the generation, whichever ends first), and resolving root dependencies in the caller's scope. Singletons are unchanged. Refs #6584 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Disposable proxies can fail during caller teardown, and generation-first teardown can synchronously block on asynchronous disposal.
3 open findings
What changed in this PR
Fixes module-scoped services so each caller scope receives isolated instances and caller-specific root dependencies.
Changes:
- Propagates caller scopes through module service resolution paths.
- Adds caller/generation lifecycle disposal handling.
- Adds integration tests and architecture documentation.
| File | Description |
|---|---|
test/MeshWeaver.Compiler.Pipeline.Test/AModuleWhoseSingletonCannotBeBuiltAtBootTest.cs |
Tests circuit-specific module services and disposal. |
test/MeshWeaver.Compiler.Pipeline.Test/ACallersModuleScopeEndsWithTheGenerationTest.cs |
Tests caller/generation disposal ordering. |
src/MeshWeaver.ServiceProvider/ModuleServiceProvider.cs |
Implements caller-owned module scopes. |
src/MeshWeaver.Mesh.Contract/ModuleServices.cs |
Propagates callers through module resolution. |
src/MeshWeaver.Mesh.Contract/ModuleServiceForwarders.cs |
Makes forwarders caller-aware. |
src/MeshWeaver.Mesh.Contract/ModuleOwnedRootSource.cs |
Passes caller scopes through stand-ins. |
src/MeshWeaver.Mesh.Contract/ModuleContexts.cs |
Adds caller-aware resolution overloads. |
src/MeshWeaver.Documentation/Data/Architecture/LiveModuleUpdate.md |
Documents scoped-service ownership and lifecycle. |
🧠 Review effort: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| case ModuleServiceRoute.Proxy: | ||
| // Scoped or transient: one proxy per caller, forwarding for it (MeshWeaver#6584). | ||
| root.Add(new ServiceDescriptor(type, key, (sp, _) => ModuleServiceProxy.Create( | ||
| type, sp.GetRequiredService<ModuleContexts>(), module, index, | ||
| ModuleServiceProvider.CallerOfRootScope(sp)), registration.Descriptor.Lifetime)); |
There was a problem hiding this comment.
Real, and fixed in the PR stacked on this one: #6591 (MeshWeaver#6588, commit 55a66eb) makes every root forwarder — Current, and Proxy at every lifetime — a NotOwnedServiceDescriptor, which every MeshWeaver container registers externally owned (PopulateMeshWeaver). The caller's teardown therefore never invokes a proxy's Dispose; the target is disposed once, by the caller-owned module scope that built it. AModuleForwarderOwnsNothingItReturnsTest covers exactly the shape asked for: a scoped IDisposableCircuitResource served through the proxy route, one circuit ending, the instance disposed exactly once and the other circuit served. The two PRs land together (#6591 contains this branch).
| foreach (var owned in ending.Values) | ||
| owned.Dispose(); |
There was a problem hiding this comment.
Measured, and not widened by this change. The generation's container is disposed synchronously on every path today — ModuleServices.Dispose → AutofacServiceProvider.Dispose(), reached from ModuleContexts.Retire and ModuleContexts.Dispose — and Autofac 9.3.4's sync Disposer runs an async-only item through Task.Run(...).GetAwaiter().GetResult(). Before #6584 every module SCOPED instance lived in that container's own root and went down that same synchronous path with it; now it lives in a caller-owned scope that the CALLER disposes asynchronously (AddInstanceForAsyncDisposal, so a circuit's async teardown reaches CallerOwnedModuleScope.DisposeAsync), and only when the generation ends first does it take the generation's synchronous path — the one its singletons already take. Making generation retirement itself asynchronous is a change to ModuleServices.Dispose/ModuleContexts.Retire for the singletons too, not to this caller-scope bookkeeping; it is out of scope here and reported as a separate finding. No change in this PR.
| // Resolved for the caller this module scope serves (MeshWeaver#6584): another module's | ||
| // scoped registration is that caller's instance, in that module's scope the caller owns. | ||
| foreach (var standIn in ModuleServiceProvider.StandIns(type, elsewhere, ownRegistrations, | ||
| ModuleServiceProvider.CallerOfModuleScope)) |
There was a problem hiding this comment.
Added in 3302dfd: AnotherModulesScopedServiceIsTheCallersTest — two real module containers built by ModuleServiceProvider, module A's scoped service taking module B's scoped type through ModuleFallbackSource's stand-ins, two caller scopes. It asserts each caller's instance is built against that caller's own root state, the two callers' instances differ, the instance reached through module A is the one in module B's scope the same caller owns, and each caller's end disposes its own instance exactly once and nothing of the other's. The control resolves the same road with no caller and gets the uninitialised-state throw. 7/7 green locally (Release).
ModuleFallbackSource's stand-ins resolve another module's scoped registration for the caller the asking module scope serves. Two real module containers, two callers: each gets its own instance of the other module's scoped service, built against its own root state, disposed once with its caller; the no-caller control builds against uninitialised state. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Caller-scope lock inversion and incomplete asynchronous teardown coordination can deadlock or dispose a generation while child cleanup is still running.
6 open findings
Caller and module scope locks can deadlock during fallback resolution · New Synchronous retirement blocks on async-only disposable services Generated proxy incorrectly forwards disposal after module scope teardown Generation retirement can race with in-flight child scope teardown · New Missing tests for caller propagation across scoped module contributions Add coverage for scoped and transient proxy caller isolation · New
🧠 Review effort: Balanced
| builder.Register(context => CallerOfModuleScope(context.Resolve<ILifetimeScope>()) is { } caller | ||
| ? new RootResolutionScope(caller, owned: false) | ||
| : new RootResolutionScope(rootScope.BeginLifetimeScope(), owned: true)) |
There was a problem hiding this comment.
This is the residual the PR documents under "Residual" in LiveModuleUpdate.md, not a new finding: it needs two threads making FIRST builds in the SAME caller scope at once, in opposite lock order. The callers that own a module scope serialise those builds (a Blazor circuit on its renderer; a Proxy defers its resolve to the first call), and no measurement has produced it. A single global lock order across the caller scope and the module scope is a design change to how Autofac shared-instance locks are taken, which is out of scope for this fix (#6584 is a wrong-instance bug: one ThemeService for every user). Not pushed on this PR under its converge rule; it stays named as an unmeasured residual for a follow-up.
| public void Dispose() => Release()?.Dispose(); | ||
|
|
||
| /// <inheritdoc /> | ||
| public ValueTask DisposeAsync() => Release()?.DisposeAsync() ?? ValueTask.CompletedTask; |
There was a problem hiding this comment.
The window is real but narrow and its consequence is a teardown-ordering one, not a wrong result: it needs a caller's async dispose of its module scope to be in flight at the exact moment that generation retires. Retirement unloads a collectible context cooperatively (the context stays alive while anything still references its code), so an in-flight child teardown is not cut from under itself; the worst case is a child component disposing against a parent singleton already disposed, which surfaces as an ObjectDisposedException in the dispose path rather than corrupted state. Tracking in-flight teardowns and joining them from the generation's dispose is a sound refinement; it is not pushed on this PR under its converge rule (push only for a wrong result, security hole, data loss or broken build/test) and is noted for a follow-up.
| case ModuleServiceRoute.Proxy: | ||
| // Scoped or transient: one proxy per caller, forwarding for it (MeshWeaver#6584). | ||
| root.Add(new ServiceDescriptor(type, key, (sp, _) => ModuleServiceProxy.Create( | ||
| type, sp.GetRequiredService<ModuleContexts>(), module, index, | ||
| ModuleServiceProvider.CallerOfRootScope(sp)), registration.Descriptor.Lifetime)); |
There was a problem hiding this comment.
Agreed it would be good coverage. The behaviour #6584 fixes is measured on the two roads that produced the incident (AModulesScopedServicesAreTheCallersTest, module-declared and platform-class, with a no-caller negative control) and on the cross-module road (AnotherModulesScopedServiceIsTheCallersTest); the scoped Proxy path builds through the same InCallerScope those tests pin. An extra two-caller proxy test is not a defect fix, so it is not pushed on this PR under its converge rule.
Test Results 20 files - 4 20 suites - 4 55m 1s ⏱️ - 1m 17s Results for commit 3302dfd. ± Comparison against base commit f007765. This pull request removes 347 and adds 52 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |



Refs #6584 · Refs #6556 · follow-up to #6558 (review thread on
ModuleServices.cs).The defect
Every road the root reaches a module service by —
ModuleOwnedRootSource's stand-ins, theCurrentforwarder, theProxyforwarder, and a module type asked from another module's container — resolved it in the module container's OWN root. A module'sAddScoped<T>()was therefore one instance per generation, shared by every Blazor circuit, and its root dependencies came from a root child scope no circuit initialised. With the Radzen view pack a module (MeshWeaver.Plugins#1974),ThemeService(injected by every Radzen chart / pivot view) was one instance for every user, andDialogServicethrew'RemoteNavigationManager' has not been initializedon its first real resolve — the throw #6558 moved off the boot path.The fix
A resolve now carries its CALLER — the root-side scope it is made in (circuit, request, hub; null for the root container itself) — down all four roads. A registration that is not a singleton is resolved in a module scope OWNED by that caller (
ModuleServiceProvider.InCallerScope,ModuleCallerScopes): begun on first use, tagged with the caller, handed to the caller's own disposer (so it ends with the circuit, asynchronously), and itsRootResolutionScopeis the caller's own scope —NavigationManager/IJSRuntimeare that circuit's. A scoped/transientProxyis now one proxy per caller scope. Singletons are unchanged (#6558: still built before published, still the generation's one instance). When a generation ends first (live swap), its container ends every caller's module scope before its own singletons and lets go of it, so a long-lived caller pins nothing of the retired generation.Residual, documented in
LiveModuleUpdate.md→ "A module's scoped service is the caller's": a caller-owned module scope has its own Autofac lock, so two concurrent FIRST builds in one caller scope in opposite orders could deadlock; not measured.Tests
AModulesScopedServicesAreTheCallersTest(mesh, real module ALC): two circuits get distinct instances, each built over its own circuit's state, through both the module-declared road and the platform-class (Current) road; ending a circuit (async) disposes its module scope and no other's; negative control: the same registration with no caller (and from a circuit that did not initialise its state) throws the uninitialised-state error.ACallersModuleScopeEndsWithTheGenerationTest: generation ends first → the caller's module scope is disposed once, and the caller's later end disposes nothing twice; caller ends first → its scope ends and the generation serves the next caller.RootAndModuleSingletonsBuildInOneLockOrderTest(Module containers: a root singleton reaching a module service while a module singleton needs a root singleton deadlocks (lock order root/module inverted) #6542 deadlock repro + its negative control),AScopedRootServiceIsNeverBuiltInTheRootTest,ModuleRootServicesSwapTest(incl. the collectible-unload drain), theModuleSingletonsBuiltAtBootfamily,AssemblyLoadContextLeakTest.Live verification owed
After the roll: open a page with a Radzen chart on two sessions and toggle dark mode on one; the other must not follow. Then recycle nothing — the module's services are re-resolved per circuit on the next page load.
Pairs-with: none — only overloads are ADDED (
ModuleServiceProvider.CreateForCallers/InCallerScope/CallerOfRootScope/CallerOfModuleScope/ResolveAt(…, caller)/StandIns(…, callerOf),ModuleServices.Resolve(…, caller),ModuleContexts.ResolveModuleService(…, caller),ModuleServiceProxy.Create(…, caller),ModuleServices.*ForCallers); nothing is removed. MeshWeaver.Pluginsorigin/main(0fd04c97) references none of these types or members, so no new overload makes a dependent<see cref>ambiguous.🤖 Generated with Claude Code