Repository navigation
fix(modules): pre-build only root-reachable singletons — never circuit-scoped services (#6556) - #6558
Conversation
…efore publish (#6556) #6545's built-before-published also built every SCOPED registration of a module container at boot and before a swap commits. A scoped instance belongs to a circuit or a request; the boot-built one ran against scoped state no circuit had initialised. On image 3.1.10611 Radzen's DialogService (scoped, subscribes to NavigationManager in its constructor) threw 'RemoteNavigationManager has not been initialized', the Radzen view pack was withdrawn, and the withdrawn module's InstalledModuleAssembly then faulted the pre-warm sweep, refusing readiness. Now only SINGLETONS the root reaches (Proxy/Current forwarders, ModuleOwned/ Contributed stand-ins) are pre-built, and not one whose constructor graph, read structurally off the registrations (ScopedDependencyWalk), reaches a scoped registration. The #6542 lock-order guarantee holds: a root singleton reaching a scoped module service is a captive dependency, not a root path. Refs #6556 #6557 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Scoped services still resolve through the fixed module scope, while factories and service locators bypass detection and can reintroduce activation failures or deadlocks.
3 open findings
What changed in this PR
Narrows module pre-building to root-reachable singletons, aiming to avoid activating circuit-scoped services during boot or live swaps.
Changes:
- Adds structural scoped-dependency detection.
- Adds boot and live-swap regression tests.
- Documents the revised activation behavior and remaining issue.
| File | Description |
|---|---|
src/MeshWeaver.ServiceProvider/ModuleServiceProvider.cs |
Implements filtered singleton pre-building. |
src/MeshWeaver.Mesh.Contract/ModuleServices.cs |
Selects root-reachable module routes. |
test/.../RootAndModuleSingletonsBuildInOneLockOrderTest.cs |
Updates lock-order coverage. |
test/.../ModuleRootServicesSwapTest.cs |
Adds live-swap coverage. |
test/.../AModuleWhoseSingletonCannotBeBuiltAtBootTest.cs |
Adds boot-time circuit-service coverage. |
src/MeshWeaver.Documentation/Data/Architecture/LiveModuleUpdate.md |
Documents the activation model. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| .Where(r => ScopedDependencyWalk.IsSingleton(r) | ||
| && r.Services.Any(rootReachable) | ||
| && !walk.ReachesAScopedService(r, ScopedDependencyWalk.Side.Module)) |
There was a problem hiding this comment.
You're right that this registration is left to its first resolve, and that a lazy first build of it can meet the #6542 lock cycle. That happens if a root singleton's constructor resolves it through Current/ModuleOwned while another thread is building it for the first time. It isn't confined to this skip, though. Every SCOPED module registration has the same shape: ModuleServices.Resolve serves it from the one module container (Scope(root)), so it is shared at that container's root and built lazily under its lock.
Rejecting the captive registration outright would withdraw any module that has one, which is the boot-time blast radius this PR removes. Building it would run circuit state nobody initialised, which is the #6556 failure itself. Neither closes the cycle for scoped registrations.
The root cause is that a module's scoped registrations resolve in the module container's root instead of a scope tied to the caller. I've filed it with the code path and what is and isn't established: rbuergi/Feedback/module-scoped-services-not-circuit-scoped (control instance bug triage). Fixing that makes the scoped-reaching singleton a real captive dependency that can be refused by name, and it removes this residual for both shapes together. This PR stays limited to the production blocker: boot no longer withdraws a module over circuit-bound services. No change in this PR.
| .Where(r => r.Route is ModuleServiceRoute.Proxy or ModuleServiceRoute.Current | ||
| or ModuleServiceRoute.ModuleOwned or ModuleServiceRoute.Contributed) | ||
| .Select(RootReachesItAs) | ||
| .ToImmutableHashSet(); | ||
| ModuleServiceProvider.BuildSharedInstances(Scope(root), hosted.Contains); | ||
| ModuleServiceProvider.BuildRootReachableSingletons(Scope(root), root, reachable.Contains); |
There was a problem hiding this comment.
Confirmed from the code. Current → ModuleContexts.ResolveModuleService → ModuleServices.Resolve always resolves from the generation's one Scope(root), and ModuleOwned resolves from the same container. So a module's scoped registration, and its scoped root dependency resolved through the fallback, is not circuit-scoped.
This PR only stops that registration being built at BOOT, where the build withdrew the whole module and refused the pod's readiness (the held production roll). It doesn't make the eventual resolve circuit-correct, and it doesn't claim to. The tests assert publication and swap, not a circuit resolve. The architectural gap predates #6542: before it, the lazy resolve took the same path.
Propagating the caller's scope into module resolution is a change to every forwarder and to ModuleContexts, so it is filed as its own root-cause item with the code path and what has not been measured on a live portal: rbuergi/Feedback/module-scoped-services-not-circuit-scoped (control instance bug triage). No change in this PR.
| .OfType<Type>() | ||
| .Distinct() | ||
| : []; |
There was a problem hiding this comment.
A delegate-activated singleton can resolve scoped state inside its factory, and the structural walk can't see into a delegate. Treating every delegate-backed root-reachable singleton as unsafe would leave most module registrations (options, factories, sp => … registrations) to a lazy first build. That reopens #6542's root/module lock cycle for all of them to protect against a shape no known module has.
So the trade-off is deliberate: a delegate singleton is built before publish, as on main since #6542. A factory that does reach uninitialised circuit state throws at boot, and the module is withdrawn and reported by name (IncompatibleModule, /health). That surfaces a captive registration; it doesn't hide one.
The real cure for scoped state reached from module services is resolving module scoped registrations in the caller's scope. That is filed with the code path: rbuergi/Feedback/module-scoped-services-not-circuit-scoped (control instance bug triage). No change in this PR.
Test Results 20 files - 4 20 suites - 4 54m 17s ⏱️ - 2m 39s Results for commit 6d4c31d. ± Comparison against base commit c702582. This pull request removes 347 and adds 18 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |


Refs #6556 #6557 (sev:H production regression from #6545, holds the memex roll on image
3.1.10611)Root cause
#6545 ("built before published") built every shared instance of a module container at boot and before a live swap commits, including each scoped registration (built at the container's root). A scoped instance belongs to a circuit or a request. The one built at boot ran against scoped state that no circuit had initialised. Radzen's
AddRadzenComponents()registersDialogServiceas scoped, and its constructor subscribes toNavigationManager.LocationChanged. The module container resolvesNavigationManagerin a root child scope (RootResolutionScope), whereRemoteNavigationManageris uninitialised, so the constructor threw'RemoteNavigationManager' has not been initialized.BuildAllBeforePublishthen withdrew the whole Radzen view pack (#6556). The withdrawn module's transientInstalledModuleAssemblyregistration then threw "no longer held by this mesh's module registry" inside the pre-warm sweep's enumeration, and the pod refused readiness (#6557).Fix
ModuleServiceProvider.BuildRootReachableSingletons(it replacesBuildSharedInstances, which was added by #6545 the same day and has no callers outside core) pre-builds only process-lifetime singletons that the root can reach:Proxycovers the proxy invoke and the health-check and options forwarders.Currentis the forwarder for class-typed services.ModuleOwnedandContributedare reached through theModuleOwnedRootSourcestand-ins. APrivateregistration is built only when a built registration takes it.Hostedregistrations are excluded, as before.RootScopeLifetime, shared). A scoped registration is never pre-built.ScopedDependencyWalkdetects this structurally from the registrations, not from type names. It reads every public constructor's parameters of a reflection-activated registration and follows them through transients and the module's own singletons. It also follows them into the root's registrations for types that name no collectible assembly; it never asks the root about a collectible type, because asking caches the type there and pins its context. A root singleton ends the walk. A delegate-activated registration names no dependencies, so it is still built.The #6542 deadlock guarantee is kept. A root singleton that takes a scoped module service is a captive dependency, not a path by which the root reaches a module. Every root-reachable singleton is still built before publish.
Tests (
MeshWeaver.Compiler.Pipeline.Test, Release)AModuleWithCircuitBoundServicesLoadsAtBootTest. It uses the Radzen shape against a root-scoped stand-in (CircuitBoundState) that throws exactly asRemoteNavigationManagerdoes. The module has a scoped module class, a scoped platform class (theCurrentroute), a singleton that takes circuit state, and a singleton that takes that singleton. The module is published, nothing is reported incompatible, and it serves.ModuleRootServicesSwapTest.AnUpdateWithCircuitBoundServices_SwapsLive. The same shape on a live swap; the swap goes Live.'CircuitBoundState' has not been initialized. (while building … CircuitDialog).AModuleWhoseSingletonCannotBeBuiltAtBootTest, a root-reachable singleton whose constructor throws still withdraws the module and reports it.AnUpdateWhoseSingletonCannotBeBuilt_IsRefusedBeforeItCommits_AndNKeepsServingstill refuses the swap.RootAndModuleSingletonsBuildInOneLockOrderTestis green (3/3, including its deadlocking negative control).MeshWeaver.Compiler.Pipeline.Testrun: 1410/1410 passed, Release, locally. The touched projects build with-c Release -warnaserrorwith 0 warnings and 0 errors.Not in this PR (#6557's latent half)
A module withdrawn at boot for a genuine reason still leaves its
InstalledModuleAssemblytransient in the root, which throws once the boot assembly is collected. Moving that registration into an Autofac registration source was tried and rejected: hosts that build with MS DI'sBuildServiceProvider()ignore sources, andConfigureModuleActivationTestlost every held module. This is documented as open inDoc/Architecture/LiveModuleUpdate→ "Built before published". With this fix Radzen is no longer withdrawn, so it no longer reaches that path.Deploy
Once an image carrying this fix is built, the memex roll proceeds on the next self-update Roll. No recycle is needed: the defect is in boot-time module activation, and a new pod is a fresh process.
Pairs-with: none — removes
ModuleServiceProvider.BuildSharedInstances, added by #6545 earlier today; no satellite or in-mesh caller (the only callers were core'sModuleServicesand one core test, both ported here)Mirror-sync: none — no i18n catalog change
🤖 Generated with Claude Code