Repository navigation
Stack overflow in Htmlizer.RenderChildComponent during UpdateDisplayAsync under parallel tests (2.11.3) #1935
Description
Describe the bug
Under xUnit's default parallelism the test host intermittently dies with Stack overflow inside bUnit's markup serialisation (Htmlizer, reached from BunitRenderer.UpdateDisplayAsync), with zero failed assertions. The recursion is cyclic and unbounded, not a deep component tree (evidence in the analysis at the end). It reproduces on 2.11.3, the latest release, at the same rate as on 2.9.0.
We could not find a matching issue on 2.x (related ones are listed below), and we have ruled out every application-side explanation we could construct, which we hope saves you the same dead ends.
Reproduction steps. We have no minimal repro, and we would rather say so up front:
- Run a test suite large enough to keep xUnit's parallel window full. Ours is ~2,255 bUnit tests of MudBlazor-based components, on 24 logical cores, with xUnit's default parallelism.
- Repeat full-suite runs. Over roughly 620 runs the test host aborted in 6–12% of them (10 in 130 pooled over three independent campaigns, 7.7%), between 9% and 77% into the run (median 55%).
What we know about the conditions:
- Concurrency inside the test runner is required. With
maxParallelThreads: 1we saw 0 aborts in 45 runs; at an ~8% base rate that outcome has a probability of about 3%. - Machine load is not the variable. A quiet arm and an arm with the machine saturated by unrelated work gave 3/30 vs 2/30 aborts (Fisher ≈ 1.0), even though runs under load took 77% longer.
Results in this output:
Test host process crashed : Stack overflow.
Repeated 505 times:
--------------------------------
at Bunit.Htmlizer.RenderElement(HtmlRenderingContext, ArrayRange<RenderTreeFrame>, Int32)
at Bunit.Htmlizer.RenderCore(HtmlRenderingContext, ArrayRange<RenderTreeFrame>, Int32)
at Bunit.Htmlizer.RenderFrames(HtmlRenderingContext, ArrayRange<RenderTreeFrame>, Int32, Int32)
at Bunit.Htmlizer.RenderChildComponent(HtmlRenderingContext, ArrayRange<RenderTreeFrame>, Int32)
... (16-frame block, four RenderChildComponent per block)
--------------------------------
at Bunit.Htmlizer.GetHtml(Int32, BunitRenderer)
at Bunit.RenderedComponent`1.UpdateMarkup()
at Bunit.RenderedComponent`1.UpdateState(Boolean, Boolean)
at Bunit.Rendering.BunitRenderer.UpdateDisplayAsync(RenderBatch ByRef)
at Microsoft.AspNetCore.Components.RenderTree.Renderer.ProcessRenderQueue()
at Microsoft.AspNetCore.Components.RenderTree.Renderer.ProcessPendingRender()
at Bunit.Rendering.BunitRenderer.ProcessPendingRender()
at Microsoft.AspNetCore.Components.RenderTree.Renderer.AddToRenderQueue(Int32, RenderFragment)
at Microsoft.AspNetCore.Components.RenderHandle.Render(RenderFragment)
at Microsoft.AspNetCore.Components.ComponentBase.StateHasChanged()
at ...RendererSynchronizationContext.<InvokeAsync>g__Execute|8_0(...)
Expected behavior:
Markup serialisation either completes or fails with a diagnosable exception; it should not recurse without bound and take down the test host.
Version info:
- bUnit version: 2.11.3 (also reproduced on 2.9.0 at the same rate: 17 aborts in 190 runs vs 6 in 60 on 2.11.3, Fisher one-sided p = 0.70)
- .NET Runtime and Blazor version: .NET runtime 10.0.11; Blazor from NuGet,
Microsoft.AspNetCore.Components10.0.12 - OS type and version: Windows 11 Pro 25H2 (build 26200), x64, AMD Ryzen 9 3900X (12 cores / 24 threads)
- Also in play: xunit 2.9.3, xunit.runner.visualstudio 3.1.5, Microsoft.NET.Test.Sdk 17.14.1, MudBlazor 8.15.0
Additional context:
Related but, as far as we can tell, not the same:
- 1.19.14 dotnet test crashes when running multiple tests #1064: recursion in
TestRenderer.LoadRenderTreeFrames(1.19.14), a different method - Multi-threaded test execution appears to cause a race condition when using SetParametersAndRender #1188:
SynchronizationContextdiffering across threads - Investigate running test code and renderer code in the same thread/sync context #124: still open, the running-in-the-same-sync-context theme underneath
- Test host process crashed : Stack overflow. #494 (2021) and StackOverflow Exception when running tests with vstest.console.exe #1227 (2023) share the symptom, but neither reports the
Htmlizerrecursion underUpdateDisplayAsync, and both predate the 2.x rendering pipeline.
Evidence we hold: 24 retained occurrences carry the full stack (blame sequences with the in-flight test sets, TRX files), and we have 18 full crash dumps, 6 of them on 2.11.3. They come from a private codebase, so we would rather not share the raw files, but we are happy to run any analysis you suggest on them (e.g. dotnet-dump commands) and post the output. Likewise, if an instrumented build or a diagnostic switch would help, we can run it against our suite.
Detailed analysis: why this is cyclic recursion, the suspected mechanism, what we ruled out
The crash in more detail
Two entry points, reported as observed rather than normalised: of 18 logs whose frames we classified, 12 bottom out in Bunit.Htmlizer and 6 in Bunit.Rendering.BunitRenderer. In several the deepest frames are the runtime itself:
at System.Collections.Generic.Dictionary`2[[System.Int32, ...]].FindValue(Int32)
at Microsoft.AspNetCore.Components.RenderTree.Renderer.GetRequiredComponentState(Int32)
at Microsoft.AspNetCore.Components.RenderTree.Renderer.GetCurrentRenderTreeFrames(Int32)
That is the component-state lookup RenderChildComponent uses to resolve a child, which is where we believe the cycle closes.
Why we think this is cyclic recursion, not depth
- The runtime counts the cycle.
Repeated 505 timesover a 16-frame block descending through fourRenderChildComponentframes each, i.e. about 2,020 nested component levels. Elsewhere the same trace appears uncollapsed asRenderCore× 2540. - The heap says those components do not exist.
dotnet-dump analyze … dumpheap -staton a full crash dump shows no type anywhere near 2,020 live instances (largest: 988BunitRootComponent, 953MudChip<String>). A genuinely 2,020-deep tree would need ~2,020 nested instances.
So a larger thread stack is not a fix: it would move the failure from 505 repetitions to N.
Suspected mechanism (unconfirmed)
BunitRenderer.UpdateDisplayAsync calls UpdateState → UpdateMarkup → Htmlizer.GetHtml synchronously inside the batch, while _isBatchInProgress is still true, and RenderChildComponent resolves children through renderer.GetCurrentRenderTreeFrames(componentId), i.e. each component's current tree. Inside ProcessRenderQueue components are updated one at a time, so a mid-batch GetHtml sees some components updated and others stale. If a component is effectively re-parented across that boundary, the descent can re-enter an ancestor and recurse without bound.
The trigger is always an asynchronous StateHasChanged (RendererSynchronizationContext → ComponentBase.StateHasChanged → AddToRenderQueue → ProcessPendingRender). That fits the dependence on runner concurrency above, and explains why it does not reproduce in isolation.
We could not take it further from outside: clrstack on the faulting thread returns only a DebuggerU2MCatchHandlerFrame, because the runtime unwinds before the dump is taken, so the dump never yields the componentId chain.
What we ruled out
| hypothesis | how it was tested | result |
|---|---|---|
| A cycle in the component markup | full component graph: 350 components, 885 edges, all elementary cycles ≤ 6 | zero cycles |
Shared state in Htmlizer |
source review | statics are immutable constants; context and StringBuilder per call |
| Undisposed test contexts | all 726 BunitRenderer instances in one dump |
704 disposed; the 22 survivors are the live scheduling window |
| Mutable static state in our tests | full scan of the test project | none; the only two non-readonly statics allocate fresh per access |
| Machine load | 30 quiet vs 30 saturated runs | 3 vs 2 aborts (Fisher ≈ 1.0) |
| One guilty test class | A/B, 160 interleaved runs, class removed | still occurs without it (1 abort in 80) |
A specific RenderFragment in a popover |
80 runs with the fragment's content neutralised | rate does not drop (10/80 vs 5/80) |
| Component size / suite occupancy | ranked all 131 test classes | the heaviest classes sit exactly at chance |
| Fixed in a newer bUnit | upgraded 2.9.0 → 2.11.3, 60 runs | 6 aborts (10.0%), unchanged, same stack |
One test class is strongly associated: in flight for 26 of the 28 occurrences we analysed, against 12.0 expected once the expectation is conditioned on when crashes happen (they cluster mid-run, so an unconditioned model over-credits any class straddling the middle). But it is not necessary: removing it cuts the rate roughly fivefold without reaching zero. We read that as a component that hits the window more often, not as a cause.
Activity