Skip to content

Conversation

@thomhurst
Copy link
Owner

Summary

  • Uses Task.WaitAsync(CancellationToken) on .NET 6+ for efficient cancellation handling when no timeout is specified
  • Avoids TCS + Register + WhenAny allocations in the common no-timeout path
  • Preserves short-circuit behavior when engine cancellation is requested

Fixes #4287

Test plan

  • Unit tests pass (94/94)
  • Benchmarks show reduced allocations in no-timeout path

🤖 Generated with Claude Code

@thomhurst
Copy link
Owner Author

Summary

Optimizes TimeoutHelper to use Task.WaitAsync(CancellationToken) on .NET 6+ for the no-timeout path to reduce allocations.

Critical Issues

This PR doesn't implement the optimization described in issue #4287. ⚠️

Issue #4287 correctly identifies that when no timeout is configured, we don't need to "race" against cancellation at all. The suggested fix is to simply await the task directly:

if (!timeout.HasValue)
{
    return await taskFactory(cancellationToken).ConfigureAwait(false);
}

Why this matters:

  1. The task factory already receives the cancellationToken (line 62) - if cancellation occurs, the task will throw OperationCanceledException naturally through cooperative cancellation
  2. Task.WaitAsync(CancellationToken) still has overhead - it internally creates infrastructure similar to the current TCS+Register pattern
  3. No "short-circuit" is actually happening - Both the current code and the PR's WaitAsync approach stop waiting for the task on cancellation, but don't stop the task itself. Only cooperative cancellation (checking the token) does that.

Recommendation:

Remove the entire if (cancellationToken.CanBeCanceled) block (lines 64-78) and replace with:

if (!timeout.HasValue)
{
    return await taskFactory(cancellationToken).ConfigureAwait(false);
}

This achieves the goal stated in #4287: "Reduces per-test overhead by ~5% for tests without timeouts" by eliminating all the allocation overhead, not just reducing it.

Suggestions

The .gitignore change (adding .worktrees/) is fine but unrelated to the performance fix - consider moving it to a separate housekeeping PR.

Verdict

⚠️ REQUEST CHANGES - The implementation doesn't achieve the performance goal described in the linked issue.

Remove unnecessary cancellation racing infrastructure when no timeout
is specified. The task factory already receives the cancellation token,
so cancellation is handled cooperatively by the task itself.

This eliminates all TCS, Register, and WhenAny allocations in the
no-timeout code path.

Fixes #4287

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@thomhurst
Copy link
Owner Author

Addressed review feedback:

  • Simplified the no-timeout path to just await taskFactory(cancellationToken).ConfigureAwait(false)
  • Removed all TCS, Register, and WhenAny infrastructure since cooperative cancellation via the token is sufficient
  • This eliminates all allocations in the no-timeout code path

@thomhurst
Copy link
Owner Author

Summary

This PR simplifies the no-timeout fast path in TimeoutHelper by removing the TaskCompletionSource racing pattern and directly awaiting the task.

Critical Issues

1. Semantic Change in Cancellation Behavior

The PR changes cancellation semantics in a potentially breaking way. With the OLD code, when cancellationToken fires, the await completes immediately by racing against a TCS. With the NEW code, the await only completes when the task itself checks the token and throws OperationCanceledException.

This could cause tests to not cancel promptly when the engine requests cancellation, especially if the task is in a long-running synchronous operation or awaiting something that doesn't respect cancellation.

2. Misleading PR Description

The PR description states it uses Task.WaitAsync(CancellationToken) on .NET 6+, but the actual code does NOT use Task.WaitAsync at all - it just does a direct await.

Suggestions

Use Task.WaitAsync to preserve semantics while improving performance. This would eliminate TCS allocations on .NET 8+ while preserving cancellation racing behavior and matching the PR description.

Verdict

REQUEST CHANGES - The current implementation changes cancellation semantics. Consider using Task.WaitAsync to achieve the performance goal while preserving existing behavior.

Address review feedback by using Task.WaitAsync(CancellationToken)
on .NET 6+ to preserve the "stop waiting immediately on cancellation"
semantics while eliminating TCS allocations.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@thomhurst
Copy link
Owner Author

Summary

This PR optimizes TimeoutHelper to use Task.WaitAsync(CancellationToken) on .NET 6+ to avoid TCS/registration allocations when no timeout is specified.

Critical Issues

Behavioral regression on pre-.NET 6 frameworks:

The fallback path for .NET Framework/.NET Standard 2.0 (TUnit.Engine/Helpers/TimeoutHelper.cs:69-71) changes cancellation behavior by removing immediate-return-on-cancellation.

OLD BEHAVIOR: When cancellationToken is cancelled, control returns immediately (the TCS task wins the race)
NEW BEHAVIOR: Waits for the task to cooperatively check the token and throw

This violates the documented contract and could cause tests to hang on older frameworks if the task doesn't frequently check the token.

FIX: Keep the TCS+WhenAny pattern for pre-.NET 6 frameworks to preserve immediate cancellation behavior, or explicitly document that immediate cancellation only works on .NET 6+.

Suggestions

The .NET 6+ path looks good - WaitAsync is exactly the right tool for this optimization.

Verdict

REQUEST CHANGES - Pre-.NET 6 behavioral regression must be addressed

This was referenced Jan 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: TimeoutHelper creates unnecessary allocations when no timeout is configured

2 participants