Skip to content

fix(messaging): snapshot rule chain — concurrent Remove NREs the dispatch walk (sync-hub flake) - #333

Merged
rbuergi merged 2 commits into
mainfrom
fix/messagehub-rulechain-race
Jul 6, 2026
Merged

rbuergi merged 2 commits into
mainfrom
fix/messagehub-rulechain-race

Conversation

@rbuergi

@rbuergi rbuergi commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

The flake

Sync-hub layout tests (`EditorTest.TestEditorWithDelayed`, `EditPersistenceTest.EditState_ShouldSurviveDataUpdates`, …) intermittently time out on CI — a different one each run. It turned main red once post-merge. Reproduced deterministically by running `MeshWeaver.Layout.Test` in bulk (whole project, one process): ~1 in 5 runs a sync-hub test fails.

Root cause

`MessageHub` dispatches a delivery by folding over its rule chain (`rules`, a `ThreadSafeLinkedList`). `HandleMessageAsync` walked the chain via the raw `LinkedListNode.Next`, outside the list's lock:

```csharp
current = current.Next; // MessageHub.cs — outside ThreadSafeLinkedList's lock
```

`ThreadSafeLinkedList` locks `Add`/`Remove`/`First`, but not this walk. When a handler-disposable's `rules.Remove(node)` fires concurrently (rapid sync-hub create/teardown churn under bulk load), `LinkedList.Remove → Invalidate` nulls the removed node's owning-list reference before its `next` pointer, so a racing `get_Next()` dereferences `list.head` → `NullReferenceException`. That fails the delivery → `DeliveryFailure` → the synchronization stream reports `[SYNC_STREAM] OnError` and propagates a `StreamErrorEvent` → the client subscriber's `.Within(Ns)` never matches → timeout. Which sync hub is being torn down at that instant decides which test flakes.

The fix

Iterate a snapshot taken under the read lock instead of walking live nodes:

  • `ThreadSafeLinkedList.Snapshot()` — `CopyTo` under the read lock.
  • `MessageHub.HandleMessageAsync` folds over the snapshot array; a concurrent `Remove` can no longer invalidate the iteration.

Snapshotting is also semantically correct: a delivery is handled by the rules present when dispatch began.

Verification

  • Bulk repro (`dotnet test test/MeshWeaver.Layout.Test` looped): ~1-in-5 failed with the NRE before; 9+ consecutive clean after.
  • Full-solution Release `-warnaserror` build (merged with main): green.

Note — a separate, rarer flake remains

`TestEditorWithDelayed`'s final `.Within(30s)` wait can still time out under thread-pool starvation: its projection does `Thread.Sleep(100)` (blocks a pool thread) inside `ThrottleImmediate(20ms).Select(result)`. Seen once under a heavily-loaded local box (whole run ballooned 13s→1m4s). This PR fixes the NRE (the likely CI red, since that test uses a sync hub); the thread-pool one is a separate follow-up if it recurs on CI.

🤖 Generated with Claude Code

…dispatch walk

MessageHub dispatches a delivery by folding over its rule chain (`rules`, a
ThreadSafeLinkedList<AsyncDelivery>). HandleMessageAsync walked the chain via the
raw LinkedListNode.Next OUTSIDE the list's lock. ThreadSafeLinkedList locks
Add/Remove/First but not that walk, so when a handler-disposable's rules.Remove(node)
fires concurrently (rapid sync-hub create/teardown churn under load),
LinkedList.Remove→Invalidate nulls the removed node's owning-list reference before
its next pointer, and a racing get_Next() dereferences list.head → NullReferenceException.
That fails the delivery → DeliveryFailure → the synchronization stream reports
[SYNC_STREAM] OnError and propagates a StreamErrorEvent → the client subscriber's
.Within(Ns) never matches → timeout. Because it depends on which sync hub is being
torn down at that instant, a different sync-hub layout test flakes each bulk run
(EditorTest.TestEditorWithDelayed, EditPersistenceTest.EditState_ShouldSurviveDataUpdates, …).

Fix: iterate a snapshot taken under the read lock instead of walking live nodes.
- ThreadSafeLinkedList.Snapshot() copies the values under the read lock.
- MessageHub.HandleMessageAsync folds over the snapshot array; a concurrent Remove
  can no longer invalidate the iteration. Also semantically correct: a delivery is
  handled by the rules present when dispatch began.

