Repository navigation
Rebuild a record that names a build the store lacks; keep a type typeable while its generation unloads - #6456
Conversation
…able while its generation unloads Refs Systemorph/MeshWeaver.Plugins#2799. Residual 1 - memex logged StaleBytes for the same 15 NodeTypes on every boot. Their records carry a version and path that resolve to build N with the MVID of a build N+1, the residue of the first-write-wins assembly store. Binders refuse those bytes, but the boot sweep's bake probe asked the store by (path, version) alone, found a file, and reported Baked, so the only pass that rebuilds without an instance never did. NodeTypeBakeStatus.ProbeOne now resolves by identity when the record claims a live-framework build and states an MVID; a different build under the record's version is BytesMissing and is re-fetched or rebuilt, which stamps one coherent (version, path, MVID). Such an entry is never a regression baseline, so a failed rebuild cannot refuse a new replica's readiness. Residual 2 - an outgoing memex-cloud pod read Ops/Status/* untyped during a roll. Hub disposal unloads the type's collectible contexts, and MeshContentTypeRegistry dropped the generation's entries the moment Unloading fired, while the generation was still loaded and being read. It now demotes them to a weak shadow (the shape TypeRegistry has): resolvable while the generation is alive, rooting nothing, displaced by the first successor. Tests: RecordNamesABuildTheStoreLacksTest (Updates suite, scenario 11), ContentTypeRegistryReleasesAnUnloadedGenerationTest (four new facts), AReadAfterTheTypesHubLeftIsStillTypedTest. Each new behaviour is red with its source fix reverted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved unload races can retain or restore incorrect generations, and the probe performs blocking file I/O outside the sanctioned pool.
4 open findings
What changed in this PR
Repairs dynamic NodeType recovery and preserves type resolution during collectible-generation unloads.
Changes:
- Detects and rebuilds records whose stored assembly identity is inconsistent.
- Adds weak-shadow type resolution during generation unloading.
- Adds integration, lifecycle, and regression tests plus architecture documentation.
| File | Description |
|---|---|
test/MeshWeaver.Updates.Test/RecordNamesABuildTheStoreLacksTest.cs |
Tests stale-build detection and repair. |
test/MeshWeaver.Graph.Test/AReadAfterTheTypesHubLeftIsStillTypedTest.cs |
Tests typed reads during unload. |
test/MeshWeaver.Compiler.Pipeline.Test/ContentTypeRegistryReleasesAnUnloadedGenerationTest.cs |
Tests weak-shadow lifecycle behavior. |
src/MeshWeaver.Messaging.Hub/Serialization/IMeshContentTypeRegistry.cs |
Adds weak-shadow content-type resolution. |
src/MeshWeaver.Hosting/MeshWeaver.Hosting.csproj |
Exposes internals to the update tests. |
src/MeshWeaver.Hosting/DynamicTypePreWarmer.cs |
Expands stale-build diagnostics. |
src/MeshWeaver.Documentation/Data/Architecture/StaleStateUntilRecycle.md |
Documents the new update scenario. |
src/MeshWeaver.Documentation/Data/Architecture/DynamicContentTypeRegistration.md |
Documents both root causes and fixes. |
src/MeshWeaver.Compiler.Pipeline/NodeTypeBakeStatus.cs |
Detects build-identity mismatches and adjusts gating. |
🧠 Review effort: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…he shadow, read the store through the I/O pool Refs Systemorph/MeshWeaver.Plugins#2799. - MeshContentTypeRegistry: Register and Evict share one short lock; Evict marks the context as unloading (a long weak reference, nothing finalizable) and a registration from a marked context goes to the weak shadow. - NodeTypeBakeStatus.ProbeThrough: the per-type store read (lookup + MVID read) runs in the mesh's FileSystem I/O pool for the in-process callers; the public Probe keeps its signature. - Tests: late registration is never rooted; the pooled probe reads inside the pool; the Updates helper passes an immutable dictionary. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Test Results 20 files - 4 20 suites - 4 53m 25s ⏱️ +24s Results for commit e1ef99b. ± Comparison against base commit dc9f15b. This pull request removes 335 and adds 28 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
| foreach (var entry in _byDiscriminator) | ||
| if (ReferenceEquals(System.Runtime.Loader.AssemblyLoadContext.GetLoadContext(entry.Value.ContentType.Assembly), unloading) | ||
| && _byDiscriminator.TryRemove(entry) | ||
| // A contested name stays unresolvable: the shadow must not hand out the answer | ||
| // the strong map was deliberately refusing. | ||
| && !entry.Value.Ambiguous) | ||
| Demote(entry.Key, entry.Value.ContentType); |
There was a problem hiding this comment.
Valid, and deferred rather than pushed. The readers are lock-free, so between TryRemove and the shadow write a lookup can see neither entry; the remedy you give (publish the shadow first under the lock, then remove the strong entry, and still drop a contested claim without one) is the right one.
I am not pushing for it in this round because of its size, not because it is wrong: the window is the two adjacent statements of one sweep, and a read that falls into it degrades once, where before this change every read after the unload began degraded until a successor registered. It is recorded for triage with your ordering as the fix.
| // A strong claim stands for this name now; the superseded generation's shadow entry is done. | ||
| _demotedByDiscriminator.TryRemove(discriminator, out _); |
There was a problem hiding this comment.
Valid, and it predates this change, so it is deferred rather than pushed here. Before the shadow existed, declaration A's entry was removed outright when its context began unloading, and a later registration of the same discriminator by declaration B was treated as unique in exactly the same way. The shadow does not widen that; it only keeps A answering until B arrives.
The remedy is the one you describe: carry the declaration into the shadow entry and let it take part in the ambiguity decision, so only a rebuild of the same declaration displaces it. That changes what a strong claim means for a name with a live shadow claimant (it becomes contested), which deserves its own change and tests. Recorded for triage.
The NodeType-keyed route is not affected: content of a node with a NodeType is resolved by that path first, and the path map is per declaration.



