Description of the bug
BunitHtmlParser keeps its parsed documents in a plain, non-thread-safe List<IDocument>, and Dispose() enumerates that list directly while Parse() can still be adding to it from another thread. The result is an intermittent teardown failure that names an innocent test and carries no assertion from the test itself:
System.InvalidOperationException : Collection was modified; enumeration operation may not execute.
at System.Collections.Generic.List`1.Enumerator.MoveNext()
at Bunit.Rendering.BunitHtmlParser.Dispose() in /_/src/bunit/Rendering/BunitHtmlParser.cs:line 162
at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.Dispose()
Why the two sides run concurrently
The relevant code (line numbers from v2.7.2; identical on main):
private readonly List<IDocument> documents = new(); — line 25.
Parse() (68) → GetNewDocumentAsync() (151) → documents.Add(result) (154).
Dispose() (159) → context.Dispose() then foreach (var doc in documents) — line 162.
IRenderedComponent.Nodes (RenderedComponent.cs line 71) is the only caller of Parse(), and WaitForHelper evaluates its condition on the renderer's dispatcher, not on the test thread — OnAfterRender calls completeChecker() directly, and CheckAndInitializeWaiting queues that through an InvokeAsync whose task is not awaited. So a cut.Find(…) inside a WaitForAssertion parses on the dispatcher thread.
Meanwhile BunitContext.Dispose(bool) runs on the test thread:
// Ensure the renderer is disposed before all others,
// otherwise a render cycle may be ongoing and try to access
// the service provider to perform operations.
bunitRenderer?.Dispose();
Services.Dispose();
That ordering does not actually hold, because BunitRenderer.Dispose(bool) (line 542) ends with:
Dispatcher.InvokeAsync(() => base.Dispose(disposing));
and drops the returned task. Renderer disposal is therefore asynchronous and unawaited, so Services.Dispose() can reach BunitHtmlParser.Dispose() while the dispatcher is still parsing. Two threads, one unsynchronised List<T>, and the enumerating side is teardown.
Steps to reproduce
The race is reachable directly through the parser, without a renderer. This fails reliably on main:
using var cts = new CancellationTokenSource();
using var parser = new BunitHtmlParser();
var parsing = Task.Run(() =>
{
while (!cts.IsCancellationRequested)
{
try { parser.Parse("<p>Hello world</p>"); } catch { }
}
});
await Task.Delay(1);
parser.Dispose(); // throws InvalidOperationException
Measured over 100 iterations per run: 0/5 runs passed unpatched, 5/5 passed with the fix below.
Expected behaviour
Dispose() should not throw when a parse is in flight on another thread.
Version info
- bUnit: 2.7.2, and
main (Dispose() is byte-identical at v2.7.2, v2.9.0 and main; nothing in the changelog between them touches it)
- .NET: net10.0
- Test framework: xunit.v3
Suggested fix
Guard documents with a lock and have Dispose() enumerate a snapshot rather than the live list. I have this working with a regression test and will open a PR referencing this issue.
Downstream context, in case it is useful: this surfaced as a random red test in a ~5,300-test bUnit suite, roughly half of full-suite CI runs while it was being observed. It reads as "that test is broken" when the named test is innocent of the assertion, which is what makes it expensive to diagnose.
Description of the bug
BunitHtmlParserkeeps its parsed documents in a plain, non-thread-safeList<IDocument>, andDispose()enumerates that list directly whileParse()can still be adding to it from another thread. The result is an intermittent teardown failure that names an innocent test and carries no assertion from the test itself:Why the two sides run concurrently
The relevant code (line numbers from v2.7.2; identical on
main):private readonly List<IDocument> documents = new();— line 25.Parse()(68) →GetNewDocumentAsync()(151) →documents.Add(result)(154).Dispose()(159) →context.Dispose()thenforeach (var doc in documents)— line 162.IRenderedComponent.Nodes(RenderedComponent.csline 71) is the only caller ofParse(), andWaitForHelperevaluates its condition on the renderer's dispatcher, not on the test thread —OnAfterRendercallscompleteChecker()directly, andCheckAndInitializeWaitingqueues that through anInvokeAsyncwhose task is not awaited. So acut.Find(…)inside aWaitForAssertionparses on the dispatcher thread.Meanwhile
BunitContext.Dispose(bool)runs on the test thread:That ordering does not actually hold, because
BunitRenderer.Dispose(bool)(line 542) ends with:and drops the returned task. Renderer disposal is therefore asynchronous and unawaited, so
Services.Dispose()can reachBunitHtmlParser.Dispose()while the dispatcher is still parsing. Two threads, one unsynchronisedList<T>, and the enumerating side is teardown.Steps to reproduce
The race is reachable directly through the parser, without a renderer. This fails reliably on
main:Measured over 100 iterations per run: 0/5 runs passed unpatched, 5/5 passed with the fix below.
Expected behaviour
Dispose()should not throw when a parse is in flight on another thread.Version info
main(Dispose()is byte-identical at v2.7.2, v2.9.0 andmain; nothing in the changelog between them touches it)Suggested fix
Guard
documentswith a lock and haveDispose()enumerate a snapshot rather than the live list. I have this working with a regression test and will open a PR referencing this issue.Downstream context, in case it is useful: this surfaced as a random red test in a ~5,300-test bUnit suite, roughly half of full-suite CI runs while it was being observed. It reads as "that test is broken" when the named test is innocent of the assertion, which is what makes it expensive to diagnose.