Repository navigation
fix(modules): a module forwarder owns nothing it returns (#6588) - #6591
Merged
Merged
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>
The root forwarders of a held module's platform-typed services were ordinary factory registrations, owned by the scope that resolved them, so a Blazor circuit ending disposed a module singleton under every other user and a module scoped instance a second time. Every forwarder is now a NotOwnedServiceDescriptor, which every MeshWeaver container registers externally owned at its own position (PopulateMeshWeaver). Refs #6588, refs #6584 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
The caller/module lock inversion, synchronous async-disposal path, and null-key registration regression remain unresolved.
3 open findings
What changed in this PR
Fixes module-service lifetime ownership and caller-scope isolation across root and module containers.
Changes:
- Resolves scoped module services within caller-owned module scopes.
- Registers module forwarders as externally owned while preserving registration order.
- Adds lifecycle tests and architecture documentation.
| File | Description |
|---|---|
test/MeshWeaver.Compiler.Pipeline.Test/AModuleWhoseSingletonCannotBeBuiltAtBootTest.cs |
Adds caller-scope isolation tests. |
test/MeshWeaver.Compiler.Pipeline.Test/AModuleForwarderOwnsNothingItReturnsTest.cs |
Tests forwarder ownership across host types. |
test/MeshWeaver.Compiler.Pipeline.Test/ACallersModuleScopeEndsWithTheGenerationTest.cs |
Tests caller/generation disposal ordering. |
src/MeshWeaver.ServiceProvider/ServiceProviderExtensions.cs |
Uses MeshWeaver-aware Autofac population. |
src/MeshWeaver.ServiceProvider/NotOwnedServiceDescriptor.cs |
Introduces externally owned descriptors. |
src/MeshWeaver.ServiceProvider/ModuleServiceProvider.cs |
Adds caller-owned module scopes. |
src/MeshWeaver.Mesh.Contract/ModuleServices.cs |
Propagates caller scopes during resolution. |
src/MeshWeaver.Mesh.Contract/ModuleServiceForwarders.cs |
Makes forwarders non-owning and caller-aware. |
src/MeshWeaver.Mesh.Contract/ModuleOwnedRootSource.cs |
Makes module stand-ins caller-aware. |
src/MeshWeaver.Mesh.Contract/ModuleContexts.cs |
Adds caller-aware service resolution. |
src/MeshWeaver.Documentation/Data/Architecture/LiveModuleUpdate.md |
Documents ownership and caller-scope behavior. |
🧠 Review effort: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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>
…ock (#6584) A caller-owned module scope had its own Autofac shared-instance lock, and the two locks were taken in both orders (caller then module scope, module scope then caller) — two first builds overlapping in those orders deadlocked. The module scope, and every scope begun from it, now takes its caller's lock as its own: one lock owner, re-entered by the thread already holding it. CallerAndModuleScopedServicesBuildInOneLockTest forces the overlap; its negative control restores the scope's own lock and deadlocks. Also pins that a null-keyed forwarder registers as the unkeyed service. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Contributor
Test Results 20 files - 4 20 suites - 4 57m 38s ⏱️ + 1m 20s Results for commit 0a72aa7. ± Comparison against base commit f007765. This pull request removes 347 and adds 60 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Refs #6588 · Refs #6584
Stacked on #6586 (unmerged): this branch is cut from
fix/6584-module-scoped-in-caller-scope, so the diff includes #6586's commit; merging this lands both. The commit specific to this PR is55a66eba94.Defect (#6588)
The root forwarders of a held module's platform-typed services (
ModuleServiceForwarding.AddForwarders) were ordinary factory registrations. Autofac'sPopulatemakes thoseOwnedByLifetimeScope, so the scope that resolved a forwarder disposed whatever it returned. When one Blazor circuit ended, a module singleton (routeCurrent) was disposed while every other user was still on it. A module scoped instance was disposed twice: once by the module scope its circuit owns (#6586) and again by the circuit. AProxyover anIDisposableinterface forwardedDisposeto the module's instance in the same way.Fix (root cause: ownership)
NotOwnedServiceDescriptor(MeshWeaver.ServiceProvider) is a factory descriptor that every MeshWeaver container registers externally owned throughPopulateMeshWeaver. That covers the root, hub child scopes and module containers.Populatewalks the collection. Which registration is the default, and the order of anIEnumerable, stay exactly as before.ANotOwnedForwarderKeepsItsPositionTestchecks both.Current, plusProxyat every lifetime, keyed and unkeyed) is now aNotOwnedServiceDescriptor. Ownership stays with the module generation's container (singleton) or with the module scope the caller owns (scoped).Evidence
AModuleForwarderOwnsNothingItReturnsTestruns on the test mesh, on anIHostApplicationBuilderhost and on a legacyIHostBuilderhost. In each, one circuit ends and then:Expected value to be 0 … but found 1) on all three hosts. They are green with it.dotnet build -c Release -warnaserror: clean for MeshWeaver.Compiler.Pipeline.Test (non-incremental, which covers ServiceProvider and Mesh.Contract), Memex.Portal.Shared.Test and MeshWeaver.Documentation.~Module210 out of 210 passed; Memex.Portal.Shared.Test~Module445 out of 445 passed.Doc:
LiveModuleUpdate.mdgains the section "A forwarder owns nothing it returns (MeshWeaver#6588)".Pairs-with: none — only adds public types; nothing outside core is removed or changed.
🤖 Generated with Claude Code