diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index a2ee9a494..372393c31 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -17,7 +17,7 @@
"rollForward": false
},
"dotnet-dump": {
- "version": "9.0.661903",
+ "version": "10.0.731102",
"commands": [
"dotnet-dump"
],
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 415d1cd40..5a52b66ad 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -182,32 +182,36 @@ jobs:
dotnet new bunit --no-restore -o ${{ github.workspace }}/TemplateTestXunit
echo '' >> ${{ github.workspace }}/TemplateTestXunit/Directory.Build.props
echo 'false' >> ${{ github.workspace }}/TemplateTestXunit/Directory.Packages.props
+ echo '{"sdk":{"rollForward":"latestMajor","allowPrerelease":true}}' >> ${{ github.workspace }}/TemplateTestXunit/global.json
dotnet restore ${{ github.workspace }}/TemplateTestXunit --source https://api.nuget.org/v3/index.json --source ${{ env.NUGET_DIRECTORY }}
- dotnet test ${{ github.workspace }}/TemplateTestXunit
+ cd ${{ github.workspace }}/TemplateTestXunit && dotnet test
- name: โ Verify xUnit.v3 template
run: |
dotnet new bunit --framework xunitv3 --no-restore -o ${{ github.workspace }}/TemplateTestXunitv3
echo '' >> ${{ github.workspace }}/TemplateTestXunitv3/Directory.Build.props
echo 'false' >> ${{ github.workspace }}/TemplateTestXunitv3/Directory.Packages.props
+ echo '{"sdk":{"rollForward":"latestMajor","allowPrerelease":true}}' >> ${{ github.workspace }}/TemplateTestXunitv3/global.json
dotnet restore ${{ github.workspace }}/TemplateTestXunitv3 --source https://api.nuget.org/v3/index.json --source ${{ env.NUGET_DIRECTORY }}
- dotnet test ${{ github.workspace }}/TemplateTestXunitv3
+ cd ${{ github.workspace }}/TemplateTestXunitv3 && dotnet test
- name: โ Verify NUnit template
run: |
dotnet new bunit --framework nunit --no-restore -o ${{ github.workspace }}/TemplateTestNunit
echo '' >> ${{ github.workspace }}/TemplateTestNunit/Directory.Build.props
echo 'false' >> ${{ github.workspace }}/TemplateTestNunit/Directory.Packages.props
+ echo '{"sdk":{"rollForward":"latestMajor","allowPrerelease":true}}' >> ${{ github.workspace }}/TemplateTestNunit/global.json
dotnet restore ${{ github.workspace }}/TemplateTestNunit --source https://api.nuget.org/v3/index.json --source ${{ env.NUGET_DIRECTORY }}
- dotnet test ${{ github.workspace }}/TemplateTestNunit
+ cd ${{ github.workspace }}/TemplateTestNunit && dotnet test
- name: โ Verify MSTest template
run: |
dotnet new bunit --framework mstest --no-restore -o ${{ github.workspace }}/TemplateTestMstest
echo '' >> ${{ github.workspace }}/TemplateTestMstest/Directory.Build.props
echo 'false' >> ${{ github.workspace }}/TemplateTestMstest/Directory.Packages.props
+ echo '{"sdk":{"rollForward":"latestMajor","allowPrerelease":true}}' >> ${{ github.workspace }}/TemplateTestMstest/global.json
dotnet restore ${{ github.workspace }}/TemplateTestMstest --source https://api.nuget.org/v3/index.json --source ${{ env.NUGET_DIRECTORY }}
- dotnet test ${{ github.workspace }}/TemplateTestMstest
+ cd ${{ github.workspace }}/TemplateTestMstest && dotnet test
validate-docs:
runs-on: ubuntu-latest
@@ -251,7 +255,8 @@ jobs:
run: dotnet build -c release
- name: ๐งช Run sample unit tests
- run: dotnet test docs/samples/samples.sln -p:VSTestUseMSBuildOutput=false -f net8.0
+ working-directory: docs/samples
+ run: dotnet test samples.sln -p:VSTestUseMSBuildOutput=false -f net8.0
- name: ๐ Build docs
working-directory: ./docs/site
diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
index 81075fccc..35f3bbdfe 100644
--- a/.github/workflows/docs-deploy.yml
+++ b/.github/workflows/docs-deploy.yml
@@ -87,7 +87,8 @@ jobs:
run: dotnet build /p:PublicRelease=true -c release
- name: ๐ ๏ธ Verify docs samples
- run: dotnet test docs/samples/samples.sln
+ working-directory: docs/samples
+ run: dotnet test samples.sln
- name: ๐ Building docs
working-directory: ./docs/site
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 676334358..c568bdff2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ All notable changes to **bUnit** will be documented in this file. The project ad
## [Unreleased]
+### Fixed
+
+- `BunitHtmlParser.Dispose()` no longer throws `InvalidOperationException: Collection was modified` when a parse is in flight on another thread during test teardown. Reported by [@thimobuchheister](https://github.com/thimobuchheister) in #1892. Fixed by [@linkdotnet](https://github.com/linkdotnet).
+
## [2.9.0] - 2026-08-03
### Changed
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1dc6e6910..56664c00a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,7 +8,7 @@
-
+
@@ -18,12 +18,12 @@
-
-
+
+
-
+
@@ -91,24 +91,24 @@
-
+
-
+
-
+
-
-
-
-
+
+
+
+
diff --git a/docs/samples/global.json b/docs/samples/global.json
new file mode 100644
index 000000000..83105ca8e
--- /dev/null
+++ b/docs/samples/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "rollForward": "latestMajor",
+ "allowPrerelease": true
+ }
+}
diff --git a/global.json b/global.json
index 83105ca8e..e5c95761f 100644
--- a/global.json
+++ b/global.json
@@ -2,5 +2,8 @@
"sdk": {
"rollForward": "latestMajor",
"allowPrerelease": true
+ },
+ "test": {
+ "runner": "Microsoft.Testing.Platform"
}
}
diff --git a/src/bunit.generators.internal/bunit.generators.internal.csproj b/src/bunit.generators.internal/bunit.generators.internal.csproj
index cae79f712..023f92c9c 100644
--- a/src/bunit.generators.internal/bunit.generators.internal.csproj
+++ b/src/bunit.generators.internal/bunit.generators.internal.csproj
@@ -2,7 +2,7 @@
netstandard2.0
- 12.0
+ 14.0
true
Bunit
false
diff --git a/src/bunit.generators/bunit.generators.csproj b/src/bunit.generators/bunit.generators.csproj
index cde790b2c..d053641dd 100644
--- a/src/bunit.generators/bunit.generators.csproj
+++ b/src/bunit.generators/bunit.generators.csproj
@@ -2,7 +2,7 @@
netstandard2.0
- 12.0
+ 14.0
true
Bunit
diff --git a/src/bunit/JSInterop/JSRuntimeInvocationNotSetException.cs b/src/bunit/JSInterop/JSRuntimeInvocationNotSetException.cs
index 4eff577f1..d3dce9302 100644
--- a/src/bunit/JSInterop/JSRuntimeInvocationNotSetException.cs
+++ b/src/bunit/JSInterop/JSRuntimeInvocationNotSetException.cs
@@ -64,7 +64,7 @@ private static string CreateErrorMessage(JSRuntimeInvocation invocation)
private static string GetArguments(JSRuntimeInvocation invocation)
{
- if (!invocation.Arguments.Any())
+ if (invocation.Arguments.Count == 0)
return $"\"{invocation.Identifier}\"";
var argStrings = invocation.Arguments.Select(FormatArgument).Prepend($"\"{invocation.Identifier}\"");
diff --git a/src/bunit/JSInterop/JSRuntimeUnhandledInvocationException.cs b/src/bunit/JSInterop/JSRuntimeUnhandledInvocationException.cs
index 500fe5a48..8f68bddb9 100644
--- a/src/bunit/JSInterop/JSRuntimeUnhandledInvocationException.cs
+++ b/src/bunit/JSInterop/JSRuntimeUnhandledInvocationException.cs
@@ -60,7 +60,7 @@ private static string CreateErrorMessage(JSRuntimeInvocation invocation)
sb.AppendLine(FormattableString.Invariant($" Setup<{GetReturnTypeName(invocation.ResultType)}>({GetArguments(invocation)})"));
}
- if (invocation.Arguments.Any())
+ if (invocation.Arguments.Count > 0)
{
sb.AppendLine("or the following, to match any arguments:");
if (invocation.IsVoidResultInvocation)
diff --git a/src/bunit/Rendering/BunitHtmlParser.cs b/src/bunit/Rendering/BunitHtmlParser.cs
index fa1e08736..47fcf0095 100644
--- a/src/bunit/Rendering/BunitHtmlParser.cs
+++ b/src/bunit/Rendering/BunitHtmlParser.cs
@@ -23,6 +23,8 @@ internal sealed class BunitHtmlParser : IDisposable
private readonly IBrowsingContext context;
private readonly HtmlParser htmlParser;
private readonly List documents = new();
+ private readonly object parserLock = new();
+ private bool disposed;
///
/// Initializes a new instance of the class
@@ -69,13 +71,16 @@ 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!);
+ }
}
private INodeList ParseSpecial(string markup, string matchedElement)
@@ -158,10 +163,18 @@ private async Task GetNewDocumentAsync()
///
public void Dispose()
{
- context.Dispose();
- foreach (var doc in documents)
+ lock (parserLock)
{
- doc.Dispose();
+ if (disposed)
+ return;
+
+ disposed = true;
+
+ context.Dispose();
+ foreach (var doc in documents)
+ {
+ doc.Dispose();
+ }
}
}
diff --git a/tests/bunit.tests/BlazorE2E/ComponentRenderingTest.cs b/tests/bunit.tests/BlazorE2E/ComponentRenderingTest.cs
index 494c6df50..9c3fb672b 100644
--- a/tests/bunit.tests/BlazorE2E/ComponentRenderingTest.cs
+++ b/tests/bunit.tests/BlazorE2E/ComponentRenderingTest.cs
@@ -669,7 +669,7 @@ public async Task CanHandleRemovedParentObjects()
cut.Find("button").Click();
- await cut.WaitForStateAsync(() => !cut.FindAll("div").Any());
+ await cut.WaitForStateAsync(() => cut.FindAll("div").Count == 0);
cut.FindAll("div").Count.ShouldBe(0);
}
@@ -681,7 +681,7 @@ public void CanHandleRemovedParentObjects_Sync()
cut.Find("button").Click();
- cut.WaitForState(() => !cut.FindAll("div").Any());
+ cut.WaitForState(() => cut.FindAll("div").Count == 0);
cut.FindAll("div").Count.ShouldBe(0);
}
@@ -693,7 +693,7 @@ public async Task CanHandleRemovedParentObjectsAsync()
await cut.Find("button").ClickAsync(new MouseEventArgs());
- await cut.WaitForStateAsync(() => !cut.FindAll("div").Any());
+ await cut.WaitForStateAsync(() => cut.FindAll("div").Count == 0);
cut.FindAll("div").Count.ShouldBe(0);
}
@@ -717,7 +717,7 @@ public async Task CanHandleRemovedParentObjectsAsync_Sync()
await cut.Find("button").ClickAsync(new MouseEventArgs());
- cut.WaitForState(() => !cut.FindAll("div").Any());
+ cut.WaitForState(() => cut.FindAll("div").Count == 0);
cut.FindAll("div").Count.ShouldBe(0);
}
diff --git a/tests/bunit.tests/Rendering/BunitHtmlParserTest.cs b/tests/bunit.tests/Rendering/BunitHtmlParserTest.cs
index 3f386b6ea..1b85f8ced 100644
--- a/tests/bunit.tests/Rendering/BunitHtmlParserTest.cs
+++ b/tests/bunit.tests/Rendering/BunitHtmlParserTest.cs
@@ -170,6 +170,63 @@ public void Test021()
actual[1].ShouldBeAssignableTo();
}
+ [Fact(DisplayName = "Dispose() does not throw while another thread is parsing")]
+ public async Task DisposeDoesNotThrowWhileAnotherThreadIsParsing()
+ {
+ using var cts = new CancellationTokenSource();
+ using var parser = new BunitHtmlParser();
+ var parseCount = 0;
+ Exception? parseException = null;
+
+ var parsing = Task.Run(
+ () =>
+ {
+ while (!cts.IsCancellationRequested)
+ {
+ try
+ {
+ parser.Parse("Hello world
");
+ Interlocked.Increment(ref parseCount);
+ }
+ catch (InvalidOperationException ex)
+ {
+ // "Collection was modified" - the race this test guards against.
+ parseException = ex;
+ return;
+ }
+ catch (Exception)
+ {
+ // Expected once Dispose() has completed: parsing against a
+ // disposed AngleSharp browsing context.
+ }
+ }
+ },
+ CancellationToken.None);
+
+ // Let the parser build up a sizeable document list, so the enumeration in
+ // Dispose() is long enough to overlap with a concurrent call to Parse().
+ while (Volatile.Read(ref parseCount) < 100)
+ {
+ await Task.Yield();
+ }
+
+ Should.NotThrow(parser.Dispose);
+
+ await cts.CancelAsync();
+ await parsing;
+
+ parseException.ShouldBeNull();
+ }
+
+ [Fact(DisplayName = "Dispose() is idempotent")]
+ public void DisposeIsIdempotent()
+ {
+ using var parser = new BunitHtmlParser();
+ parser.Dispose();
+
+ Should.NotThrow(parser.Dispose);
+ }
+
private static void VerifyElementParsedWithId(string expectedElementName, List actual)
{
var elm = actual.OfType()
diff --git a/version.json b/version.json
index 105776cf0..cf16a5ace 100644
--- a/version.json
+++ b/version.json
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
- "version": "2.9",
+ "version": "2.10",
"assemblyVersion": {
"precision": "revision"
},