Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ 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).
- 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

### Fixed
Expand Down
6 changes: 3 additions & 3 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<GlobalPackageReference Include="AsyncFixer" Version="2.1.0" PrivateAssets="All" IncludeAssets="Runtime;Build;Native;contentFiles;Analyzers"/>
<GlobalPackageReference Include="SonarAnalyzer.CSharp" Version="10.29.0.143774" PrivateAssets="All" IncludeAssets="Runtime;Build;Native;contentFiles;Analyzers"/>
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Meziantou.Polyfill" Version="1.0.166" />
<PackageVersion Include="Meziantou.Polyfill" Version="1.0.168" />
</ItemGroup>

<ItemGroup Label="Shared">
Expand All @@ -18,8 +18,8 @@
<PackageVersion Include="Serilog" Version="4.4.0"/>
<PackageVersion Include="Serilog.Expressions" Version="5.0.0"/>
<PackageVersion Include="AngleSharp.Diffing" Version="1.1.1"/>
<PackageVersion Include="AngleSharp" Version="1.8.5"/>
<PackageVersion Include="AngleSharp.Css" Version="1.1.3"/>
<PackageVersion Include="AngleSharp" Version="1.8.6"/>
<PackageVersion Include="AngleSharp.Css" Version="1.2.0"/>
</ItemGroup>

<ItemGroup Label="Package Versioning">
Expand Down
27 changes: 27 additions & 0 deletions src/bunit/Extensions/WaitForHelpers/WaitForFailedException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,33 @@ namespace Bunit.Extensions.WaitForHelpers;
/// </summary>
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.";

/// <summary>
/// Initializes a new instance of the <see cref="WaitForFailedException"/> class.
/// </summary>
Expand Down
33 changes: 30 additions & 3 deletions src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ internal abstract class WaitForHelper<T, TComponent> : IDisposable
private bool isDisposed;
private bool isChecking;
private bool isDirty;
private bool isCheckStarted;
private int checkCount;
private Exception? capturedException;

Expand Down Expand Up @@ -94,24 +95,35 @@ 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) =>
{
var @this = (WaitForHelper<T, TComponent>)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));
},
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();
}
}

Expand Down Expand Up @@ -159,12 +171,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<T> CreateWaitTask()
{
// Two to failure conditions, that the renderer captures an unhandled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <inheritdoc/>
Expand Down
16 changes: 16 additions & 0 deletions tests/bunit.tests/Assembly.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
using AngleSharp.Dom;

namespace Bunit.Extensions.WaitForHelpers;
Expand Down Expand Up @@ -240,6 +241,110 @@ await cut.WaitForAssertionAsync(async () =>
expected.Message.ShouldStartWith(WaitForAssertionHelper<Simple1>.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<Simple1>();
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<WaitForFailedException>(
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<Simple1>();
var neverCompletes = new TaskCompletionSource<bool>();

var expected = await Should.ThrowAsync<WaitForFailedException>(
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<Simple1>();

var expected = Should.Throw<WaitForFailedException>(() =>
cut.WaitForState(() => false, TimeSpan.FromMilliseconds(50)));

expected.Message.ShouldNotContain(WaitForFailedException.CheckNotStartedMessage);
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<Simple1>();

// 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()
Expand Down
2 changes: 1 addition & 1 deletion version.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "2.12",
"version": "2.13",
"assemblyVersion": {
"precision": "revision"
},
Expand Down
Loading