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()