From d1563f2efa92e175bb324f24deae66c5c6e9a469 Mon Sep 17 00:00:00 2001 From: bUnit bot Date: Mon, 19 Oct 2026 11:26:21 +0000 Subject: [PATCH 1/8] Set version to '2.13-preview' --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index b6ff506b5..1f145276a 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.12-preview", + "version": "2.13-preview", "assemblyVersion": { "precision": "revision" }, From 9043ecf7e7ab9bcc09ffa45b372db1ce9d3e3f38 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:14:34 +0000 Subject: [PATCH 2/8] Bump Meziantou.Polyfill from 1.0.166 to 1.0.168 --- updated-dependencies: - dependency-name: Meziantou.Polyfill dependency-version: 1.0.168 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8a23376ef..e352ab3ed 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + From 927b8b7ccdcfaecd5d31673dc7a2ba72332b0acd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:15:26 +0000 Subject: [PATCH 3/8] Bump AngleSharp.Css from 1.1.3 to 1.1.4 --- updated-dependencies: - dependency-name: AngleSharp.Css dependency-version: 1.1.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e352ab3ed..234b4c58e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,7 +19,7 @@ - + From 31d1b96ac973038ba6a1feb88386cd03d76631d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:15:10 +0000 Subject: [PATCH 4/8] Bump AngleSharp.Css from 1.1.4 to 1.2.0 --- updated-dependencies: - dependency-name: AngleSharp.Css dependency-version: 1.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 234b4c58e..1d462f834 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,7 +19,7 @@ - + From 807cfe55a62607347f47be739cbf4a1179a8534a Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Fri, 23 Oct 2026 15:01:33 +0200 Subject: [PATCH 5/8] feat: More detailled exception message for users --- .../WaitForHelpers/WaitForFailedException.cs | 27 +++++++++ .../WaitForHelpers/WaitForHelper.cs | 21 ++++++- tests/bunit.tests/Assembly.cs | 16 ++++++ ...enderedComponentWaitForHelperExtensions.cs | 55 +++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) diff --git a/src/bunit/Extensions/WaitForHelpers/WaitForFailedException.cs b/src/bunit/Extensions/WaitForHelpers/WaitForFailedException.cs index ec18da2ff..1033e6947 100644 --- a/src/bunit/Extensions/WaitForHelpers/WaitForFailedException.cs +++ b/src/bunit/Extensions/WaitForHelpers/WaitForFailedException.cs @@ -7,6 +7,33 @@ namespace Bunit.Extensions.WaitForHelpers; /// public sealed class WaitForFailedException : Exception, ITestTimeoutException { + internal const string CheckNotStartedMessage = """ + The wait condition was never checked, because the renderer's dispatcher did not run the check before the timeout. + This points to the test setup rather than the component under test: the thread pool may be starved, + e.g. by many tests blocking threads with synchronous WaitFor* or Click calls in parallel, or other work is blocking the dispatcher. + + Consider one of the following: + - Increase the timeout per call, e.g. WaitForElement("div", TimeSpan.FromSeconds(15)), + or globally via BunitContext.DefaultWaitTimeout. + - Prefer the async WaitFor*Async overloads, which do not block a thread pool thread while waiting. + - Raise the thread pool minimum once for the test assembly, e.g.: + + internal static class ThreadPoolSetup + { + [System.Runtime.CompilerServices.ModuleInitializer] + internal static void RaiseMinThreads() + { + ThreadPool.GetMinThreads(out var workers, out var io); + ThreadPool.SetMinThreads(Math.Max(workers, Environment.ProcessorCount * 4), io); + } + } + """; + + internal const string CheckNotCompletedMessage = + "The wait condition check started but did not complete before the timeout. " + + "The asynchronous check is most likely awaiting something that never completes, or that takes longer than the timeout. " + + "This usually indicates a problem in the test itself."; + /// /// Initializes a new instance of the class. /// diff --git a/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs b/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs index d8e5111e1..9f3c01ced 100644 --- a/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs +++ b/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs @@ -20,6 +20,7 @@ internal abstract class WaitForHelper : IDisposable private bool isDisposed; private bool isChecking; private bool isDirty; + private bool isCheckStarted; private int checkCount; private Exception? capturedException; @@ -100,11 +101,12 @@ protected WaitForHelper( static (state) => { var @this = (WaitForHelper)state!; + var checks = Volatile.Read(ref @this.checkCount); @this.logger.LogWaiterTimedOut(@this.renderedComponent.ComponentId); @this.checkPassedCompletionSource.TrySetException( new WaitForFailedException( - @this.TimeoutErrorMessage ?? string.Empty, - @this.checkCount, + @this.CreateTimeoutErrorMessage(checks), + checks, @this.renderedComponent.RenderCount, @this.renderer.RenderCount, @this.capturedException)); @@ -159,12 +161,27 @@ private void CheckAndInitializeWaiting() { // Before subscribing to renderedFragment.OnAfterRender, // we need to make sure that the desired state has not already been reached. + Volatile.Write(ref isCheckStarted, true); OnAfterRender(this, EventArgs.Empty); SubscribeToOnAfterRender(); }); } } + // Called from the timer on a thread pool thread, while the check fields are written on the dispatcher. + private string CreateTimeoutErrorMessage(int completedCheckCount) + { + var message = TimeoutErrorMessage ?? string.Empty; + if (completedCheckCount > 0) + return message; + + var reason = Volatile.Read(ref isCheckStarted) + ? WaitForFailedException.CheckNotCompletedMessage + : WaitForFailedException.CheckNotStartedMessage; + + return $"{message}{Environment.NewLine}{Environment.NewLine}{reason}"; + } + private Task CreateWaitTask() { // Two to failure conditions, that the renderer captures an unhandled diff --git a/tests/bunit.tests/Assembly.cs b/tests/bunit.tests/Assembly.cs index 786c37ff8..7ea1fae08 100644 --- a/tests/bunit.tests/Assembly.cs +++ b/tests/bunit.tests/Assembly.cs @@ -1 +1,17 @@ +using System.Runtime.CompilerServices; + [assembly: System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + +namespace Bunit; + +internal static class ThreadPoolSetup +{ + // Many tests block a pool thread synchronously (WaitFor*, Click) while xunit runs 2x processor count in parallel, + // so the default pool floor starves the continuations those tests wait on, causing sporadic WaitFor* timeouts on CI. + [ModuleInitializer] + internal static void RaiseMinThreads() + { + ThreadPool.GetMinThreads(out var workers, out var io); + ThreadPool.SetMinThreads(Math.Max(workers, Environment.ProcessorCount * 4), io); + } +} diff --git a/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs b/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs index 0d0044ea4..555a61cd5 100644 --- a/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs +++ b/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs @@ -240,6 +240,61 @@ await cut.WaitForAssertionAsync(async () => expected.Message.ShouldStartWith(WaitForAssertionHelper.TimeoutMessage); } + [Fact(DisplayName = "WaitForStateAsync reports a setup problem when the dispatcher never runs the check before the timeout")] + public async Task Test500() + { + var cut = Render(); + var cancellationToken = Xunit.TestContext.Current.CancellationToken; + using var dispatcherEntered = new ManualResetEventSlim(); + using var releaseDispatcher = new ManualResetEventSlim(); + var blocker = Task.Run(() => cut.InvokeAsync(() => + { + dispatcherEntered.Set(); + releaseDispatcher.Wait(cancellationToken); + }), cancellationToken); + + try + { + dispatcherEntered.Wait(cancellationToken); + + var expected = await Should.ThrowAsync( + cut.WaitForStateAsync(() => true, TimeSpan.FromMilliseconds(50))); + + expected.Message.ShouldContain(WaitForFailedException.CheckNotStartedMessage); + expected.Message.ShouldContain("Check count: 0."); + } + finally + { + releaseDispatcher.Set(); + await blocker; + } + } + + [Fact(DisplayName = "WaitForStateAsync reports a test problem when the async check never completes before the timeout")] + public async Task Test501() + { + var cut = Render(); + var neverCompletes = new TaskCompletionSource(); + + var expected = await Should.ThrowAsync( + cut.WaitForStateAsync(() => neverCompletes.Task, TimeSpan.FromMilliseconds(50))); + + expected.Message.ShouldContain(WaitForFailedException.CheckNotCompletedMessage); + expected.Message.ShouldContain("Check count: 0."); + } + + [Fact(DisplayName = "WaitForState timeout after completed checks does not report a setup or test problem")] + public void Test502() + { + var cut = Render(); + + var expected = Should.Throw(() => + cut.WaitForState(() => false, TimeSpan.FromMilliseconds(50))); + + expected.Message.ShouldNotContain(WaitForFailedException.CheckNotStartedMessage); + expected.Message.ShouldNotContain(WaitForFailedException.CheckNotCompletedMessage); + } + private sealed class ThrowsAfterAsyncOperation : ComponentBase { protected override async Task OnInitializedAsync() From 98a15599213bd067c6aeb7447ec2718a4988c85b Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 25 Oct 2026 16:45:13 +0100 Subject: [PATCH 6/8] Bump AngleSharp to 1.8.6 and implement SelectedIndex setter (fixes #1957) --- CHANGELOG.md | 4 ++++ Directory.Packages.props | 2 +- ...ratorTest.Generator#HtmlSelectElementWrapper.g.verified.cs | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f289f6066..482ab78d6 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 + +- `Find` threw `TypeLoadException` with AngleSharp 1.8.6 because `HtmlSelectElementWrapper` did not implement the new `SelectedIndex` setter. Reported by [@poxet](https://github.com/poxet) in #1957. Fixed by [@linkdotnet](https://github.com/linkdotnet). + ## [2.12.5] - 2026-10-19 ### Fixed diff --git a/Directory.Packages.props b/Directory.Packages.props index 1d462f834..95a3da0d9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,7 +18,7 @@ - + diff --git a/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#HtmlSelectElementWrapper.g.verified.cs b/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#HtmlSelectElementWrapper.g.verified.cs index b4f0b77c5..32178d2a8 100644 --- a/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#HtmlSelectElementWrapper.g.verified.cs +++ b/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#HtmlSelectElementWrapper.g.verified.cs @@ -1572,6 +1572,10 @@ public int SelectedIndex [System.Diagnostics.DebuggerStepThrough] [System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)] get => WrappedElement.SelectedIndex; + [System.Diagnostics.DebuggerHidden] + [System.Diagnostics.DebuggerStepThrough] + [System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)] + set => WrappedElement.SelectedIndex = value; } /// From 165eb122c09be69e0191e8081d3b963d2ab4d8f2 Mon Sep 17 00:00:00 2001 From: Xavier Klausener Date: Fri, 23 Oct 2026 15:49:36 +0200 Subject: [PATCH 7/8] fix: stop the WaitFor timer when the first check passes before it is started When a WaitFor* check passes synchronously, the helper disposes itself from inside the constructor, while the timer field is still null. The constructor then looks at WaitTask.IsCompleted to decide whether to start the timer, but WaitTask is a WhenAny(...).Unwrap() that completes on the thread pool, so it usually still reads as not completed at that point and the timer is started anyway. The later Dispose() from the using block returns early because the helper is already disposed, and nothing ever stops the timer. The timer's state is the helper, so until the timeout elapses it roots the helper, the rendered component, the renderer and everything the renderer holds. With bUnit's default timeout of one second that goes unnoticed. With a long DefaultWaitTimeout, a test suite keeps every page it rendered in memory for its whole run. The constructor now also checks isDisposed before starting the timer, and disposes the timer right after creating it if the check disposed the helper in between. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + .../WaitForHelpers/WaitForHelper.cs | 12 ++++- ...enderedComponentWaitForHelperExtensions.cs | 50 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 482ab78d6..82adb1477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to **bUnit** will be documented in this file. The project ad ### Fixed - `Find` threw `TypeLoadException` with AngleSharp 1.8.6 because `HtmlSelectElementWrapper` did not implement the new `SelectedIndex` setter. Reported by [@poxet](https://github.com/poxet) in #1957. Fixed by [@linkdotnet](https://github.com/linkdotnet). +- A `WaitFor*` call whose first check passed left its timeout timer running, which kept the helper, the renderer and everything the renderer holds alive until the timeout elapsed. With a long `DefaultWaitTimeout`, a test suite could hold every rendered component in memory for its whole run. Fixed by [@xaviergxf](https://github.com/xaviergxf). ## [2.12.5] - 2026-10-19 diff --git a/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs b/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs index 9f3c01ced..99790d681 100644 --- a/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs +++ b/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs @@ -95,7 +95,11 @@ protected WaitForHelper( // If the initial check did not complete successfully, // start the timer and recheck after every render until the timer expires. - if (!WaitTask.IsCompleted) + // A check that completed synchronously has already disposed this helper + // before WaitTask, which completes on the thread pool, reflects it. A timer + // started after that would never be disposed, and would keep this helper, + // and through it the renderer, alive until the timeout elapsed. + if (!isDisposed && !WaitTask.IsCompleted) { timer = new Timer( static (state) => @@ -114,6 +118,12 @@ protected WaitForHelper( this, GetRuntimeTimeout(timeout), Timeout.InfiniteTimeSpan); + + // The check may have completed on the renderer's dispatcher, and disposed + // this helper while the timer field was still null, between the test above + // and the assignment. Dispose(bool) found no timer to stop in that case. + if (isDisposed) + timer.Dispose(); } } diff --git a/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs b/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs index 555a61cd5..34482aff9 100644 --- a/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs +++ b/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using AngleSharp.Dom; namespace Bunit.Extensions.WaitForHelpers; @@ -295,6 +296,55 @@ public void Test502() expected.Message.ShouldNotContain(WaitForFailedException.CheckNotCompletedMessage); } + [Fact(DisplayName = "WaitForAssertion does not keep the renderer alive after the first check passes")] + public async Task Test503() + { + // The first call is slow while it is being JIT compiled, which is enough for the wait task + // to complete before the helper decides whether to start its timer, so it is not measured. + RenderAndWaitForAssertionThatPassesAtOnce(); + + var renderers = Enumerable.Range(0, 10) + .Select(_ => RenderAndWaitForAssertionThatPassesAtOnce()) + .ToList(); + + // Disposing a context queues work that still references the renderer: cancelling its + // unhandled exception task wakes the helper's failure continuation on the thread pool, and + // the renderer's own base disposal goes through the dispatcher. On a busy test host that + // can still be queued when the first collection runs, so the collection is retried for a + // short while. A timer left running roots the renderer for ten minutes, so no number of + // retries makes the leaking case pass. + var alive = renderers.Count; + for (var attempt = 0; attempt < 20 && alive > 0; attempt++) + { + if (attempt > 0) + await Task.Delay(50, Xunit.TestContext.Current.CancellationToken); + +#pragma warning disable S1215 // A full collection is what proves the renderers are no longer rooted. + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); +#pragma warning restore S1215 + + alive = renderers.Count(r => r.IsAlive); + } + + alive.ShouldBe(0, "a wait whose first check passed should leave no timer running"); + } + + // Kept out of the test method so that nothing on its stack frame keeps the context alive. + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference RenderAndWaitForAssertionThatPassesAtOnce() + { + using var context = new BunitContext(); + var cut = context.Render(); + + // A timeout far longer than the test: a timer left running roots the helper, + // and through it the renderer, until it fires. + cut.WaitForAssertion(() => { }, TimeSpan.FromMinutes(10)); + + return new WeakReference(context.Renderer); + } + private sealed class ThrowsAfterAsyncOperation : ComponentBase { protected override async Task OnInitializedAsync() From 1e13872c4928925f877bba433819ca718350c01c Mon Sep 17 00:00:00 2001 From: bUnit bot Date: Sun, 25 Oct 2026 16:03:01 +0000 Subject: [PATCH 8/8] Set version to '2.13' --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 1f145276a..b94e03074 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.13-preview", + "version": "2.13", "assemblyVersion": { "precision": "revision" },