Refs Systemorph/MeshWeaver.Plugins#2799 (the two residuals in the 2026-10-10 20:45Z comment).
Residual 1:
StaleBytesfor the same 15 types on every memex bootReadings (governed
Logson control.systemorph.com,Ops/Actions/w14c-memex-*):DynamicContentTypeRegistration: 15 baked dynamic NodeType(s) could NOT be registered on this replicain a 240 min window.content for nodeType rsalzmann/Clicker was NEVER resolvable on this replica — 2 read(s) degraded.Root cause. These records carry a version and a path that resolve to build N and the MVID of a build N+1. That is what the first-write-wins assembly store produced before it became content-addressed. No new record gets into that state, but nothing healed the old ones, because three components asked different questions about the same record:
StaleBytes(path, LastCompiledVersion)?BakedThe sweep is the only pass that rebuilds without an instance, and it takes its work list from the probe.
Fix.
NodeTypeBakeStatus.ProbeOneresolves by identity when the record claims a live-framework build and states an MVID (TryGetBuildPathplus an MVID comparison of the file it got). A different build under the record's version isBytesMissing, with both MVIDs in the detail. The sweep then re-fetches the shipped build or rebuilds on the owner, which stamps one coherent (version, path, MVID). The pass and the read route stay non-compiling and non-writing.Such an entry is never a regression baseline (
NodeTypeBakeEntry.RecordNamesABuildTheStoreLacks): the build the record names was available to no replica, so a failed rebuild must not refuse a new replica's readiness and stall the roll.Residual 2: an outgoing memex-cloud pod read
Ops/Status/*untyped during a rollReadings (
Ops/Actions/w14c-cloud-786n6-*), pod6dc5db759b-786n6, booted 19:51Z:GetStream: Content for Ops/Status/… stayed an untyped JsonElementApplication is shutting down...on this podgiving up on owner Hosting/DeploymentStatus — 4 DISTINCT owner activations have refused this stream;Hosting/DeploymentStatus was NEVER resolvable on this replica — 15 read(s) degradedThe degraded-read count equals the 15 lines, so the pod typed every earlier read of that type for 45 minutes and lost the type before its own host began to stop.
Root cause. Hub disposal unloads the type's collectible contexts (
UnloadNodeContexts).AssemblyLoadContext.Unload()only starts an unload, butMeshContentTypeRegistrydropped the generation's entries the momentUnloadingfired, while the generation was still loaded and its standing watches were still being served. No successor registered on that pod, and the on-demand route's kept verdict saidRegistered.Fix. The registry demotes an unloading generation's entries to a weak shadow instead of dropping them, the shape
TypeRegistryhas had since #1169. A demoted type answers while something keeps its generation alive, roots nothing, never answers for a contested discriminator, and is displaced by the first successor registration.Tests
Each is red without its fix (run with the two source fixes reverted):
RecordNamesABuildTheStoreLacksTest(MeshWeaver.Updates.Test, scenario 11):Expected value to be BytesMissing … but found Baked.ContentTypeRegistryReleasesAnUnloadedGenerationTest, two new facts for the shadow:Expected value to be true because the generation is still loaded and in use … but found False. Two more cover the finalizer path: a context nobody unloads runs the eviction from its finalizer, where a first draft threw and took a test host down.AReadAfterTheTypesHubLeftIsStillTypedTest(MeshWeaver.Graph.Test):Did not expect value to be of type JsonElement ….Added in the review round:
ATypeRegisteredAfterItsContextBeganUnloading_IsResolvableWhileHeld_AndNeverRooted: red with the late-registration routing disabled (the generation is never collected).BakeProbeReadsTheStoreThroughThePoolTest: the store read runs inside the I/O pool for in-process callers (NodeTypeBakeStatus.ProbeThrough); the control is the inline publicProbe.Controls inside the tests: a coherent record probes
Baked; an ordinary store miss is a regression baseline; a collected generation is still released (the pre-existing fact).Not established
Logsread says nothing.To verify after the roll
DynamicTypePreWarmer: N NodeType(s) claim a usable build … Rebuilding:Warning naming the fifteen with both MVIDs. Every later boot: thecould NOT be registeredWarning no longer names them. A type named on a second boot was re-broken by something live.GetStream: Content for Ops/Status/… stayed an untyped JsonElementon an outgoing replica between the first new replica's boot and its own shutdown line.No address needs a recycle: the heal rides the boot sweep.
Docs:
Doc/Architecture/DynamicContentTypeRegistration(two new sections),Doc/Architecture/StaleStateUntilRecycle(scenario 11).🤖 Generated with Claude Code