Skip to content

A test's Catch(...).Repeat() was a 54 kHz busy-wait that killed the whole test host (#2341) - #2390

Merged
rbuergi merged 2 commits into
mainfrom
fix/2341-unthrottled-resubscribe-busy-wait
Aug 26, 2026
Merged

rbuergi merged 2 commits into
mainfrom
fix/2341-unthrottled-resubscribe-busy-wait

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Closes #2341.

MeshWeaver.Threading.Test died with exit=124 TIMEOUT (8m wall-clock cap hit) and no .trx — 12 times in ~15 h across five branches AND main (first 2026-08-25T23:24Z, latest 2026-08-26T12:35Z; 400 runs scanned back to 2026-08-22, none before). It reddened shards on PRs whose diffs never touched the threading library.

The issue's hypothesis is disproved, with evidence

#2341 named the two static ManualResetEventSlim signals in that assembly, on the theory that a signal set by one test makes a later test's guard vacuous. That cannot happen: ParkingChatClient.Entered and ParkedDelegationFactory.DelegationEntered are each referenced only from inside their own single-[Fact] class, so each is Set() and Wait()ed exactly once per process, by the same test; both waits are bounded (Wait(30 s/60 s, ct)); and in every wedged run's phase trace those two drain tests complete in 0.7–2 s. They are still wrong per AGENTS.md's no-static-state rule — but they are not this defect, and this PR leaves them alone.

What it actually is

The failing shards' own artifact carries the answer. In collected-logs/_meshweaver-test-trace.log the wedged host logs 112 INIT_DONE and 111 DISPOSE_DONE — exactly one test method entered and never returned — and in all three failing runs whose artifacts survive it is the same one: SubThreadHangRepro. Everything else finishes ~36 s in; the process then looks idle for 7.5 min until CI kills it.

Reproduced locally 2 attempts out of 2 by running six hosts of the assembly at once under DOTNET_PROCESSOR_COUNT=4 (12 sequential single-host runs on the same box were all green — the repro needs CPU contention). Both local wedges show the same trace imbalance, the same class, and one thread at 100 % CPU whose managed stack names the defect:

SubThreadHangRepro.<HungSubThread_WithoutUserCancel_StaysExecuting>d__4.MoveNext()
  ObservableAssertions.Emit -> WaitForFirst -> ToTask(...)          <- SYNCHRONOUS subscribe
    Timeout.Relative.Run -> ToList.Run -> Take.Count.Run
      ObservableAssertions.SubscribeHereOffSyncContext.b__0
        Do.OnNext.Run -> Concat.Run -> TailRecursiveSink.Drain()    <- Repeat()'s engine, spinning
          Producer.SubscribeRaw (Catch) -> Producer.SubscribeRaw (Defer) -> Defer.Run()
            SubThreadHangRepro+<>c__DisplayClass4_0.b__6()          <- the Defer lambda
              AutofacServiceProvider.GetService -> ResolveOperation.ExecuteOperation

The site:

Observable.Defer(() => workspace.GetMeshNodeStream(subThreadPath) ... .Take(1))
    .Catch<MeshThread?, Exception>(_ => Observable.Empty<MeshThread?>().Delay(200.Milliseconds()))
    .Repeat()

Catch intercepts OnError only. A point read of the not-yet-created sub-thread node completes without emitting, so the 200 ms backoff never runs and Repeat re-subscribes immediately. Measured with a temporary probe on the real repro: 460 000 re-subscribes in 8.45 s (~54 kHz) and ZERO OnError — the delay everyone believed was there had never executed once, on any run.

Two things turn that from wasteful into fatal:

  • Rx subscribes synchronously, so the loop runs inside the assertion's own .ToTask() call and the await is never reached. That puts it out of reach of every timeout the harness has: .Within(15 s), [Fact(Timeout)], methodTimeout, and MonolithMeshTestBase's hard-deadline watchdog all need the method to return or park at an await. Nothing can fail this test.
  • It starves what it waits for. The spin pins a core and hammers Autofac + the node-stream cache; on a 4-vCPU runner that is a quarter of the machine plus lock traffic against the very hub that must create the sub-thread node. The condition stops arriving, so the loop that prevents it cannot end. On an idle 18-core box the node lands after ~170 ms and it exits — which is why this stayed invisible locally for the seven weeks the construct has existed.

