Skip to content

Add cancellation coverage for the internal-end execute path - #4546

Merged
cheenamalhotra merged 3 commits into
dev/cheena/fix-async-cancel-attentionfrom
dev/automation/pr4435-internal-end-cancellation-tests
Aug 19, 2026
Merged

Add cancellation coverage for the internal-end execute path#4546
cheenamalhotra merged 3 commits into
dev/cheena/fix-async-cancel-attentionfrom
dev/automation/pr4435-internal-end-cancellation-tests

Conversation

@mdaigle

@mdaigle mdaigle commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Stacked on #4435. Base is dev/cheena/fix-async-cancel-attention, so the diff here is just the two tests.

Opening as a draft because both new tests fail against the current head of #4435 by design. They are the reproduction for the review feedback on that PR.

Why

#4435 removes lock (_stateObj) from EndExecuteReaderAsync, EndExecuteNonQueryAsync and EndExecuteXmlReaderAsync, and keeps it in CreateLocalCompletionTask. The five tests it adds all exercise the paths where the lock was removed, so they pass with or without its own change. Nothing covers the path that is still locked.

That path is reachable in production whenever column encryption is enabled and the parameter metadata came from the cache.

What the tests show

Cancelling 1s into a 60s WAITFOR, with the metadata cache warm and _internalEndExecuteInitiated confirmed true:

result
lock retained (head of #4435) 60018ms, no exception, ExecuteReaderAsync returns a reader
lock removed 1016ms, SqlException with Number == 0

Number == 0 is the error TdsParser.TryRun adds when it consumes the attention ack, so it is a direct signal that the round trip happened.

Full DataReaderCancellationTest class:

The two tests

CancellationOnInternalEndExecutePath_SendsAttention forces the path with the existing DEBUG _forceInternalEndQuery hook, so it runs without an Always Encrypted setup. It no-ops against a Release driver, where the hook is compiled out.

TestAsyncCancellationSendsAttention_WithAlwaysEncryptedCommand_AfterPartialResults is the Always Encrypted equivalent, verified against SQL Server 2022 with a custom key store provider.

It differs from the existing TestAsyncCancellationSendsAttention_WithAlwaysEncryptedCommand in one way: it flushes a partial result before the delay. Both shapes reach CreateLocalCompletionTask, but only this one catches the bug.

query shape result
WAITFOR DELAY @Delay; SELECT ... (existing test) cancels in 27ms, passes
RAISERROR(...) WITH NOWAIT; WAITFOR DELAY @Delay; SELECT ... 60018ms, reader returned, fails

With WAITFOR first, nothing comes back until the delay expires, so the continuation never runs while the server is busy and cancellation is handled out in ExecuteReaderAsync's await. The lock is never held across the blocking read, which is the thing under test.

Suggested resolution on #4435

Remove the lock from CreateLocalCompletionTask as well. The justification comment there says the continuation runs after the data is buffered, but localCompletion is completed by BeginExecuteReaderInternalReadStage_stateObj.ReadSni(completion), which fires on the first packet.

ac8acd72 restored that lock to fix an AE test. The cancellation exception on the unlocked path is a SqlException carrying two errors, A severe error occurred on the current command. followed by Operation cancelled by user., and TestCancellationToken asserts an exact message match. That is a correct cancellation, so the assertion is worth relaxing to check Number == 0 instead.

  • Tests added
  • Public API changes documented (none)
  • Verified against a live SQL Server 2022 instance
  • No breaking changes

The tests in #4435 all exercise the paths where lock (_stateObj) was
removed, so they pass with or without the change to
CreateLocalCompletionTask. Nothing covers the path that is still locked.

Adds two tests:

- CancellationOnInternalEndExecutePath_SendsAttention forces the
  internal-end path with the existing DEBUG _forceInternalEndQuery hook,
  so it runs without an Always Encrypted setup and no-ops against a
  Release driver.

- TestAsyncCancellationSendsAttention_WithAlwaysEncryptedCommand_AfterPartialResults
  is the Always Encrypted equivalent. The existing AE test puts WAITFOR
  first, so nothing returns until the delay expires and the continuation
  never holds the monitor while the server is busy. Flushing a partial
  result via RAISERROR WITH NOWAIT before the delay is what gets the
  continuation into endFunc, and therefore into lock (_stateObj), while
  the read is still blocked.

Both fail against the current head of dev/cheena/fix-async-cancel-attention:
ExecuteReaderAsync returns a reader after the full 60s WAITFOR with no
exception, so cancellation is silently dropped. Removing the lock from
CreateLocalCompletionTask makes them pass, and the five tests already in
#4435 keep passing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings August 17, 2026 18:53
@github-project-automation github-project-automation Bot moved this to To triage in SqlClient Board Aug 17, 2026
@mdaigle
mdaigle changed the base branch from main to dev/cheena/fix-async-cancel-attention August 17, 2026 18:54

Copilot AI 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.

Pull request overview

This PR adds two new manual tests intended to reproduce and prevent a regression where async cancellation fails to send a TDS attention signal on the “internal end” completion path (notably reachable with Always Encrypted + cached parameter metadata, or forced via a DEBUG-only hook). The changes extend the existing cancellation coverage by ensuring the scenario includes an early “partial results” packet before a server-side block.

Changes:

  • Added a new DataReaderCancellationTest case that forces the internal-end path via the DEBUG-only _forceInternalEndQuery hook.
  • Added a new Always Encrypted cancellation test variant that flushes partial results (RAISERROR ... WITH NOWAIT) before WAITFOR, to exercise the internal-end path while the server is blocked.
  • Extended the manual-test reflection helper to access _forceInternalEndQuery.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/DataReaderTest/DataReaderCancellationTest.cs Adds a DEBUG-hook-based regression test for cancellation on the internal-end execute path.
src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Common/SystemDataInternals/CommandHelper.cs Adds reflection access for _forceInternalEndQuery to drive the new regression test.
src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ApiShould.cs Adds an AE cancellation test variant that flushes partial results before a blocking delay.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +2455 to +2462
System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew();
Task<SqlDataReader> execTask = sqlCommand.ExecuteReaderAsync(cts.Token);

// Wait for the RAISERROR NOWAIT packet so we know the continuation is
// inside endFunc before cancelling.
var infoMessageReceived = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
sqlConnection.InfoMessage += (_, __) => infoMessageReceived.TrySetResult(true);
await Task.WhenAny(infoMessageReceived.Task, Task.Delay(TimeSpan.FromSeconds(5)));
Comment on lines +379 to +386
Stopwatch stopwatch = Stopwatch.StartNew();
Task<SqlDataReader> execTask = command.ExecuteReaderAsync(cts.Token);

// Let the RAISERROR NOWAIT packet land so the internal-end continuation is
// inside endFunc, and therefore inside lock (_stateObj), before we cancel.
var infoMessageReceived = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
connection.InfoMessage += (_, __) => infoMessageReceived.TrySetResult(true);
await Task.WhenAny(infoMessageReceived.Task, Task.Delay(System.TimeSpan.FromSeconds(5)));
CancellationOnInternalEndExecutePath_SendsAttention returned early when the
DEBUG-only _forceInternalEndQuery hook was absent, so against a Release build
of the driver it reported as passed while covering nothing. CI on the stacked
PR showed only the Always Encrypted variant failing, which is the signature of
this test quietly no-opping.

Gate it on a DataTestUtility.IsForceInternalEndQuerySupported condition so the
run summary shows it as skipped and the lost coverage is visible.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 18, 2026 21:41

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ApiShould.cs:2462

  • This test starts ExecuteReaderAsync before subscribing to InfoMessage and doesn’t verify the RAISERROR WITH NOWAIT message was actually observed. That makes the setup racy: if the INFO token arrives before the handler is attached (or never arrives), the test can cancel without confirming it reached the intended “partial results received/internal-end continuation running” state, producing false positives/flakiness. Attach the handler first, assert it fires within the timeout, and detach it before continuing so later commands on the same connection aren’t affected.
                        // Wait for the RAISERROR NOWAIT packet so we know the continuation is
                        // inside endFunc before cancelling.
                        var infoMessageReceived = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
                        sqlConnection.InfoMessage += (_, __) => infoMessageReceived.TrySetResult(true);
                        await Task.WhenAny(infoMessageReceived.Task, Task.Delay(TimeSpan.FromSeconds(5)));

src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/DataReaderTest/DataReaderCancellationTest.cs:382

  • The test starts ExecuteReaderAsync before subscribing to InfoMessage and doesn’t assert that the RAISERROR WITH NOWAIT message was actually observed. That creates a race where the INFO token can be missed, and the test can proceed to cancel without confirming it reached the intended “partial results received/internal-end continuation running” state (risking false positives/flakiness). Subscribe before starting the command, assert the InfoMessage arrives within the timeout, and unsubscribe to avoid affecting later commands (see ExceptionTest.cs:39-49 for the add/remove pattern).
                        // Let the RAISERROR NOWAIT packet land so the internal-end continuation is
                        // inside endFunc, and therefore inside lock (_stateObj), before we cancel.
                        var infoMessageReceived = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
                        connection.InfoMessage += (_, __) => infoMessageReceived.TrySetResult(true);
                        await Task.WhenAny(infoMessageReceived.Task, Task.Delay(System.TimeSpan.FromSeconds(5)));

Matches the fix cheenamalhotra made in b16406c for
CancellationSendsAttention_WhenPartialResultsReceived. Both new tests had the
same ordering problem I raised in review: the handler was attached after
ExecuteReaderAsync was dispatched, so the RAISERROR ... WITH NOWAIT token could
arrive before anything was listening, silently degrading the handshake to a
fixed timer.

Also asserts the InfoMessage actually arrived, so a missed handshake fails
loudly instead of weakening the test.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 18, 2026 21:47

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/DataReaderTest/DataReaderCancellationTest.cs:387

  • The test intends to cancel only after the RAISERROR ... WITH NOWAIT InfoMessage arrives (so cancellation happens in the "partial results received" state), but the current WhenAny(..., Delay(10s)) result is ignored. If the delay wins, the test still cancels and may later see InfoMessageReceived.Task.IsCompleted become true (arriving after cancellation), weakening the assertion and potentially causing false results under slow/loaded environments. Capture the completed task and fail immediately if the InfoMessage was not received before cancellation.
                        // Let the RAISERROR NOWAIT packet land so the internal-end continuation is
                        // inside endFunc, and therefore inside lock (_stateObj), before we cancel.
                        await Task.WhenAny(infoMessageReceived.Task, Task.Delay(System.TimeSpan.FromSeconds(10)));

                        long cancelledAtMs = stopwatch.ElapsedMilliseconds;

src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ApiShould.cs:2467

  • This test relies on Task.WhenAny(infoMessageReceived, Delay(10s)) to ensure a partial result was flushed before cancelling, but it doesn't check which task completed. If the delay wins, cancellation may happen before the InfoMessage is processed, and infoMessageReceived.Task.IsCompleted can still become true later (after cancellation), reducing the test’s ability to prove it exercised the internal-end partial-results path. Capture the WhenAny result and assert the InfoMessage arrived before calling Cancel().
                        // Wait for the RAISERROR NOWAIT packet so we know the continuation is
                        // inside endFunc before cancelling.
                        await Task.WhenAny(infoMessageReceived.Task, Task.Delay(TimeSpan.FromSeconds(10)));

                        long cancelledAtMs = stopwatch.ElapsedMilliseconds;

@cheenamalhotra
cheenamalhotra merged commit 120ba67 into dev/cheena/fix-async-cancel-attention Aug 19, 2026
242 of 353 checks passed
@cheenamalhotra
cheenamalhotra deleted the dev/automation/pr4435-internal-end-cancellation-tests branch August 19, 2026 01:09
@github-project-automation github-project-automation Bot moved this from To triage to Done in SqlClient Board Aug 19, 2026
@cheenamalhotra

Copy link
Copy Markdown
Member

Thanks for putting this together — the _forceInternalEndQuery hook is the piece that made this testable without an AE setup, and I've merged this branch into #4435 (commit 120ba67).

To close the loop on the finding: the internal-end path is now fixed there rather than left as a known gap. I removed lock (_stateObj) from CreateLocalCompletionTask, which is what was starving TdsParserStateObject.Cancel()'s Monitor.TryEnter poll. Verified against a live server — CancellationOnInternalEndExecutePath_SendsAttention returned at 60005ms before the change and passes in ~1s after.

The reason the earlier revision reverted this (commit ac8acd72) turned out to be the AE TestCancellationToken assertion, not the fix: on the unlocked path the exception is a SqlException with Number == 0 carrying two errors — A severe error occurred on the current command. followed by Operation cancelled by user. The first is what TryRun adds when it consumes the attention ack, so that is correct behaviour; the test was asserting an exact whole-message match. I relaxed it to Assert.Contains.

Since the tests and the fix now both live in #4435, this PR can be closed — happy to do that, or leave it to you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants