fix: Race condition between HTML parser disposal and concurrent parsing - #1909
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The new test and changelog currently claim “waiting” semantics that are not actually asserted/implemented, so they should be corrected for accuracy (and ideally strengthened with a deterministic regression test for the race).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR addresses an intermittent teardown race where BunitHtmlParser.Dispose() could enumerate its internal document list while another thread was still parsing and adding documents, causing InvalidOperationException: Collection was modified.
Changes:
- Serialize
BunitHtmlParser.Parse()andDispose()via a shared lock to prevent concurrent mutation/enumeration of tracked documents. - Add a new test intended to cover disposal during an active markup assertion.
- Document the fix in the changelog.
File summaries
| File | Description |
|---|---|
| src/bunit/Rendering/BunitHtmlParser.cs | Adds a lock to serialize parsing and disposal to prevent concurrent access to the internal documents list. |
| tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs | Adds a regression-style test around disposal while markup assertions run on the renderer dispatcher. |
| CHANGELOG.md | Adds an Unreleased “Fixed” entry describing the change. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| public INodeList Parse([StringSyntax("Html")] string markup) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(markup); | ||
|
|
||
| var document = GetNewDocumentAsync().GetAwaiter().GetResult(); | ||
| lock (parserLock) | ||
| { | ||
| var document = GetNewDocumentAsync().GetAwaiter().GetResult(); | ||
|
|
||
| var (ctx, matchedElement) = GetParseContext(markup, document); | ||
| var (ctx, matchedElement) = GetParseContext(markup, document); | ||
|
|
||
| return ctx is null && matchedElement is not null | ||
| ? ParseSpecial(markup, matchedElement) | ||
| : htmlParser.ParseFragment(markup, ctx!); | ||
| return ctx is null && matchedElement is not null | ||
| ? ParseSpecial(markup, matchedElement) | ||
| : htmlParser.ParseFragment(markup, ctx!); | ||
| } |
|
|
||
| ### Fixed | ||
|
|
||
| - Synchronous context disposal now waits for queued renderer work, and HTML parsing is serialized with parser disposal, preventing teardown races. Reported by [@thimobuchheister](https://github.com/thimobuchheister) in #1892. Fixed by [@linkdotnet](https://github.com/linkdotnet). |
| [Fact(DisplayName = "Disposing a context waits for an active markup assertion on the renderer dispatcher")] | ||
| public async Task Context_disposal_waits_for_active_markup_assertion() |
aa5f3a4 to
603ea9a
Compare
| private readonly IBrowsingContext context; | ||
| private readonly HtmlParser htmlParser; | ||
| private readonly List<IDocument> documents = new(); | ||
| private readonly object parserLock = new(); |
There was a problem hiding this comment.
Can we use the Lock type instead of object?
There was a problem hiding this comment.
We have to #if NET9_0_OR_GREATER - we never use System.Lock until now but that can be done in bunit v3
603ea9a to
0a54860
Compare
|
I would merge if there are no reasons against it |
Closes #1892