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: 1 we 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.Components 10.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:
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 times over a 16-frame block descending through four RenderChildComponent frames each, i.e. about 2,020 nested component levels. Elsewhere the same trace appears uncollapsed as RenderCore × 2540.
- The heap says those components do not exist.
dotnet-dump analyze … dumpheap -stat on a full crash dump shows no type anywhere near 2,020 live instances (largest: 988 BunitRootComponent, 953 MudChip<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.
Describe the bug
Under xUnit's default parallelism the test host intermittently dies with
Stack overflowinside bUnit's markup serialisation (Htmlizer, reached fromBunitRenderer.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:
What we know about the conditions:
maxParallelThreads: 1we saw 0 aborts in 45 runs; at an ~8% base rate that outcome has a probability of about 3%.Results in this output:
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:
Microsoft.AspNetCore.Components10.0.12Additional context:
Related but, as far as we can tell, not the same:
TestRenderer.LoadRenderTreeFrames(1.19.14), a different methodSynchronizationContextdiffering across threadsHtmlizerrecursion 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-dumpcommands) 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.Htmlizerand 6 inBunit.Rendering.BunitRenderer. In several the deepest frames are the runtime itself:That is the component-state lookup
RenderChildComponentuses to resolve a child, which is where we believe the cycle closes.Why we think this is cyclic recursion, not depth
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.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.UpdateDisplayAsynccallsUpdateState→UpdateMarkup→Htmlizer.GetHtmlsynchronously inside the batch, while_isBatchInProgressis still true, andRenderChildComponentresolves children throughrenderer.GetCurrentRenderTreeFrames(componentId), i.e. each component's current tree. InsideProcessRenderQueuecomponents are updated one at a time, so a mid-batchGetHtmlsees 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:
clrstackon the faulting thread returns only aDebuggerU2MCatchHandlerFrame, because the runtime unwinds before the dump is taken, so the dump never yields thecomponentIdchain.What we ruled out
HtmlizerStringBuilderper callBunitRendererinstances in one dumpreadonlystatics allocate fresh per accessRenderFragmentin a popoverOne 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.