Skip to content

[Feature]: Add CancellationToken overload to Eventually / WaitsFor #5932

Description

@JohnVerheij

Problem Statement

Assert.That(...).Eventually(predicate, timeout) and Assert.That(...).WaitsFor(predicate, timeout) accept a TimeSpan but no CancellationToken. When a test framework or harness signals external cancellation (Ctrl+C, parent-class timeout, harness abort), the poll cannot be interrupted until its internal TimeSpan elapses.

Three probes against TUnit 1.44.39 show the underlying behaviour:

// Probe 1: supplier throws OCE on entry (pre-cancelled CTS).
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
var sw = Stopwatch.StartNew();
try
{
    await Assert.That(() =>
    {
        cts.Token.ThrowIfCancellationRequested();
        return true;
    }).Eventually(a => a.IsTrue(), TimeSpan.FromSeconds(2));
}
catch (AssertionException) { /* ... */ }
sw.Stop();
// Observed: elapsed ~2000 ms (full timeout), AssertionException after 185 attempts.
// "Last error: Expected to be true but found False": the OCE was converted to
// "predicate returned false" and the loop kept polling.
// Probe 2: supplier throws OCE mid-poll (cts.CancelAfter(100 ms)).
// Observed: elapsed ~3006 ms (full timeout), AssertionException after 278 attempts.
// Same swallow-and-retry; external cancellation cannot reach Eventually.
// Probe 3: baseline (supplier returns false). Sanity check.
// Observed: elapsed ~508 ms with a 500ms internal timeout. Behaves as expected;
// confirms polling cadence is ~10ms.

The OperationCanceledException from the supplier is completely transparent to the assertion machinery; the loop treats it as a predicate failure and continues until the internal TimeSpan expires. A 30-second poll cannot abort in <100 ms even when the harness asks for it.

Task.WaitAsync(TimeSpan, CancellationToken) (added in .NET 6) is the canonical BCL precedent for this exact API shape: a timeout-bearing wait that also accepts a cooperative cancellation token. Eventually and WaitsFor are the natural targets for the same pattern.

Proposed Solution

Additive overload. Old callers compile and behave unchanged:

public WaitsForAssertion<T> Eventually<T>(
    Func<ValueAssertion<T>, AssertionResult> assertion,
    TimeSpan timeout,
    CancellationToken cancellationToken = default);

public WaitsForAssertion<T> WaitsFor<T>(
    Func<ValueAssertion<T>, AssertionResult> assertion,
    TimeSpan timeout,
    CancellationToken cancellationToken = default);

Semantics

  1. Honour cancellation immediately between iterations of the polling loop. If the token is already cancelled on entry, throw OperationCanceledException without invoking the predicate.
  2. Propagate cancellation from the predicate. If the predicate throws OperationCanceledException chained to the supplied token, propagate it out of Eventually. Do not swallow-and-retry.
  3. Preserve the internal-timeout contract. If the predicate continues to fail without throwing OCE, after timeout elapses the original AssertionException is thrown as today.
  4. Compose with the existing predicate signature. The predicate does not gain a CancellationToken parameter; the token is wired into the polling-loop infrastructure only.

External cancellation and internal timeout are distinguishable at the call site by exception type: OperationCanceledException vs AssertionException.

Proposed defaults for the open design points