Repro: `dotnet test test/MeshWeaver.Layout.Test` in a loop — ~1-in-5 runs failed
with the NRE before, 9+ clean after.

Co-Authored-By: Claude Opus 4.8 (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.

Pull request overview

This PR fixes an intermittent CI timeout/flakiness in sync-hub layout tests by eliminating a concurrency race in MessageHub rule-chain dispatch: the rule chain is now iterated from a point-in-time snapshot taken under ThreadSafeLinkedList’s read lock, preventing concurrent Remove() from invalidating a live LinkedListNode.Next walk.

Changes:

  • Added ThreadSafeLinkedList<T>.Snapshot() to safely copy list values under a read lock.
  • Updated MessageHub.HandleMessageAsync to fold over a snapshot array rather than walking LinkedListNode.Next.
  • Kept/adjusted the existing guardrail by enforcing a maximum rule-chain length (500) based on the snapshot size.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/MeshWeaver.Messaging.Hub/ThreadSafeLinkedList.cs Adds a read-locked snapshot API to support safe iteration under concurrent mutations.
src/MeshWeaver.Messaging.Hub/MessageHub.cs Switches dispatch rule iteration to use the snapshot to avoid Remove()-vs-walk races and resulting NREs/timeouts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +756 to +758
// Snapshot the rule chain ONCE under the list's read lock (see HandleMessageAsync) so a
// concurrent rules.Remove during teardown can't NRE the iteration.
var ruleChain = rules.Snapshot();
@github-actions

github-actions Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

697 tests  ±0   695 ✅ ±0   1m 49s ⏱️ -1s
  9 suites ±0     2 💤 ±0 
  9 files   ±0     0 ❌ ±0 

Results for commit 5a4b6d7. ± Comparison against base commit 339c21f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

832 tests  +212   650 ✅ +212   3m 37s ⏱️ + 1m 52s
  9 suites ±  0   182 💤 ±  0 
  9 files   ±  0     0 ❌ ±  0 

Results for commit 5a4b6d7. ± Comparison against base commit 339c21f.

This pull request removes 5 and adds 217 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.CopyModifyCopyBackTest ‑ CopyModifyCopyBack_UpdatesOnlyDeltas
MeshWeaver.Hosting.Monolith.Test.GetDataRequestPropagationTest ‑ LocalUpdate_VisibleViaPolledGetDataRequest
MeshWeaver.Hosting.Monolith.Test.OverwritePropagationTest ‑ Overwrite_ReplacesFullNode_PropagatesViaOwner
MeshWeaver.Hosting.Monolith.Test.SpaceEditableTitleTest ‑ SpaceOverview_RendersClickToEditTitle_ForEditor
MeshWeaver.Hosting.Monolith.Test.WorkspaceUpdateMeshNodePropagationTest ‑ UpdateMeshNode_PropagatesToOwnSubscribers
MeshWeaver.Hosting.Monolith.Test.AgentChatClientNoSuitableAgentTest ‑ AgentChatClient_OnHostedSubHub_DoesNotReturn_NoSuitableAgent
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionTest ‑ ObserveAgents_FromHostedSubHub_PopulatesCombobox
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionTest ‑ ObserveAgents_FromMeshHub_PopulatesCombobox
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionTest ‑ ObserveAgents_SpaceAndUserSet_SurfacesSpaceAgent_UserAgent_AndPlatform
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionTest ‑ ObserveModels_FromHostedSubHub_PopulatesCombobox
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionTest ‑ ObserveModels_FromMeshHub_PopulatesCombobox
MeshWeaver.Hosting.Monolith.Test.CessionLayoutAreaTest ‑ BusinessRulesDoc_RelativeReference_ResolvesToMotorXL
MeshWeaver.Hosting.Monolith.Test.CessionLayoutAreaTest ‑ Cession_Trace_HubConfiguration
MeshWeaver.Hosting.Monolith.Test.CessionLayoutAreaTest ‑ MotorXL_LayoutArea_ReturnsContent
MeshWeaver.Hosting.Monolith.Test.CessionLayoutAreaTest ‑ MotorXL_Overview_ShouldRender
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

819 tests  ±0   819 ✅ ±0   3m 53s ⏱️ -3s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit 5a4b6d7. ± Comparison against base commit 339c21f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

   10 files  ±  0     10 suites  ±0   4m 35s ⏱️ -20s
1 223 tests  - 136  1 223 ✅  - 136  0 💤 ±0  0 ❌ ±0 
1 233 runs   - 165  1 233 ✅  - 165  0 💤 ±0  0 ❌ ±0 

Results for commit 5a4b6d7. ± Comparison against base commit 339c21f.

This pull request removes 136 tests.
MeshWeaver.Persistence.Test.ConcurrentRequestsTest ‑ ConcurrentRequests_MultipleNodeTypes_AllLoadWithoutHanging
MeshWeaver.Persistence.Test.DocumentationCodeBlockCompilationTest ‑ ExecutedCsharpBlocks_MustCompile(embeddedResourceName: "MeshWeaver.Documentation.Data.AI.AgenticAI.md")
MeshWeaver.Persistence.Test.DocumentationCodeBlockCompilationTest ‑ ExecutedCsharpBlocks_MustCompile(embeddedResourceName: "MeshWeaver.Documentation.Data.AI.ChatCommands.md")
MeshWeaver.Persistence.Test.DocumentationCodeBlockCompilationTest ‑ ExecutedCsharpBlocks_MustCompile(embeddedResourceName: "MeshWeaver.Documentation.Data.AI.ExecuteScript.md")
MeshWeaver.Persistence.Test.DocumentationCodeBlockCompilationTest ‑ ExecutedCsharpBlocks_MustCompile(embeddedResourceName: "MeshWeaver.Documentation.Data.AI.ExecutiveAssistan"···)
MeshWeaver.Persistence.Test.DocumentationCodeBlockCompilationTest ‑ ExecutedCsharpBlocks_MustCompile(embeddedResourceName: "MeshWeaver.Documentation.Data.AI.McpAuthentication"···)
MeshWeaver.Persistence.Test.DocumentationCodeBlockCompilationTest ‑ ExecutedCsharpBlocks_MustCompile(embeddedResourceName: "MeshWeaver.Documentation.Data.AI.ModelProviderSetu"···)
MeshWeaver.Persistence.Test.DocumentationCodeBlockCompilationTest ‑ ExecutedCsharpBlocks_MustCompile(embeddedResourceName: "MeshWeaver.Documentation.Data.AI.PlatformProviderS"···)
MeshWeaver.Persistence.Test.DocumentationCodeBlockCompilationTest ‑ ExecutedCsharpBlocks_MustCompile(embeddedResourceName: "MeshWeaver.Documentation.Data.AI.ProviderConfigura"···)
MeshWeaver.Persistence.Test.DocumentationCodeBlockCompilationTest ‑ ExecutedCsharpBlocks_MustCompile(embeddedResourceName: "MeshWeaver.Documentation.Data.AI.TeamsBot.md")
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 029 tests  ±0   1 028 ✅ ±0   3m 38s ⏱️ -15s
    8 suites ±0       1 💤 ±0 
    8 files   ±0       0 ❌ ±0 

Results for commit 5a4b6d7. ± Comparison against base commit 339c21f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

648 tests  ±0   648 ✅ ±0   5m 2s ⏱️ -7s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit 5a4b6d7. ± Comparison against base commit 339c21f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   56 files  ± 0     56 suites  ±0   22m 37s ⏱️ + 1m 8s
5 248 tests +76  5 063 ✅ +76  185 💤 ±0  0 ❌ ±0 
5 258 runs  +47  5 073 ✅ +47  185 💤 ±0  0 ❌ ±0 

Results for commit 5a4b6d7. ± Comparison against base commit 339c21f.

This pull request removes 141 and adds 217 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.CopyModifyCopyBackTest ‑ CopyModifyCopyBack_UpdatesOnlyDeltas
MeshWeaver.Hosting.Monolith.Test.GetDataRequestPropagationTest ‑ LocalUpdate_VisibleViaPolledGetDataRequest
MeshWeaver.Hosting.Monolith.Test.OverwritePropagationTest ‑ Overwrite_ReplacesFullNode_PropagatesViaOwner
MeshWeaver.Hosting.Monolith.Test.SpaceEditableTitleTest ‑ SpaceOverview_RendersClickToEditTitle_ForEditor
MeshWeaver.Hosting.Monolith.Test.WorkspaceUpdateMeshNodePropagationTest ‑ UpdateMeshNode_PropagatesToOwnSubscribers
MeshWeaver.Persistence.Test.ConcurrentRequestsTest ‑ ConcurrentRequests_MultipleNodeTypes_AllLoadWithoutHanging
MeshWeaver.Persistence.Test.DocumentationCodeBlockCompilationTest ‑ ExecutedCsharpBlocks_MustCompile(embeddedResourceName: "MeshWeaver.Documentation.Data.AI.AgenticAI.md")
MeshWeaver.Persistence.Test.DocumentationCodeBlockCompilationTest ‑ ExecutedCsharpBlocks_MustCompile(embeddedResourceName: "MeshWeaver.Documentation.Data.AI.ChatCommands.md")
MeshWeaver.Persistence.Test.DocumentationCodeBlockCompilationTest ‑ ExecutedCsharpBlocks_MustCompile(embeddedResourceName: "MeshWeaver.Documentation.Data.AI.ExecuteScript.md")
MeshWeaver.Persistence.Test.DocumentationCodeBlockCompilationTest ‑ ExecutedCsharpBlocks_MustCompile(embeddedResourceName: "MeshWeaver.Documentation.Data.AI.ExecutiveAssistan"···)
…
MeshWeaver.Hosting.Monolith.Test.AgentChatClientNoSuitableAgentTest ‑ AgentChatClient_OnHostedSubHub_DoesNotReturn_NoSuitableAgent
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionTest ‑ ObserveAgents_FromHostedSubHub_PopulatesCombobox
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionTest ‑ ObserveAgents_FromMeshHub_PopulatesCombobox
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionTest ‑ ObserveAgents_SpaceAndUserSet_SurfacesSpaceAgent_UserAgent_AndPlatform
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionTest ‑ ObserveModels_FromHostedSubHub_PopulatesCombobox
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionTest ‑ ObserveModels_FromMeshHub_PopulatesCombobox
MeshWeaver.Hosting.Monolith.Test.CessionLayoutAreaTest ‑ BusinessRulesDoc_RelativeReference_ResolvesToMotorXL
MeshWeaver.Hosting.Monolith.Test.CessionLayoutAreaTest ‑ Cession_Trace_HubConfiguration
MeshWeaver.Hosting.Monolith.Test.CessionLayoutAreaTest ‑ MotorXL_LayoutArea_ReturnsContent
MeshWeaver.Hosting.Monolith.Test.CessionLayoutAreaTest ‑ MotorXL_Overview_ShouldRender
…

♻️ This comment has been updated with latest results.

Hammers MessageHub dispatch while a background task disposes+re-registers rules
(each Dispose is a rules.Remove), over 300 round-trips on a 100-rule chain.
Proven: fails on the pre-fix raw-LinkedListNode.Next walk (the delivery NREs →
the response never lands → 10s timeout) and passes deterministically with the
snapshot fix. Addresses the Copilot review on #333.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rbuergi

rbuergi commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @copilot — added test/MeshWeaver.Messaging.Hub.Test/RuleChainConcurrentDisposeTest.cs in 5a4b6d7: it hammers dispatch (300 round-trips over a 100-rule chain) while a background task disposes+re-registers rules, i.e. the exact remove-during-dispatch shape. Proven both ways: it fails on the pre-fix raw-LinkedListNode.Next walk (the delivery NREs → the response never lands → 10s timeout) and passes deterministically (3/3, ~240ms) with the snapshot fix.

(FYI the shard-3 red on the first run was a foreign flake — MeshHostBuilderTeardownOrderingTest, unrelated to this dispatch change; passes locally with the fix. The new push re-runs CI clean.)

@rbuergi
rbuergi merged commit 9c98f8c into main Jul 6, 2026
16 checks passed
@rbuergi
rbuergi deleted the fix/messagehub-rulechain-race branch August 5, 2026 10:45
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