Skip to content

fix(rx): owned-connection releases go through one serial lane — concurrent terminals deadlocked teardown - #5660

Merged
meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/owned-release-serial-lane
Sep 24, 2026
Merged

meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/owned-release-serial-lane

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

The failure

MeshWeaver.Plugins main red on set 3.0.0-ci.9296 (core 8d3fd9f): LayoutAreaIdentityTest.AuthorizedUser_CanSubscribe_ToLayoutArea (run 35985114586) and GitHubSyncSettingsTabTest.GitHubSyncTab_ShownOnSpace (run 35985209459) "timed out" although the test body passed and Mesh.Dispose() completed clean. The CI trace shows DISPOSE_DONE and then no DISPOSE_UNLOADS_* line ever — the hang is in the test base's ServiceProvider.Dispose().

The exact cause

Reproduced locally (1 in 12 full MeshWeaver.Security.Test runs under the MTP runner) and captured with dotnet-stack:

#5647 scheduled each connection's release as its own TaskPoolScheduler work item. An owner registry is released in ONE sweep (_queryConnections.Dispose()), so N connections produced N CONCURRENT terminals into consumers that compose several of them; Rx combinators dispose their sources under their gate on a terminal, so two concurrent terminals entering a nested Zip/CombineLatest from opposite ends invert the lock order.

Not the #5635 hypothesis: no wait on StreamEndedEvent is involved.

The fix

