fix: guard BunitHtmlParser.documents against concurrent access - #1893
Open
thimobuchheister wants to merge 2 commits into
Open
fix: guard BunitHtmlParser.documents against concurrent access#1893thimobuchheister wants to merge 2 commits into
thimobuchheister wants to merge 2 commits into
Conversation
BunitHtmlParser keeps parsed documents in a plain List<IDocument>.
Parse() adds to it, Dispose() enumerates it, and the two can run on
different threads: WaitForHelper evaluates its condition on the
renderer's dispatcher, while BunitContext.Dispose() runs Services
disposal on the test thread. BunitRenderer.Dispose() drops the task
returned by Dispatcher.InvokeAsync(() => base.Dispose(disposing)), so
renderer disposal is asynchronous and unawaited and the parser can be
disposed while a parse is still in flight.
The result is an intermittent
System.InvalidOperationException: Collection was modified;
enumeration operation may not execute.
at Bunit.Rendering.BunitHtmlParser.Dispose()
thrown during teardown, naming whichever test happened to lose the race.
Guard the list with a lock and dispose a snapshot rather than
enumerating the live list.
Fixes bUnit-dev#1892
linkdotnet
reviewed
Aug 11, 2026
| // live list, which would otherwise throw "Collection was modified" if a | ||
| // parse completed mid-loop. | ||
| IDocument[] documentsToDispose; | ||
| lock (documentsLock) |
Collaborator
There was a problem hiding this comment.
The lock can go through the while Dispose method. Context should be safe here as well. That would also remove the need for re-enumerating.
Collaborator
There was a problem hiding this comment.
Also Parse should for sure have the lock - dispose and parse can run against each other
| var result = await context.OpenNewAsync().ConfigureAwait(false); | ||
| documents.Add(result); | ||
|
|
||
| lock (documentsLock) |
Collaborator
There was a problem hiding this comment.
Technically the lock here is not needed - the only usage does a synchronous wait for it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pull request description
Fixes #1892.
BunitHtmlParserkeeps its parsed documents in a plainList<IDocument>.Parse()adds to it andDispose()enumerates it, and the two can run on different threads, producing an intermittent teardown failure that names an innocent test:Why the two sides overlap:
WaitForHelperevaluates its condition on the renderer's dispatcher (OnAfterRendercallscompleteChecker()directly), socut.Find(…)inside aWaitForAssertionparses on the dispatcher thread.BunitContext.Dispose(bool)meanwhile runsbunitRenderer?.Dispose()thenServices.Dispose()on the test thread — and its "ensure the renderer is disposed before all others" comment does not hold in practice, becauseBunitRenderer.Dispose(bool)ends withDispatcher.InvokeAsync(() => base.Dispose(disposing));and drops the returned task. Renderer disposal is asynchronous and unawaited, so the parser can be disposed mid-parse. Full trace in the issue.The change is deliberately minimal — a dedicated lock object guarding
documents, andDispose()disposing a snapshot rather than the live list:I deliberately did not try to fix the unawaited
Dispatcher.InvokeAsyncinBunitRenderer.Dispose(), even though it is the reason the two sides overlap at all. That is a behavioural change to teardown ordering with a much wider blast radius, and making the parser thread-safe fixes the reported crash on its own. Happy to look at it separately if you would like.One residual worth naming: a document whose parse completes after
Dispose()has taken its snapshot will not be disposed by the parser. That was already true before this change (it would have been added to a list nobody reads again), and closing it properly means deciding whatParse()should do on a disposed parser — out of scope here, but I can follow up.Verification
New test
Dispose_does_not_throw_while_another_thread_is_parsinginBunitHtmlParserTest. It reaches the race through the parser directly, so it needs no renderer. Measured on net10.0, 100 iterations per run:dotnet test tests/bunit.tests -c Release— 2266/2266 passing, no new warnings (checked in Release, whereTreatWarningsAsErrorsis on).Caveat on my local verification: my SDK is 10.0.400-preview, which cannot target
net11.0, so I built and ran againstnet10.0only. The change uses nothing version-specific (lock+List<T>.ToArray()), so I would expect the other TFMs to be unaffected, but CI will be the real check.PR meta checklist
mainbranch for codeor targeted at
stablebranch for documentation that is live on bunit.dev.Code PR specific checklist