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
- Honour cancellation immediately between iterations of the polling loop. If the token is already cancelled on entry, throw
OperationCanceledException without invoking the predicate.
- 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.
- Preserve the internal-timeout contract. If the predicate continues to fail without throwing OCE, after
timeout elapses the original AssertionException is thrown as today.
- 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.
- Parameter name:
cancellationToken (matches the BCL and TUnit's [Test]-injected CT convention).
- 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.
- Pre-cancelled-token shortcut: yes;
ThrowIfCancellationRequested() at the top before invoking the predicate. Matches BCL convention (Task.Run(action, ct) etc.).
- Exception type for external cancellation: plain
OperationCanceledException. No TUnit-specific wrapper. Friendliest for consumer try/catch (OperationCanceledException) patterns.
- 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
Problem Statement
Assert.That(...).Eventually(predicate, timeout)andAssert.That(...).WaitsFor(predicate, timeout)accept aTimeSpanbut noCancellationToken. When a test framework or harness signals external cancellation (Ctrl+C, parent-class timeout, harness abort), the poll cannot be interrupted until its internalTimeSpanelapses.Three probes against TUnit 1.44.39 show the underlying behaviour:
The
OperationCanceledExceptionfrom the supplier is completely transparent to the assertion machinery; the loop treats it as a predicate failure and continues until the internalTimeSpanexpires. 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.EventuallyandWaitsForare the natural targets for the same pattern.Proposed Solution
Additive overload. Old callers compile and behave unchanged:
Semantics
OperationCanceledExceptionwithout invoking the predicate.OperationCanceledExceptionchained to the supplied token, propagate it out ofEventually. Do not swallow-and-retry.timeoutelapses the originalAssertionExceptionis thrown as today.CancellationTokenparameter; the token is wired into the polling-loop infrastructure only.External cancellation and internal timeout are distinguishable at the call site by exception type:
OperationCanceledExceptionvsAssertionException.Proposed defaults for the open design points
Positions taken upfront to keep the issue actionable; any can be reversed during discussion.
cancellationToken(matches the BCL and TUnit's[Test]-injected CT convention).CancellationToken cancellationToken = defaultrather than two non-defaulting overloads. Friendlier for the common case where[Test]-injected tokens flow through without ceremony.ThrowIfCancellationRequested()at the top before invoking the predicate. Matches BCL convention (Task.Run(action, ct)etc.).OperationCanceledException. No TUnit-specific wrapper. Friendliest for consumertry/catch (OperationCanceledException)patterns.EventuallyandWaitsForonly. 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
Eventuallywith an external token via aTask.WhenAnyrace: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:
CancellationToken(and typically does, since the getter isasync () => await httpClient.GetAsync(uri, ct)). ButEventuallyswallows the resultingOperationCanceledExceptionand retries (probe evidence above), so the predicate's own CT support does not propagate.EventuallyinTask.Run(..., ct).Task.Runcancellation only prevents the work from starting; once running, theEventuallytask continues to completion.TimeSpanto bound the unwind delay. Cuts the worst case but defeats the purpose ofEventuallyfor 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:
Implementation sketch
External cancel and internal timeout are distinguishable at the catch site via
cancellationToken.IsCancellationRequested.Related issues
Eventually/Pollfeature delivery): no CT discussion in the body or comments.No existing issue covers
CancellationTokensupport onEventuallyorWaitsFor.Effort estimate
Assert.That(...)chain.Contribution