feat(assertions): add CancellationToken overload to WaitsFor and Eventually#5938
Conversation
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 4 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Code Review — feat(assertions): add CancellationToken to WaitsFor and Eventually
Overall this is a well-designed, thoughtful addition. The edge cases are properly considered, the tests are comprehensive, and the public API snapshots are correctly updated for all four TFMs. A few items worth discussing:
1. Conditional linked-CTS construction — correct and good
using var linkedCts = _cancellationToken.CanBeCanceled
? CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken)
: new CancellationTokenSource();
linkedCts.CancelAfter(_timeout);Checking CanBeCanceled before linking is the right call — it avoids a heap-allocated CancellationTokenRegistration for every assertion invocation when no external token is supplied. Good optimisation that matches TUnit's performance-first principle.
2. Exception-filter ordering in Task.Delay catch — correct
catch (OperationCanceledException) when (_cancellationToken.IsCancellationRequested)
{
throw; // external
}
catch (OperationCanceledException)
{
break; // internal timeout
}The when filter correctly distinguishes external from internal cancellation. The one inherent race is if both fire at exactly the same instant: IsCancellationRequested will be true and we throw OCE rather than producing AssertionResult.Failed. This is arguably the right semantics (external cancellation takes priority), so the implementation is sound.
3. CancelAsync() in tests — verify .NET Framework build
WaitsFor_Honours_PreCancelled_Token_Before_First_Poll calls await cts.CancelAsync(), which was added in .NET 8. The public-API snapshots include Net4_7.verified.txt, which implies TUnit targets net472 somewhere. If TUnit.Assertions.Tests also compiles against net472, this test will fail to compile.
Suggestion: Either add #if NET8_0_OR_GREATER guards or replace with the synchronous cts.Cancel() (which is equivalent for a pre-cancel scenario and works on all TFMs).
4. Comment density exceeds project style guide
CLAUDE.md states "Default to writing no comments. Only add one when the WHY is non-obvious." Several comments in WaitsForAssertion.cs describe WHAT the code does rather than WHY it exists:
// Honour external cancellation between iterations: a pre-cancelled token, or a token
// cancelled during the previous Task.Delay, exits the loop without invoking the
// predicate. Internal-timeout cancellation falls through to AssertionResult.Failed.
_cancellationToken.ThrowIfCancellationRequested();The code is self-explanatory here. The one comment worth keeping is the CanBeCanceled block — the WHY (avoiding registration overhead) is genuinely non-obvious. The others could be removed.
5. Timing-sensitive test margins — probably fine, but note the risk
Tests like:
await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromSeconds(1));...with a 100ms cancel delay and a 30s internal timeout leave a comfortable 10× margin. In practice this should be robust, but under heavy CI load even 100ms operations can stretch. If these become flaky, widening the margin (e.g. < TimeSpan.FromSeconds(5)) would be the fix without changing the semantics.
6. Breaking change documentation is thorough
The PR description clearly enumerates affected call sites and the resolution path (shift to named arguments). Well-handled.
Summary
This is a solid, mergeable implementation. The two actionable items before merging are:
- Verify
CancelAsync()compiles on net472 (or switch tocts.Cancel()). - Trim the explanatory comments to align with the project's comment policy.
The design choices — parameter position, single defaulted overload, plain OperationCanceledException for external cancellation — are all well-reasoned and match the locked defaults from #5932.
…tually Adds a CancellationToken parameter to WaitsFor and Eventually so external cancellation can interrupt the polling loop before its internal timeout elapses. The token is honoured between iterations and during the polling Task.Delay; external cancellation propagates as OperationCanceledException while the internal timeout still produces the standard AssertionException. The parameter is defaulted to default(CancellationToken), so existing callers compile and behave unchanged. Closes thomhurst#5932.
Baseline regen for the Should public surface (DotNet8_0 / DotNet9_0 /
DotNet10_0): Eventually<TValue> and WaitFor<TValue> now carry the new
CancellationToken parameter, which had been missed in the initial commit and
was failing RunPublicAPITestsModule on all three modularpipeline matrix legs.
Test fixup: WaitsFor_Honours_PreCancelled_Token_Before_First_Poll switches
from cts.CancelAsync() to the synchronous cts.Cancel(). The async cancel is
.NET 8+ only and the test's purpose is verifying pre-cancelled state, for
which the two are equivalent.
Code-style fixup: trimmed WHAT-style explanatory comments in WaitsForAssertion
and the new tests per CLAUDE.md ("only when the WHY is non-obvious"). The
CanBeCanceled-skip-link comment stays; that one explains a non-obvious
optimisation.
Head branch was pushed to by a user without write access
da2e433 to
d3ef1d0
Compare
Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.44.0 to 1.45.8. <details> <summary>Release notes</summary> _Sourced from [TUnit.Core's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.45.8 <!-- Release notes generated using configuration in .github/release.yml at v1.45.8 --> ## What's Changed ### Other Changes * fix(aspire): route CreateHttpClient through IHttpClientFactory by @thomhurst in thomhurst/TUnit#5957 ### Dependencies * chore(deps): update tunit to 1.45.0 by @thomhurst in thomhurst/TUnit#5949 * chore(deps): update dependency dompurify to v3.4.5 by @thomhurst in thomhurst/TUnit#5951 * chore(deps): update dependency microsoft.testing.extensions.codecoverage to 18.7.0 by @thomhurst in thomhurst/TUnit#5953 * chore(deps): update dependency coverlet.collector to 10.0.1 by @thomhurst in thomhurst/TUnit#5952 * chore(deps): update dependency polyfill to 10.6.0 by @thomhurst in thomhurst/TUnit#5955 * chore(deps): update dependency polyfill to 10.6.0 by @thomhurst in thomhurst/TUnit#5954 **Full Changelog**: thomhurst/TUnit@v1.45.0...v1.45.8 ## 1.45.0 <!-- Release notes generated using configuration in .github/release.yml at v1.45.0 --> ## What's Changed ### Other Changes * fix(generator): place CallerArgumentExpression before params in [GenerateAssertion] emit by @JohnVerheij in thomhurst/TUnit#5940 * fix(sourcegen): drop covariant TActual when [GenerateAssertion] method has its own type parameters by @JohnVerheij in thomhurst/TUnit#5935 * feat(assertions): add CancellationToken overload to WaitsFor and Eventually by @JohnVerheij in thomhurst/TUnit#5938 * fix(html-report): extract categories using MTP Key=name convention by @thomhurst in thomhurst/TUnit#5946 * feat(html-report): rewrite as split-pane design template by @thomhurst in thomhurst/TUnit#5947 ### Dependencies * chore(deps): update microsoft.testing to 2.2.3 by @thomhurst in thomhurst/TUnit#5927 * chore(deps): update mstest to 4.2.3 by @thomhurst in thomhurst/TUnit#5928 * chore(deps): update tunit to 1.44.39 by @thomhurst in thomhurst/TUnit#5929 * chore(deps): update aspire to 13.3.3 by @thomhurst in thomhurst/TUnit#5933 * chore(deps): update dependency dompurify to v3.4.4 by @thomhurst in thomhurst/TUnit#5944 * chore(deps): update dependency qs to v6.15.2 by @thomhurst in thomhurst/TUnit#5941 **Full Changelog**: thomhurst/TUnit@v1.44.39...v1.45.0 ## 1.44.39 <!-- Release notes generated using configuration in .github/release.yml at v1.44.39 --> ## What's Changed ### Other Changes * fix(tests): retry trx read to dodge MTP post-exit flush race on Windows by @thomhurst in thomhurst/TUnit#5888 * fix(pipeline): timeout + retry InstallPlaywrightModule so a hung download fails fast by @thomhurst in thomhurst/TUnit#5889 * fix(otel): require two consecutive idle windows in DrainAsync to catch in-transit POSTs by @thomhurst in thomhurst/TUnit#5890 * test(assertions): drop flaky wall-clock upper bound on WaitsFor timeout test by @thomhurst in thomhurst/TUnit#5886 * fix(sourcegen): drop spurious ')' in MethodAssertionGenerator Task<bool> emit by @JohnVerheij in thomhurst/TUnit#5920 * fix(sourcegen): merge generic parameter lists in [AssertionExtension] emit by @JohnVerheij in thomhurst/TUnit#5921 * fix(aspnetcore): scope correlation processor per-factory to stop cross-factory tag leak by @thomhurst in thomhurst/TUnit#5891 * Changed FSharp.Core version to 10.1.300 by @licon4812 in thomhurst/TUnit#5909 * feat(mocks): add Mock.HttpClientFactory() helper by @thomhurst in thomhurst/TUnit#5894 * Harden WaitsFor timeout test by @thomhurst in thomhurst/TUnit#5926 * fix(sourcegen): emit `default` literal for value-type assertion parameters by @JohnVerheij in thomhurst/TUnit#5919 ### Dependencies * chore(deps): update dependency nunit to 4.6.0 by @thomhurst in thomhurst/TUnit#5826 * chore(deps): update tunit to 1.44.0 by @thomhurst in thomhurst/TUnit#5882 * chore(deps): update dependency mockolate to 3.2.0 by @thomhurst in thomhurst/TUnit#5892 * chore(deps): update dependency yaml to v2.9.0 by @thomhurst in thomhurst/TUnit#5887 * chore(deps): update dependency nuget.protocol to 7.6.0 by @thomhurst in thomhurst/TUnit#5897 * chore(deps): update dependency microsoft.entityframeworkcore to 10.0.8 by @thomhurst in thomhurst/TUnit#5898 * chore(deps): update dependency microsoft.templateengine.authoring.cli to v10.0.300 by @thomhurst in thomhurst/TUnit#5899 * chore(deps): update microsoft.extensions by @thomhurst in thomhurst/TUnit#5905 * chore(deps): update microsoft.aspnetcore to 10.0.8 by @thomhurst in thomhurst/TUnit#5904 * chore(deps): update dependency microsoft.templateengine.authoring.templateverifier to 10.0.300 by @thomhurst in thomhurst/TUnit#5902 * chore(deps): update aspire to 13.3.1 by @thomhurst in thomhurst/TUnit#5900 * chore(deps): update dependency system.commandline to 2.0.8 by @thomhurst in thomhurst/TUnit#5903 * chore(deps): update dependency azure.storage.blobs to 12.28.0 by @thomhurst in thomhurst/TUnit#5910 * chore(deps): update dependency dotnet-sdk to v10.0.300 by @thomhurst in thomhurst/TUnit#5901 * chore(deps): update dependency stackexchange.redis to 2.13.1 by @thomhurst in thomhurst/TUnit#5906 * chore(deps): update aspire to 13.3.2 by @thomhurst in thomhurst/TUnit#5924 * chore(deps): bump mermaid from 11.12.2 to 11.15.0 in /docs by @dependabot[bot] in thomhurst/TUnit#5893 * chore(deps): update dependency streamjsonrpc to 2.24.92 by @thomhurst in thomhurst/TUnit#5915 * chore(deps): update dependency dompurify to v3.4.3 by @thomhurst in thomhurst/TUnit#5913 * chore(deps): update microsoft.build to 18.6.3 by @thomhurst in thomhurst/TUnit#5914 **Full Changelog**: thomhurst/TUnit@v1.44.0...v1.44.39 Commits viewable in [compare view](thomhurst/TUnit@v1.44.0...v1.45.8). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Description
Adds a
CancellationTokenparameter toWaitsForandEventually. The token is honoured between iterations of the polling loop, and the internalTask.Delayis wired through a linked cancellation source so both external cancellation and the internal timeout break the sleep. External cancellation propagates asOperationCanceledException; the internal timeout still produces the standardAssertionException.The parameter is defaulted (
CancellationToken cancellationToken = default), so callers using named arguments compile and behave unchanged.Implements the locked defaults proposed in #5932:
cancellationTokenThrowIfCancellationRequestedat the top of each iteration)OperationCanceledException(no TUnit wrapper)WaitsForandEventuallyonly (no change to other timeout-bearing assertions)Any of these defaults can be reversed in review.
Breaking changes
The new
cancellationTokenparameter is positioned betweenpollingIntervaland the[CallerArgumentExpression]parameters ofWaitsFor/Eventually, matching the convention used elsewhere inAssertionExtensions.cs(real parameters first, compiler-fill diagnostic parameters last).Impact on callers:
WaitsForAssertionTests.cs): unaffected.pollingInterval: unaffected.timeoutExpressionorpollingIntervalExpressiondirectly (overriding the compiler's auto-fill; non-standard but legal): CS0029 type mismatch at the 5th positional argument. Resolution is a one-line shift to named arguments.The functional contract for existing callers is preserved: when
cancellationTokenis not passed, the linked CTS short-circuits to a plain timeout-only source and behaviour matches the prior release.Related Issue
Fixes #5932
Type of Change
Checklist
Required
TUnit-Specific Requirements
TUnit.Core.SourceGenerator)TUnit.Engine)TUnit.Core.SourceGenerator.Testsand/orTUnit.PublicAPItests.received.txtfiles and accepted them as.verified.txt.verified.txtfiles[DynamicallyAccessedMembers]annotationsdotnet publish -p:PublishAot=trueTesting
dotnet test)Additional Notes
Regression tests added
Five new tests in
WaitsForAssertionTests.cs:WaitsFor_PropagatesExternalCancellation_Before_Internal_Timeout: external CT cancels after 100ms, predicate returns false continuously, internal timeout 30s. AssertsOperationCanceledExceptionand elapsed time under 1 second.WaitsFor_Throws_AssertionException_On_Internal_Timeout_When_Token_Not_Cancelled: CT created but never cancelled, internal timeout 200ms. AssertsAssertionException(the existing contract is preserved when CT is unused).WaitsFor_Honours_PreCancelled_Token_Before_First_Poll: pre-cancelled CT, would-pass predicate, internal timeout 5s. AssertsOperationCanceledExceptionand elapsed under 500ms (proves the loop exits on the first iteration).Eventually_PropagatesExternalCancellation_Before_Internal_Timeout: alias-symmetry test confirming the same cancellation contract holds forEventually(which forwards toWaitsFor).WaitsFor_Propagates_OCE_When_Predicate_Observes_Supplied_Token: predicate callscts.Token.ThrowIfCancellationRequested()after the token is cancelled mid-poll; verifies cancellation propagates within roughly the cancel delay plus one poll interval.Implementation
WaitsForAssertion<TValue>constructor takes the token and stores it as a field.CheckAsyncbuilds a linked CTS when the supplied token is cancellable, otherwise a plain timeout-only CTS. This avoids the linked-registration overhead when no external token is provided.cancellationToken.ThrowIfCancellationRequested()honours external cancel before each predicate invocation.Task.Delay(_pollingInterval, linkedCts.Token)is wrapped in twocatch (OperationCanceledException)clauses, the first using awhen (cancellationToken.IsCancellationRequested)filter to rethrow on external cancel and the second falling through to the existingAssertionResult.Failedpath on internal timeout.Public API snapshot updates
The
TUnit.PublicAPItest project'sTests.Assertions_Library_Has_No_API_Changessnapshots have been regenerated for the four supported TFMs (net472 / net8.0 / net9.0 / net10.0). Each delta is exactly three lines: theWaitsForAssertionconstructor, theWaitsForextension, and theEventuallyextension all gain the new defaultedCancellationTokenparameter.Local verification
TUnit.Assertions.Testssuite: 2111/2111 pass (existing 2106 + 5 new).TUnit.Assertionsonly; no source-generator emit is affected.