Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
21 changes: 19 additions & 2 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 @@ -100,11 +101,12 @@ protected WaitForHelper(
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));
Expand Down Expand Up @@ -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<T> CreateWaitTask()
{
// Two to failure conditions, that the renderer captures an unhandled
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
Expand Up @@ -240,6 +240,61 @@ 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);
}

private sealed class ThrowsAfterAsyncOperation : ComponentBase
{
protected override async Task OnInitializedAsync()
Expand Down
Loading