ReleaseLane (Messaging.Hub): releases posted to one lane run on the ThreadPool (still never on the disposing turn, #4530's trade kept) ONE AT A TIME, in order — Subject.Synchronize + ObserveOn(TaskPoolScheduler). The second terminal reaches a consumer the first already tore down.

  • The mesh root hub registers one lane (same pattern as AccessService); every hub resolves it, so the hub overload of AutoConnectOwnedBy uses the mesh lane.
  • MeshNodeStreamCache passes the mesh lane for all query connections; PackageListingCache / GitHubRepoIdentityResolver hold one lane per registry.
  • New overload AutoConnectOwnedBy(source, owner, lane, ownerName, minObservers); the lane-less CompositeDisposable overload stays as an [Obsolete] forwarder (nothing removed; Plugins has no caller).

Verification

  • OwnedConnectionTest.ReleasingTwoConnectionsAConsumerComposes_NeverDeliversTheirTerminalsConcurrently forces the two releases to meet inside their first gates. Negative control: Release restored to one TaskPool work item per connection → red (teardown never finishes, 36 s wait fails). With the lane → green. Whole OwnedConnectionTest class 11/11.
  • dotnet build -c Release -warnaserror clean: Messaging.Hub.Test, Hosting, PluginCatalog (pulls GitSync), Documentation.Test; Documentation.Test guards 628/628.
  • Plugins MeshWeaver.Security.Test built against this branch and looped under MTP (results in the PR thread).

Docs: Architecture/HubDisposalModel → "Those terminals are delivered one at a time".

Nothing a per-node hub serves changes; no recycle needed. No i18n change. No public member removed; no interface member added.

🤖 Generated with Claude Code

…rrent terminals deadlocked the permission fold in teardown

#5647 (#5135) made an owner's release terminate every subscriber still attached, but scheduled
each connection's terminal as its own TaskPool work item. An owner registry is released in ONE
sweep (MeshNodeStreamCache disposes every query connection; a hub's ShutDown every registration),
so N connections produced N concurrent terminals. The permission fold composes several cache
queries under a Zip nested in a CombineLatest; Rx takes a combinator's gate on a terminal and
disposes its other sources under it, so one release held the CombineLatest gate waiting for the
Zip gate while another held the Zip gate waiting for the CombineLatest gate. The SP disposal then
parked behind them (UiContributionCatalog.Dispose -> BehaviorSubject.OnCompleted -> the same
CombineLatest), and Plugins teardowns timed out after a passing body (LayoutAreaIdentityTest,
GitHubSyncSettingsTabTest on set 3.0.0-ci.9296). Reproduced locally 1 in 12 MTP runs of
MeshWeaver.Security.Test with a stack capture of exactly those threads.

ReleaseLane: releases posted to one lane run on the ThreadPool one at a time, in order. The mesh
root registers one (like AccessService) and every hub resolves it; MeshNodeStreamCache uses the
mesh lane; registries outside a hub hold one lane for all their connections. The lane-less
CompositeDisposable overload is [Obsolete] (it mints a lane per connection).

Test: ReleasingTwoConnectionsAConsumerComposes_NeverDeliversTheirTerminalsConcurrently forces the
two releases to meet inside their first gates — red with one work item per release (negative
control run by hand), green on a shared lane.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 24, 2026 12:21

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.

Copilot review overview

🟡 Changes recommended

Unresolved critical and moderate findings remain in release-lane error handling, hub lane access, and regression-test synchronization.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

This PR serializes owned-connection release notifications to prevent Rx teardown deadlocks.

Changes:

  • Adds mesh- and registry-scoped ReleaseLane.
  • Routes owned connections and caches through shared lanes.
  • Adds regression coverage and documents release ordering.
File Summary
test/​MeshWeaver.Messaging.Hub.Test/​OwnedConnectionTest.cs Adds concurrent-release regression coverage.
src/​MeshWeaver.PluginCatalog/​PackageListingCache.cs Uses a registry-specific release lane.
src/​MeshWeaver.Messaging.Hub/​ReleaseLane.cs Implements serialized release delivery.
src/​MeshWeaver.Messaging.Hub/​OwnedConnectionExtensions.cs Adds lane-aware ownership APIs.
src/​MeshWeaver.Messaging.Hub/​MessageHubConfiguration.cs Registers the mesh-scoped lane.
src/​MeshWeaver.Hosting/​MeshNodeStreamCache.cs Uses the mesh release lane.
src/​MeshWeaver.GitSync/​GitHubRepoIdentityResolver.cs Uses registry-scoped release ordering.
src/​MeshWeaver.Documentation/​Data/​Architecture/​HubDisposalModel.md Documents serialized release behavior.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +53 to +55
subject
.ObserveOn(TaskPoolScheduler.Default)
.Subscribe(release => release());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — fixed in 3b130f3. ReleaseLane.Run now wraps each release: if a subscriber's error continuation throws, the exception is captured and re-raised on a ThreadPool work item of its own (ExceptionDispatchInfo.Throw via ThreadPool.UnsafeQueueUserWorkItem), which is exactly where it surfaced when every release was its own work item — so it stays as loud as before (an unhandled ThreadPool exception), and the lane's ObserveOn observer survives and drains the releases queued behind it. There is no existing scheduler error-boundary helper in src/ to route through; this keeps the pre-existing failure semantics rather than swallowing.

Comment on lines +201 to +202
Volatile.Write(ref aHoldsCombineLatestGate, 1);
SpinWait.SpinUntil(() => Volatile.Read(ref bHoldsZipGate) == 1, meetBudget);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point on the green path — changed in 3b130f3. The wait is kept, but as an OBSERVATION window rather than synchronisation: its result is now recorded and asserted (bMetAWhileItHeldItsGate must be 0 — B's release never entered the consumer while A's held the CombineLatest gate — and -1 would mean A's hook never ran). That is the positive statement of the serialised contract, alongside the existing sourcesReleased assertion that A's teardown actually finished. The bounded meeting is what makes the NEGATIVE control deterministic (with one TaskPool work item per release the two releases meet inside their first gates and deadlock every time — run by hand: red at the sourcesReleased wait); a serialised lane cannot let the second release start while the first runs, so by construction any test that forces the concurrent interleaving has to observe "it never came" over some window on the green path. The 2 s window costs 2 s once, in one test.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

  1 files  ±0    1 suites  ±0   3m 4s ⏱️ +3s
342 tests ±0  342 ✅ ±0  0 💤 ±0  0 ❌ ±0 
346 runs  ±0  346 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 3b130f3. ± Comparison against base commit 3d1dc77.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

550 tests   - 1 098   550 ✅  - 1 098   1m 5s ⏱️ - 2m 13s
  1 suites  -     1     0 💤 ±    0 
  1 files    -     1     0 ❌ ±    0 

Results for commit 3b130f3. ± Comparison against base commit 3d1dc77.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

430 tests   - 13   430 ✅  - 13   1m 7s ⏱️ +12s
  2 suites  -  1     0 💤 ± 0 
  2 files    -  1     0 ❌ ± 0 

Results for commit 3b130f3. ± Comparison against base commit 3d1dc77.

♻️ This comment has been updated with latest results.

…t asserts B never overlapped A's hold

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

    2 files   -   1      2 suites   - 1   6m 28s ⏱️ +13s
2 617 tests +605  2 617 ✅ +605  0 💤 ±0  0 ❌ ±0 
2 618 runs  +605  2 618 ✅ +605  0 💤 ±0  0 ❌ ±0 

Results for commit 3b130f3. ± Comparison against base commit 3d1dc77.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 613 tests  +871   1 613 ✅ +1 063   9m 16s ⏱️ + 2m 11s
    2 suites  -   1       0 💤  -   192 
    2 files    -   1       0 ❌ ±    0 

Results for commit 3b130f3. ± Comparison against base commit 3d1dc77.

@rbuergi

rbuergi commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

Plugins-side verification (MeshWeaver.Plugins src/MeshWeaver.Security.Test at head 66b77fd6e, built with -p:MeshWeaverRoot= this branch, run via dotnet test under the MTP runner, MESHWEAVER_TEST_FILE_LOGS=1 CI=true, full assembly of 383 tests per run, macOS arm64):

  • core 8d3fd9f (the set-9296 core, WITHOUT the fix): 1 of 12 runs hung — LayoutAreaIdentityTest.AuthorizedUser_CanSubscribe_ToLayoutArea "failed (canceled) 10s", the same test and shape as CI run 35985114586; dotnet-stack of the testhost showed the Release threads deadlocked on CombineLatest/Zip gates and AutofacServiceProvider.Dispose → UiContributionCatalog.Dispose parked behind them.
  • this branch (fix, first commit be5aecd): 36 of 36 runs clean, 383/383 each.

(The native xunit runner did not reproduce it in 60+ runs on macOS and in a 2-CPU linux/arm64 container; only the MTP runner — what CI uses — did.)

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    5 files  ±  0      5 suites  ±0   14m 50s ⏱️ -43s
3 431 tests  - 647  3 431 ✅  - 645  0 💤  - 2  0 ❌ ±0 
3 435 runs   - 647  3 435 ✅  - 645  0 💤  - 2  0 ❌ ±0 

Results for commit 3b130f3. ± Comparison against base commit 3d1dc77.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   13 files   -   4     13 suites   - 4   35m 52s ⏱️ -18s
8 983 tests  - 282  8 983 ✅  - 88  0 💤  - 194  0 ❌ ±0 
8 992 runs   - 282  8 992 ✅  - 88  0 💤  - 194  0 ❌ ±0 

Results for commit 3b130f3. ± Comparison against base commit 3d1dc77.

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