Skip to content

NCBC-4336: Complete WhenAnySuccessful when the caller's token cancels - #241

Open
davidkelly wants to merge 2 commits into
masterfrom
NCBC-4336
Open

davidkelly wants to merge 2 commits into
masterfrom
NCBC-4336

Conversation

@davidkelly

@davidkelly davidkelly commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

GetAnyReplicaAsync and LookupInAnyReplicaAsync never returned if the caller's CancellationToken was cancelled before every replica read finished. TaskHelpers.WhenAnySuccessful completed its result only from continuations registered with that token, and a cancelled continuation never runs. LazyCancellation would not fix this: it only delays the cancellation, and the continuation still never runs.

Changes in WhenAnySuccessful

  • Register the continuations with CancellationToken.None. The caller's token now cancels the result through a Register callback, which is disposed once the result completes.
  • Run the continuations inline (ExecuteSynchronously), and create the result with RunContinuationsAsynchronously so the caller's own code never runs inside cts.Cancel() or a replica's completion.
  • Remove the CancellationTokenSource, which was never disposed and whose token nothing read (NCBC-4249).
  • Fix the fail-safe: observe the WhenAll fault, and return once the result is complete in any state. Before, every replica fault was reported as an unobserved task exception, and the fail-safe could throw from a discarded continuation.

Tests

  • Rewrite AllCanceled and AllFailed. They discarded their assertions under #pragma warning disable xUnit2021, so they could never fail.
  • Add tests for:
    • cancel, then every replica fails
    • cancel, then one replica succeeds
    • token already cancelled when called
    • one replica succeeds, then cancel

All of these use TaskCompletionSource and an explicit Cancel(), and check the state immediately, with no delays or timeouts.

Verification

  • Against master, 5 of the new tests fail in under 1 ms each, with no hang. The success-then-cancel test passes on both.
  • TaskHelpersTests passed in 25 of 25 runs on net8.0.
  • The full net8.0 unit suite passes.
  • The library builds with no warnings for every target framework.
  • net48 was not run locally.

Behaviour change: callers now always resume on the thread pool after awaiting WhenAnySuccessful.

🤖 Generated with Claude Code

Motivation
==========
WhenAnySuccessful, behind GetAnyReplicaAsync and
LookupInAnyReplicaAsync, completed its result only from continuations
registered with the caller's token. A token cancelled before every
replica finished cancelled those continuations without running them,
so the call never returned. Two tests that should have caught it
discarded their assertions. The CancellationTokenSource the method
created was never disposed (NCBC-4249), and every replica fault was
reported as an unobserved task exception.

Modification
============
Register the continuations with CancellationToken.None and let the
token cancel the result through a registration, disposed once the
result completes. Run the continuations inline and the caller's
continuation asynchronously. Remove the unused CancellationTokenSource.
Make the fail-safe observe the WhenAll fault and stop once the result
is complete in any state.

Rewrite the two masked tests and add cancellation tests. They drive
the replicas with TaskCompletionSource and assert synchronously, with
no delays or timeouts.

Results
=======
Cancelling the token completes the call as cancelled at once. Five of
the new tests fail on master and pass with the fix; the unit suite
passes on net8.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

FIT performer image

Published:

ghcr.io/couchbase/dotnet-fit-performer:NCBC-4336

Run FIT locally against this PR:

fit run preset <preset-name> --performer dotnet-fit-performer:NCBC-4336

Or run it from the workflow here.

Note

Each push to this PR replaces the image and edits this comment. The image is deleted 7 days after the last push.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain for the changes addressing cancellation hangs in WhenAnySuccessful.

No open issues 🎉

What changed in this PR

This PR fixes cancellation hangs in WhenAnySuccessful, which supports GetAnyReplicaAsync and LookupInAnyReplicaAsync.

Changes:

  • Cancel the result directly through the caller’s token while allowing replica continuations to run independently.
  • Dispose the cancellation registration, observe aggregate faults, and safely handle already-completed results.
  • Replace ineffective exception assertions and add deterministic tests for cancellation and replica completion ordering.
File Description
tests/​Couchbase.UnitTests/​Utils/​TaskHelpersTests.cs Repairs exception assertions and covers cancellation before and after replica completion.
src/​Couchbase/​Utils/​TaskHelpers.cs Fixes cancellation handling, continuation scheduling, and fail-safe completion.

Review effort: Balanced

@davidkelly
davidkelly marked this pull request as ready for review October 30, 2026 16:46

@emilienbev emilienbev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

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.

3 participants