Why it started on 2026-08-25: the construct dates from 2026-07-02 and its comment records the then-true behaviour ("GetMeshNodeStream surfaces missing satellite as OnError almost immediately"). The absent-node answer is now an empty completion, which silently made the Catch guard vacuous and flipped a 200 ms poll into a 54 kHz spin. Nothing in src/ shares the shape — its only two .Repeat() sites are over sources that carry their own cadence.

The fix

Both waits move to the composition AGENTS.md prescribes for a node that may not exist yet: the keyed GetQuery listing for existence (empty-on-absent, live — the index trails the store, so "the index has seen it" implies "the store has it"), then the owner's stream for content. No polling, no retry, no resubscribe; every wait is bounded by the assertion's own timeout again because nothing blocks the subscribe. The n.Content as MeshThread trap-door casts on the touched lines become ContentAs<MeshThread>(...).

SubThreadColdStartRepro and SubThreadStallRepro carried the same construct in two more places (same assembly, same delegation wait) and are fixed identically — otherwise the wedge simply moves.

The control

UnthrottledResubscribeInTestRatchetGuard refuses an argument-less .Repeat() / .Retry() anywhere under test/. Its allow file is deliberately empty: the three sites that existed are the three fixed here. .Repeat(count) / .Retry(count) / .RetryWhen(handler) are not matched — a bounded count terminates, and RetryWhen is the sanctioned replacement (OnError only, its own timer, bounded).