Positions taken upfront to keep the issue actionable; any can be reversed during discussion.

  1. Parameter name: cancellationToken (matches the BCL and TUnit's [Test]-injected CT convention).
  2. Signature shape: single overload with CancellationToken cancellationToken = default rather than two non-defaulting overloads. Friendlier for the common case where [Test]-injected tokens flow through without ceremony.
  3. Pre-cancelled-token shortcut: yes; ThrowIfCancellationRequested() at the top before invoking the predicate. Matches BCL convention (Task.Run(action, ct) etc.).
  4. Exception type for external cancellation: plain OperationCanceledException. No TUnit-specific wrapper. Friendliest for consumer try/catch (OperationCanceledException) patterns.
  5. Scope: this issue covers Eventually and WaitsFor only. Other timeout-bearing assertion DSL methods (Repeats, etc.) are out of scope unless you signal otherwise; can follow up in a separate issue.

Alternatives Considered

Today's portable workaround pairs Eventually with an external token via a Task.WhenAny race:

private static async Task<bool> RaceEventuallyAsync(
    Func<Task<bool>> predicate, CancellationToken cancellationToken)
{
    Task eventuallyTask = RunEventuallyAsync(predicate);
    var cancellationTcs = new TaskCompletionSource(
        TaskCreationOptions.RunContinuationsAsynchronously);
    await using CancellationTokenRegistration registration = cancellationToken.Register(
        static state => ((TaskCompletionSource)state!).TrySetResult(),
        cancellationTcs);
    Task winner = await Task.WhenAny(eventuallyTask, cancellationTcs.Task).ConfigureAwait(false);
    if (winner != eventuallyTask)
        cancellationToken.ThrowIfCancellationRequested();

    try { await eventuallyTask.ConfigureAwait(false); return true; }
    catch (AssertionException) { return false; }
}

Portable but boilerplate (~25 lines per shared helper). Every helper that polls an HTTP or JSON response carries some variant of it. The TaskCompletionSource + CancellationTokenRegistration + winner-comparison plumbing duplicates across helper-of-helpers a test suite carries.

Other alternatives considered and rejected:

  • Predicate-side cancellation only. The predicate can already accept the same CancellationToken (and typically does, since the getter is async () => await httpClient.GetAsync(uri, ct)). But Eventually swallows the resulting OperationCanceledException and retries (probe evidence above), so the predicate's own CT support does not propagate.
  • Wrap Eventually in Task.Run(..., ct). Task.Run cancellation only prevents the work from starting; once running, the Eventually task continues to completion.
  • Use a shorter TimeSpan to bound the unwind delay. Cuts the worst case but defeats the purpose of Eventually for long-running polls (background workers, eventually-consistent reads).

Feature Category

Assertions

How important is this feature to you?

Important - significantly impacts my workflow

Additional Context

Test cases

Three regression tests would prove the contract:

[Test]
public async Task Eventually_PropagatesExternalCancellation()
{
    using var cts = new CancellationTokenSource();
    cts.CancelAfter(TimeSpan.FromMilliseconds(100));

    var stopwatch = Stopwatch.StartNew();
    await Assert.That(async () =>
        await Assert.That(() => false)
            .Eventually(a => a.IsTrue(), TimeSpan.FromSeconds(30), cts.Token))
        .Throws<OperationCanceledException>();
    stopwatch.Stop();

    await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromSeconds(2));
}

[Test]
public async Task Eventually_ThrowsAssertionExceptionOnInternalTimeout_WhenTokenNotCancelled()
{
    using var cts = new CancellationTokenSource();
    await Assert.That(async () =>
        await Assert.That(() => false)
            .Eventually(a => a.IsTrue(), TimeSpan.FromMilliseconds(200), cts.Token))
        .Throws<AssertionException>();
}

[Test]
public async Task Eventually_HonoursPreCancelledToken()
{
    using var cts = new CancellationTokenSource();
    await cts.CancelAsync();
    await Assert.That(async () =>
        await Assert.That(() => true /* would pass! */)
            .Eventually(a => a.IsTrue(), TimeSpan.FromSeconds(5), cts.Token))
        .Throws<OperationCanceledException>();
}

Implementation sketch

internal async Task<TResult> RunPollAsync<TResult>(
    Func<Task<TResult>> getter,
    Func<TResult, AssertionResult> assertion,
    TimeSpan timeout,
    CancellationToken cancellationToken)
{
    using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    timeoutCts.CancelAfter(timeout);

    while (true)
    {
        cancellationToken.ThrowIfCancellationRequested();   // external-cancel check first

        var result = await getter().ConfigureAwait(false);
        var assertionResult = assertion(result);
        if (assertionResult.IsSuccess)
            return result;

        if (timeoutCts.IsCancellationRequested)
            throw new AssertionException(assertionResult.ToFailureMessage());

        try
        {
            await Task.Delay(PollInterval, timeoutCts.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            throw;   // external cancel: propagate
        }
        catch (OperationCanceledException)
        {
            // internal timeout: loop iterates once more to throw AssertionException above
        }
    }
}

External cancel and internal timeout are distinguishable at the catch site via cancellationToken.IsCancellationRequested.

Related issues

No existing issue covers CancellationToken support on Eventually or WaitsFor.

Effort estimate

  • API surface: two overloads on the Assert.That(...) chain.
  • Implementation: localised change inside the poll-loop. ~30-60 lines including exception distinction.
  • Tests: three regression tests as sketched above.
  • Docs: one XML doc entry on each new overload.

Contribution

  • I'm willing to submit a pull request for this feature

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions