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