Proved non-vacuous two ways in the same run: a fixture asserts the scanner counts the shape in code and ignores it in comments and string literals (load-bearing — the guard's own prose quotes .Repeat() repeatedly), and it was mutation-tested by reintroducing one call site, which turns it red with NEW SITE.

Verification

result
BEFORE — 6 concurrent hosts, DOTNET_PROCESSOR_COUNT=4 2 wedges in 2 rounds; stack + full dump captured for both
BEFORE — 12 sequential single-host runs, same box all green (the repro needs contention)
BEFORE — CI 12 × exit=124 in the last 400 runs
AFTER — 12 rounds × 6 concurrent hosts = 72 full-assembly runs 0 wedges
MeshWeaver.Threading.Test 139/139 green
MeshWeaver.Documentation.Test 64/64 green (both ratchets + DocumentationLinkIntegrityTest)
dotnet build -c Release -p:CIRun=true -warnaserror clean on both touched test projects

Follow-up worth a separate look (not changed here)

MeshNodeStreamCache's cached entry tracks a terminal OnError (MarkFaulted → re-probe) but not a terminal OnCompleted, and the absent-node read now answers with an empty completion rather than the NotFound error the 2026-07 comment assumed. No other caller depends on that assumption today (checked: the only .Repeat() sites in src/ are cadence-carrying), so this PR does not touch it — but it is the behaviour change that made the guard above vacuous, and worth a decision of its own.

🤖 Generated with Claude Code

…hole test host (#2341)

`MeshWeaver.Threading.Test` died with `exit=124 TIMEOUT (8m wall-clock cap hit)` and no `.trx` —
12 times in ~15 h across five branches AND `main` (first 2026-08-25T23:24Z, latest
2026-08-26T12:35Z; 400 runs scanned back to 2026-08-22, none before). It reddened shards on PRs
whose diffs never touch the threading library.

## The standing hypothesis is DISPROVED, with evidence

#2341 proposed the two static `ManualResetEventSlim` signals in that assembly as the cause, on the
theory that a signal set by one test makes a later test's guard vacuous. That cannot happen:
`ParkingChatClient.Entered` and `ParkedDelegationFactory.DelegationEntered` are each referenced ONLY
from inside their own single-`[Fact]` class, so each is `Set()` and `Wait()`ed exactly once per
process by the same test; both waits are bounded (`Wait(30 s/60 s, ct)`); and in every wedged run's
phase trace those two drain tests complete in 0.7–2 s. They remain wrong per AGENTS.md's
no-static-state rule, but they are not this defect and are left alone here.

## What it actually is

The failing shards' own artifact carries the answer. In `_meshweaver-test-trace.log`, the wedged
host logs 112 `INIT_DONE` and 111 `DISPOSE_DONE`: exactly ONE test method is entered and never
returns, and in all three failing runs whose artifacts survive it is the same one —
`SubThreadHangRepro`. Everything else in the assembly finishes ~36 s in; the process then sits
idle-looking for 7.5 min until CI's `timeout 8m` kills it.

Reproduced locally 2 attempts out of 2 by running six hosts of the assembly concurrently under
`DOTNET_PROCESSOR_COUNT=4` (12 sequential single-host runs on the same box were all green — the
repro needs CPU contention). Both local wedges show the identical trace imbalance, the identical
class, and one thread at 100 % CPU. Its managed stack names the defect outright:

    SubThreadHangRepro.<HungSubThread_WithoutUserCancel_StaysExecuting>d__4.MoveNext()
      ObservableAssertions.Emit -> WaitForFirst -> ToTask(...)        <- SYNCHRONOUS subscribe
        Timeout.Relative.Run -> ToList.Run -> Take.Count.Run
          ObservableAssertions.SubscribeHereOffSyncContext.b__0
            Do.OnNext.Run -> Concat.Run -> TailRecursiveSink.Drain()  <- Repeat()'s engine, spinning
              Producer.SubscribeRaw (Catch) -> Producer.SubscribeRaw (Defer) -> Defer.Run()
                SubThreadHangRepro+<>c__DisplayClass4_0.b__6()        <- the Defer lambda
                  AutofacServiceProvider.GetService -> ResolveOperation.ExecuteOperation

The site is

    Observable.Defer(() => workspace.GetMeshNodeStream(subThreadPath) ... .Take(1))
        .Catch<MeshThread?, Exception>(_ => Observable.Empty<MeshThread?>().Delay(200.Milliseconds()))
        .Repeat()

`Catch` intercepts **OnError only**. A point read of the not-yet-created sub-thread node COMPLETES
without emitting, so the 200 ms backoff never runs and `Repeat` re-subscribes immediately. Measured
with a temporary probe on the real repro: **460 000 re-subscribes in 8.45 s (~54 kHz) and ZERO
OnError** — the delay everyone believed was there had never executed once, on any run.

Two things turn that from "wasteful" into "kills the host":

* **Rx subscribes synchronously.** The loop runs inside the assertion's own `.ToTask()` call, so the
  `await` is never reached — which puts it out of reach of EVERY timeout the harness has:
  `.Within(15 s)`, `[Fact(Timeout)]`, `methodTimeout`, and `MonolithMeshTestBase`'s hard-deadline
  watchdog all need the method to return or to park at an await. Nothing can fail this test.
* **It starves what it waits for.** The spin pins a core and hammers Autofac + the node-stream cache.
  On a 4-vCPU runner that is a quarter of the machine plus lock traffic against the very hub that has
  to create the sub-thread node — so the condition the loop waits for stops arriving, and the loop
  that prevents it cannot end. On an idle 18-core box the node lands after ~170 ms and it exits,
  which is why this was invisible locally for the seven weeks the construct has existed.

Why it started on 2026-08-25 and not in July: the construct dates from 2026-07-02 and its comment
records the then-true behaviour ("GetMeshNodeStream surfaces missing satellite as OnError almost
immediately"). The absent-node answer is now an empty COMPLETION, which silently made the `Catch`
guard vacuous. Nothing in `src/` shares the shape — the only two `.Repeat()` sites there are over
sources that carry their own cadence.

## The fix

Both waits move to the composition AGENTS.md prescribes for a node that may not exist yet: the keyed
`GetQuery` listing for EXISTENCE (empty-on-absent, live — the index trails the store, so "the index
has seen it" implies "the store has it"), then the owner's stream for CONTENT. No polling, no retry,
no resubscribe: purely event-driven, and every wait is bounded by the assertion's own timeout again
because nothing blocks the subscribe. The `n.Content as MeshThread` trap-door casts on the touched
lines become `ContentAs<MeshThread>(...)` at the same time.

`SubThreadColdStartRepro` and `SubThreadStallRepro` carried the SAME construct in two more places
(same assembly, same delegation wait) and are fixed identically — otherwise the wedge simply moves.

## The control

`UnthrottledResubscribeInTestRatchetGuard` refuses an argument-less `.Repeat()` / `.Retry()` in
`test/**`. Its allow file is deliberately EMPTY: the three sites that existed are the three fixed
here. `.Repeat(count)` / `.Retry(count)` / `.RetryWhen(handler)` are not matched — a bounded count
terminates and `RetryWhen` is the sanctioned replacement (OnError only, its own timer).

The guard is proved non-vacuous two ways in the same run: a fixture asserts the scanner counts the
shape in CODE and ignores it in comments and string literals (load-bearing — the guard's own prose
quotes `.Repeat()` repeatedly), and it was mutation-tested by reintroducing one call site, which
turns it red with `NEW SITE`.

## Verification

* BEFORE: 2 wedges in 2 harness rounds (6 concurrent hosts, `DOTNET_PROCESSOR_COUNT=4`); stack +
  full dump captured for both; 12 CI occurrences in the last 400 runs.
* AFTER: 12 rounds x 6 concurrent hosts = 72 host runs of the full assembly, ZERO wedges.
* `MeshWeaver.Threading.Test` 139/139 green; `MeshWeaver.Documentation.Test` 64/64 green
  (includes both ratchets and `DocumentationLinkIntegrityTest`).
* `dotnet build -c Release -p:CIRun=true -warnaserror` clean on both touched test projects.

Closes #2341

Co-Authored-By: Claude Opus 5 (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 addresses CI-host wedges caused by unthrottled Rx resubscribe loops in delegation sub-thread tests, where a missing-node read completed (no OnError) and .Catch(...).Repeat() turned into a tight synchronous busy-wait. It replaces the polling pattern with the repo’s canonical “existence via synced query, then content via owner stream” composition and adds a governance ratchet to prevent reintroducing the defect class.

Changes:

  • Replace Defer(...).Catch(...).Repeat() delegation sub-thread waits with GetQuery(...select:path) existence gating followed by GetMeshNodeStream(...) content reads.
  • Switch touched content reads from node.Content as T to ContentAs<T>(...) at the updated wait sites.
  • Add a new test ratchet (UnthrottledResubscribeInTestRatchetGuard) plus an empty allow-file to block any future argument-less .Repeat() / .Retry() usage under test/.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
test/UnthrottledResubscribeSites.allow New (deliberately empty) allow-file for the unthrottled-resubscribe ratchet.
test/MeshWeaver.Threading.Test/SubThreadHangRepro.cs Replaces the unthrottled resubscribe wait with existence-gated query + owner stream read; switches to ContentAs.
test/MeshWeaver.Threading.Test/SubThreadColdStartRepro.cs Applies the same existence-gated wait pattern and ContentAs usage at the affected sites.
test/MeshWeaver.Documentation.Test/UnthrottledResubscribeInTestRatchetGuard.cs New governance ratchet that scans test/ for argument-less .Repeat() / .Retry() in code (masking comments/strings).
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-26-test-suite-busy-wait.md “What’s New” entry documenting the fix and the new build guard.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

    9 files  ±0      9 suites  ±0   5m 21s ⏱️ -20s
1 960 tests ±0  1 958 ✅ ±0  2 💤 ±0  0 ❌ ±0 
2 518 runs  +1  2 516 ✅ +1  2 💤 ±0  0 ❌ ±0 

Results for commit fd68d8f. ± Comparison against base commit f494f65.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

599 tests  ±0   595 ✅ ±0   14m 21s ⏱️ - 2m 4s
  7 suites ±0     4 💤 ±0 
  7 files   ±0     0 ❌ ±0 

Results for commit fd68d8f. ± Comparison against base commit f494f65.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    9 files  ±0      9 suites  ±0   6m 53s ⏱️ -8s
1 611 tests ±0  1 610 ✅ ±0  1 💤 ±0  0 ❌ ±0 
1 612 runs  ±0  1 611 ✅ ±0  1 💤 ±0  0 ❌ ±0 

Results for commit fd68d8f. ± Comparison against base commit f494f65.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

2 050 tests  +2   1 857 ✅ +2   8m 20s ⏱️ -6s
    9 suites ±0     193 💤 ±0 
    9 files   ±0       0 ❌ ±0 

Results for commit fd68d8f. ± Comparison against base commit f494f65.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

2 142 tests  ±0   2 140 ✅ ±0   14m 47s ⏱️ + 2m 2s
   10 suites ±0       2 💤 ±0 
   10 files   ±0       0 ❌ ±0 

Results for commit fd68d8f. ± Comparison against base commit f494f65.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

2 832 tests  ±0   2 729 ✅ ±0   9m 13s ⏱️ -5s
    9 suites ±0     103 💤 ±0 
    9 files   ±0       0 ❌ ±0 

Results for commit fd68d8f. ± Comparison against base commit f494f65.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    53 files  ±0      53 suites  ±0   58m 57s ⏱️ -41s
11 192 tests +2  10 887 ✅ +2  305 💤 ±0  0 ❌ ±0 
11 753 runs  +3  11 448 ✅ +3  305 💤 ±0  0 ❌ ±0 

Results for commit fd68d8f. ± Comparison against base commit f494f65.

♻️ This comment has been updated with latest results.

@rbuergi

rbuergi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

The first CI run was red on something this PR cannot cause — evidence, not assertion

Run tests (shard 2) failed with MeshWeaver.Hosting.Monolith.Test (part 2/2) exit=124 TIMEOUT. That is a different assembly from the one this PR fixes, and a different mechanism.

This PR's own assembly passed, in the same run, on the first attempt:

MeshWeaver.Threading.Test  Total: 139, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0, Time: 41.650s
[CI] MeshWeaver.Threading.Test exit=0

The failure is a different defect. Applying the same phase-trace technique to shard 2's artifact: pid 3520 logs 92 INIT_DONE against 91 DISPOSE_DONE, and the unbalanced class is MeshNodeLanguageServiceTest. It hangs in teardown, not in the test body — DISPOSE_START and DISPOSE_INVOKED both fired, DISPOSE_DONE never did — which is the opposite half of the lifecycle from #2341 (where the test method never returns and DISPOSE_START is never reached). teardown-stragglers.log names the straggler:

System.ObjectDisposedException: Instances cannot be resolved ... has already been disposed.
   at Autofac.Extensions.DependencyInjection.AutofacServiceProvider.GetService(Type)
   at MeshWeaver.Graph.MeshDataSourceExtensions.FlushPendingOwnSave(...)  MeshDataSource.cs:1659
   at MeshWeaver.Messaging.MessageHub.DisposeImpl()                       MessageHub.cs:1853

Both frames are framework teardown code this PR does not touch, and main has not changed either file since this branch's base.

It is a live, branch-independent flake, and it predates this branch. The byte-identical marker on two other runs:

run branch when
32939560960 main 2026-08-26T06:45Z
32965944797 refactor/test-suites-drop-ai-batch 2026-08-26T11:56Z

The second is ~2 h before this branch existed and after #2335 merged (08:58Z), so it is a post-fix recurrence of the Hosting.Monolith.Test wedge rather than residue #2335 already closed. It deserves its own issue; it is not in scope here.

What I did about it — the step AGENTS.md prescribes for a red outside the diff, not a bare re-run: merged current main (the branch was 4 merges behind) so CI re-samples on a current base. Revert-checked with git diff origin/main...HEAD --stat — still exactly the five intended files — and re-verified locally after the merge: dotnet build -c Release -p:CIRun=true -warnaserror clean on both touched test projects, MeshWeaver.Documentation.Test 64/64 green.

I will not merge this PR on a red or pending suite.

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.

MeshWeaver.Threading.Test wedged with exit=124 and is still unexplained — two static ManualResetEventSlim signals the #2013 sweep reported as absent

2 participants