Skip to content

fix(modules): a module forwarder owns nothing it returns (#6588) - #6591

Merged
rbuergi merged 6 commits into
mainfrom
fix/module-forwarder-ownership
Oct 12, 2026
Merged

rbuergi merged 6 commits into
mainfrom
fix/module-forwarder-ownership

Conversation

@rbuergi

@rbuergi rbuergi commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

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 is 55a66eba94.

Defect (#6588)

The root forwarders of a held module's platform-typed services (ModuleServiceForwarding.AddForwarders) were ordinary factory registrations. Autofac's Populate makes those OwnedByLifetimeScope, so the scope that resolved a forwarder disposed whatever it returned. When one Blazor circuit ended, a module singleton (route Current) 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. A Proxy over an IDisposable interface forwarded Dispose to 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 through PopulateMeshWeaver. That covers the root, hub child scopes and module containers.
  • Each forwarder is registered at its own position while Populate walks the collection. Which registration is the default, and the order of an IEnumerable, stay exactly as before. ANotOwnedForwarderKeepsItsPositionTest checks both.
  • Every forwarder (Current, plus Proxy at every lifetime, keyed and unkeyed) is now a NotOwnedServiceDescriptor. Ownership stays with the module generation's container (singleton) or with the module scope the caller owns (scoped).

Evidence

  • AModuleForwarderOwnsNothingItReturnsTest runs on the test mesh, on an IHostApplicationBuilder host and on a legacy IHostBuilder host. In each, one circuit ends and then:
    • the module singleton keeps serving the other circuit and was never disposed;
    • each scoped instance (class route and proxy route) was disposed exactly once;
    • a control circuit's own end disposes its own instance once.
  • Negative control: with the ownership handling disabled, 4 of 5 tests went red (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.
  • Module suites: Compiler.Pipeline.Test ~Module 210 out of 210 passed; Memex.Portal.Shared.Test ~Module 445 out of 445 passed.

Doc: LiveModuleUpdate.md gains 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

rbuergi and others added 2 commits October 12, 2026 16:47
…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>
@rbuergi
rbuergi enabled auto-merge October 12, 2026 14:58
Copilot AI balanced review requested due to automatic review settings October 12, 2026 14:58

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

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.

rbuergi and others added 4 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>
…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>
Copilot AI balanced review requested due to automatic review settings October 12, 2026 15:34
@meshweaver-cloud
meshweaver-cloud Bot disabled auto-merge October 12, 2026 15:37

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.

🔵 Needs a closer look

It changes cross-container lifetime and concurrency behavior using Autofac private-field reflection, warranting final human review.

0 open findings

3 resolved since last review

🧠 Review effort: Balanced

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    20 files   -   4      20 suites   - 4   57m 38s ⏱️ + 1m 20s
11 879 tests  - 263  11 879 ✅  - 70  0 💤  - 193  0 ❌ ±0 
11 893 runs   - 263  11 893 ✅  - 70  0 💤  - 193  0 ❌ ±0 

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.

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