Skip to content

fix(modules): a module's scoped service is built in a module scope its caller owns (#6584) - #6586

Merged
rbuergi merged 3 commits into
mainfrom
fix/6584-module-scoped-in-caller-scope
Oct 12, 2026
Merged

rbuergi merged 3 commits into
mainfrom
fix/6584-module-scoped-in-caller-scope

Conversation

@rbuergi

@rbuergi rbuergi commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

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, the Current forwarder, the Proxy forwarder, and a module type asked from another module's container — resolved it in the module container's OWN root. A module's AddScoped<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, and DialogService threw 'RemoteNavigationManager' has not been initialized on 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 its RootResolutionScope is the caller's own scope — NavigationManager/IJSRuntime are that circuit's. A scoped/transient Proxy is 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.
  • Kept green: 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), the ModuleSingletonsBuiltAtBoot family, 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.Plugins origin/main (0fd04c97) references none of these types or members, so no new overload makes a dependent <see cref> ambiguous.

🤖 Generated with Claude Code

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

Copilot AI 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.

🟡 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.

Comment on lines +196 to +200
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));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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).

Comment on lines +444 to +445
foreach (var owned in ending.Values)
owned.Dispose();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +525 to +528
// 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))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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).

rbuergi and others added 2 commits October 12, 2026 17:27
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>
Copilot AI balanced review requested due to automatic review settings October 12, 2026 15:29
@meshweaver-cloud
meshweaver-cloud Bot disabled auto-merge October 12, 2026 15:29

Copilot AI 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.

Comment on lines +100 to +102
builder.Register(context => CallerOfModuleScope(context.Resolve<ILifetimeScope>()) is { } caller
? new RootResolutionScope(caller, owned: false)
: new RootResolutionScope(rootScope.BeginLifetimeScope(), owned: true))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +196 to +200
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));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    20 files   -   4      20 suites   - 4   55m 1s ⏱️ - 1m 17s
11 871 tests  - 271  11 871 ✅  - 78  0 💤  - 193  0 ❌ ±0 
11 885 runs   - 271  11 885 ✅  - 78  0 💤  - 193  0 ❌ ±0 

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.

   --- End of inner exception stack trace ---
   --- End of inner exception stack trace ---, expected: True)
   --- End of inner exception stack trace ---, isDenial: True)
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---
 ---> (Inner Exception #1) MeshWeaver.Messaging.Hub.Test.InfrastructureFaultTest+ProviderException (0x80004005): Failed to connect to 10.42.18.4:5432<---
 ---> (Inner Exception #1) System.ArgumentException: Value does not fall within the expected range.<---
 ---> (Inner Exception #1) System.InvalidOperationException: boom<---
 ---> (Inner Exception #1) System.InvalidOperationException: source B is misconfigured<---
 ---> (Inner Exception #1) System.Net.Sockets.SocketException (0xFFFDFFFF): Name or service not known<---
…
Memex.Portal.Shared.Test.CallerFacingReadsDecideForTheCallerTest ‑ Autocomplete_ForAnUngrantedCaller_NamesNothingUnderTheHiddenSubtree
Memex.Portal.Shared.Test.CallerFacingReadsDecideForTheCallerTest ‑ Autocomplete_InsideANodeTheCallerCannotRead_AnswersNothingFromIt
Memex.Portal.Shared.Test.CallerFacingReadsDecideForTheCallerTest ‑ Autocomplete_InsideANodeTheHolderCanRead_IsAnsweredByIt
Memex.Portal.Shared.Test.CallerFacingReadsDecideForTheCallerTest ‑ Autocomplete_TheGrantHolder_StillCompletesIt_UnderTheSameSplit
Memex.Portal.Shared.Test.CallerFacingReadsDecideForTheCallerTest ‑ ContentList_ForAnUngrantedCaller_IsNotServedByTheOwner
Memex.Portal.Shared.Test.CallerFacingReadsDecideForTheCallerTest ‑ ContentList_TheGrantHolder_IsServed_UnderTheSameSplit
Memex.Portal.Shared.Test.CallerFacingReadsDecideForTheCallerTest ‑ Recycle_ForACallerWithoutUpdate_IsRefused
Memex.Portal.Shared.Test.CallerFacingReadsDecideForTheCallerTest ‑ Recycle_TheEditor_RecyclesIt_UnderTheSameSplit
Memex.Portal.Shared.Test.CallerFacingReadsDecideForTheCallerTest ‑ RenderArea_ForAnUngrantedCaller_RendersNothingOfTheNode
Memex.Portal.Shared.Test.CallerFacingReadsDecideForTheCallerTest ‑ RenderArea_TheGrantHolder_StillRendersIt_UnderTheSameSplit
…

♻️ This comment has been updated with latest results.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants