Add cancellation coverage for the internal-end execute path - #4546
Conversation
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>
There was a problem hiding this comment.
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
DataReaderCancellationTestcase that forces the internal-end path via the DEBUG-only_forceInternalEndQueryhook. - Added a new Always Encrypted cancellation test variant that flushes partial results (
RAISERROR ... WITH NOWAIT) beforeWAITFOR, 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.
| 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))); |
| 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>
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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 seeInfoMessageReceived.Task.IsCompletedbecome 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, andinfoMessageReceived.Task.IsCompletedcan still become true later (after cancellation), reducing the test’s ability to prove it exercised the internal-end partial-results path. Capture theWhenAnyresult and assert the InfoMessage arrived before callingCancel().
// 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;
120ba67
into
dev/cheena/fix-async-cancel-attention
|
Thanks for putting this together — the To close the loop on the finding: the internal-end path is now fixed there rather than left as a known gap. I removed The reason the earlier revision reverted this (commit 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. |
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)fromEndExecuteReaderAsync,EndExecuteNonQueryAsyncandEndExecuteXmlReaderAsync, and keeps it inCreateLocalCompletionTask. 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_internalEndExecuteInitiatedconfirmed true:ExecuteReaderAsyncreturns a readerSqlExceptionwithNumber == 0Number == 0is the errorTdsParser.TryRunadds when it consumes the attention ack, so it is a direct signal that the round trip happened.Full
DataReaderCancellationTestclass:ExecuteReaderAsync returned a reader 60015ms after cancellation was requestedCreateLocalCompletionTask: 6 passed, the new one in 12msThe two tests
CancellationOnInternalEndExecutePath_SendsAttentionforces the path with the existing DEBUG_forceInternalEndQueryhook, so it runs without an Always Encrypted setup. It no-ops against a Release driver, where the hook is compiled out.TestAsyncCancellationSendsAttention_WithAlwaysEncryptedCommand_AfterPartialResultsis the Always Encrypted equivalent, verified against SQL Server 2022 with a custom key store provider.It differs from the existing
TestAsyncCancellationSendsAttention_WithAlwaysEncryptedCommandin one way: it flushes a partial result before the delay. Both shapes reachCreateLocalCompletionTask, but only this one catches the bug.WAITFOR DELAY @Delay; SELECT ...(existing test)RAISERROR(...) WITH NOWAIT; WAITFOR DELAY @Delay; SELECT ...With
WAITFORfirst, nothing comes back until the delay expires, so the continuation never runs while the server is busy and cancellation is handled out inExecuteReaderAsync'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
CreateLocalCompletionTaskas well. The justification comment there says the continuation runs after the data is buffered, butlocalCompletionis completed byBeginExecuteReaderInternalReadStage→_stateObj.ReadSni(completion), which fires on the first packet.ac8acd72restored that lock to fix an AE test. The cancellation exception on the unlocked path is aSqlExceptioncarrying two errors,A severe error occurred on the current command.followed byOperation cancelled by user., andTestCancellationTokenasserts an exact message match. That is a correct cancellation, so the assertion is worth relaxing to checkNumber == 0instead.