Fix async cancellation failing to send TDS attention signal - #4435
Fix async cancellation failing to send TDS attention signal#4435cheenamalhotra wants to merge 18 commits into
Conversation
Remove lock(_stateObj) from EndExecuteReaderAsync, EndExecuteNonQueryAsync, and EndExecuteXmlReaderAsync. The lock prevented Cancel() from acquiring the stateObj monitor to send a TDS attention signal when the async completion path was blocked on a synchronous network read (e.g., waiting for metadata during WAITFOR). This caused cancellation via CancellationToken to hang until the query completed naturally. Concurrent close/cancel safety is maintained by: - Parser state checks within TryRun (detects Broken/Closed state) - Cancel()'s polling loop via Monitor.TryEnter with parser state guards - The stateObj's internal synchronization mechanisms Fixes #4424 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Validates that CancellationToken triggers TDS attention when the server has sent partial results (RAISERROR WITH NOWAIT) but is blocked on WAITFOR. This test would previously hang for 60+ seconds without the fix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a deadlock/hang in async cancellation by removing lock(_stateObj) from the async EndExecute* paths so that TdsParserStateObject.Cancel() can acquire the monitor and send a TDS attention signal even when the async end-path is blocked on synchronous network reads (e.g., partial results + WAITFOR).
Changes:
- Removed
lock(_stateObj)fromEndExecuteReaderAsync,EndExecuteNonQueryAsync, andEndExecuteXmlReaderAsyncto avoid blocking cancellation’s attention-send path. - Added a new manual regression test intended to reproduce the “partial results + server blocked” cancellation hang scenario from #4424.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs | Removes lock(_stateObj) from EndExecuteReaderAsync and documents rationale tied to #4424. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.NonQuery.cs | Removes lock(_stateObj) from EndExecuteNonQueryAsync to keep cancellation from being blocked by the monitor. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Xml.cs | Removes lock(_stateObj) from EndExecuteXmlReaderAsync consistent with reader/nonquery. |
| src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/DataReaderTest/DataReaderCancellationTest.cs | Adds a manual regression test for cancellation sending attention after partial results are received. |
…pt SqlException Move CancelAfter() before ExecuteReaderAsync so cancellation fires during the async completion path (not just ReadAsync). Also accept SqlException in addition to OperationCanceledException since attention acknowledgment from the server surfaces as SqlException. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds TestAsyncCancellationSendsAttention_WithAlwaysEncryptedCommand that exercises the internal-end path in CreateLocalCompletionTask by warming the query metadata cache first, then running a long-running AE query with CancellationToken. Also removes the lock from CreateLocalCompletionTask to fix the same contention issue in the AE retry path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- Use severity 10 in RAISERROR (matches real-world repro from #4424) - Assert.Fail if reader is returned in WAITFOR-first test (should never happen) - Fix AE test cache key: use same CommandText with parameterized @delay so warmup and cancel executions share the metadata cache entry Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
- Assert cts.IsCancellationRequested in all tests to guard against false positives from unrelated SqlExceptions - Reorder AE test query to WAITFOR DELAY @delay; SELECT ... so that EndExecuteReaderInternal blocks on metadata (exercises the CreateLocalCompletionTask internal-end path as intended) - Add Assert.Fail in AE test if reader is unexpectedly returned Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
mdaigle
left a comment
There was a problem hiding this comment.
Agreed on tests. But code looks fine.
paulmedynski
left a comment
There was a problem hiding this comment.
Maybe I'm misunderstanding, but I don't see how these tests confirm what we're trying to confirm. Would it be possible to use the simulated server to check that we sent ATTENTION as expected?
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/DataReaderTest/DataReaderCancellationTest.cs:130
- The InfoMessage handler is attached after ExecuteReaderAsync is started, so the RAISERROR WITH NOWAIT message can be received before the handler is subscribed. That makes the cancellation timing nondeterministic and can let the test cancel without ever confirming the “partial results received” state that #4424 regressed. Subscribe to InfoMessage (and create the TaskCompletionSource) before starting ExecuteReaderAsync, then start the exec task.
Task<SqlDataReader> execTask = command.ExecuteReaderAsync(cts.Token);
// Cancel only after the server has flushed the RAISERROR NOWAIT packet so we know we're in the
// "partial results received" state that regressed in #4424.
var infoMessageReceived = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs:2461
- The new comment claims the internal-end continuation runs only after “the data is already buffered”, but localCompletion is completed after a single ReadSni callback processes one packet; endFunc can still perform synchronous reads (e.g., FinishExecuteReader/TryRun) while holding lock(_stateObj). That lock can therefore still delay stateObj.Cancel() sending attention, so the current wording is misleading. Update the comment to reflect that the lock may still cover blocking reads and can delay cancellation.
// because this continuation runs after the initial async I/O has
// already completed — the blocking metadata read that caused #4424
// in the user-facing path does not apply here since the data is
// already buffered by the time this continuation fires.
|
Three smaller items:
|
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>
…rrived Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs:2461
- The rationale in this comment is inaccurate: CreateLocalCompletionTask runs after the initial async SNI read stage (a single response packet) completes, which can still be a partial response (e.g., an INFO token from RAISERROR ... WITH NOWAIT). In that case, endFunc may still need additional reads, and if stateObj._syncOverAsync becomes true (e.g., InfoMessage handler is invoked), those reads can become synchronous while this lock is held—potentially blocking Cancel() again in the same way as #4424. Please reword to avoid asserting that the blocking metadata read “does not apply” here, and clarify the actual constraint (avoid sync-over-async work while holding this lock).
// This is retained here (unlike the user-facing EndExecute* methods)
// because this continuation runs after the initial async I/O has
// already completed — the blocking metadata read that caused #4424
// in the user-facing path does not apply here since the data is
// already buffered by the time this continuation fires.
src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/DataReaderTest/DataReaderCancellationTest.cs:143
- This regression test can still take the full 60s WAITFOR duration when cancellation/attention regresses, because it awaits execTask directly without a watchdog. Adding a hard timeout (similar to CancellationOfInfiniteWhileLoop_DoesNotHang) will fail faster and avoid long manual-test hangs, while still preserving the <30s correctness assertion.
using (var reader = await execTask)
{
// If we reach here, cancellation failed to abort ExecuteReaderAsync while it was waiting
// for metadata after a partial response (e.g., RAISERROR WITH NOWAIT).
Assert.Fail("ExecuteReaderAsync should have been cancelled before returning a reader.");
}
|
It seems possible to construct a query that blocks on the lock(_stateObj) within CreateLocalCompletionTask. It requires returning partial results before a long-running query begins: I'll leave it up to you if you're comfortable touching that lock right now. The existing changes are still an improvement as-is, but I think we should either include a fix for this case as well, or modify the comment on the CreateLocalCompletionTask lock to indicate that this is future work instead of stating that the lock is desired behavior. |
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>
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>
https://github.com/dotnet/SqlClient into pr/4435/dev/cheena/fix-async-cancel-attention
Addresses review feedback that the retained lock in CreateLocalCompletionTask
is reachable and blocks cancellation, and that the justification comment was
factually wrong.
localCompletion is completed from _stateObj.ReadSni, which resolves as soon as
a packet arrives - not once the whole response is buffered. So when a batch
flushes partial results and then blocks, the continuation enters endFunc while
the server is still busy and performs a blocking sync-over-async read in
FinishExecuteReader's TryRun(UntilDone). FinishExecuteReader only early-returns
for !isInternal, so the internal path falls through to that read. Holding the
stateObj monitor across it starves TdsParserStateObject.Cancel(), which needs
the same monitor to send the TDS attention signal.
Measured against SQL Server: cancelling 1s into a blocked batch on this path
returned a reader after 60005ms with the lock, and cancels in ~12ms without it.
Also:
- Relax TestCancellationToken's exact-message assertion. A cancellation that
round-trips as an attention ack surfaces as SqlException with two errors
("A severe error occurred on the current command." then "Operation cancelled
by user.", both Number=0). That is a correct cancellation; assert the
cancellation error is present instead of matching the whole message. This is
what commit ac8acd7 originally reverted the fix for.
- Add CancellationDuringExecuteXmlReaderAsync_SendsAttention, closing the
ExecuteXmlReaderAsync coverage gap (uses FOR XML RAW; FOR XML AUTO requires a
table in the FROM clause and fails at compile time before RAISERROR runs).
- Observe execTask in CancellationOfInfiniteWhileLoop_DoesNotHang when the
watchdog fires, so an abandoned faulting task cannot surface as an
unobserved task exception in an unrelated test.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Thanks Malcolm — you were right, and I've included the fix rather than downgrading the comment to "future work". My justification comment was simply wrong, and I've verified that against a live SQL Server rather than reasoning about it. Where my comment was wrong. I claimed the continuation "runs after the initial async I/O has already completed... the data is already buffered". Measured, cancelling ~1s into
Full On the Tests for this path came from your linked #4546 (the |
|
All three addressed — thanks. 1. One gotcha worth recording: 2. Unobserved task — fixed on the 3. Release note — I checked the convention before adding a file, and release notes here are batched into dedicated release PRs (
Happy to add the file if you'd rather I pick a version. On scope: the AE/internal-end path is now covered and fixed in this PR rather than deferred, so the caveat you mentioned no longer applies. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (5)
src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/DataReaderTest/DataReaderCancellationTest.cs:409
- This comment assumes the internal-end continuation is running "inside lock (_stateObj)", but the lock has been removed. Updating this avoids misleading future investigations of failures/flakiness.
// 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)));
src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/DataReaderTest/DataReaderCancellationTest.cs:438
- The failure message for this assertion attributes a late reader to the internal-end path "held lock (_stateObj)", but the lock has been removed. The message should describe the observed behavior (attention not aborting promptly) rather than a no-longer-true implementation detail.
Assert.False(readerReturned,
$"ExecuteReaderAsync returned a reader {latency}ms after cancellation was requested. " +
"The internal-end path held lock (_stateObj) across a blocking read, so " +
"TdsParserStateObject.Cancel() could not send the attention signal.");
src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ApiShould.cs:2494
- This assertion message blames a "retained lock (_stateObj)" for blocking attention, but that lock is removed in the current implementation. Updating the message to focus on observable behavior will keep test failures actionable.
Assert.False(readerReturned,
$"ExecuteReaderAsync returned a reader {latency}ms after cancellation was requested. " +
"The retained lock (_stateObj) in CreateLocalCompletionTask blocked " +
"TdsParserStateObject.Cancel() from sending the attention signal.");
src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/DataReaderTest/DataReaderCancellationTest.cs:367
- The summary claims CreateLocalCompletionTask "still takes lock (_stateObj)" and that cancellation is dropped, but the production code change removes that lock. This doc should be updated so it remains accurate and doesn’t imply the test is validating behavior that no longer exists.
This issue also appears in the following locations of the same file:
- line 407
- line 435
/// <summary>
/// Regression test for the CreateLocalCompletionTask "internal end" path, which still
/// takes lock (_stateObj) while calling endFunc. That continuation fires as soon as the
/// first packet arrives, not once the whole result is buffered, so when a query flushes
/// partial results (RAISERROR WITH NOWAIT) and then blocks (WAITFOR), endFunc performs a
src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ApiShould.cs:2409
- This summary refers to a "retained lock" in CreateLocalCompletionTask blocking Cancel(), but the lock has been removed in the implementation. The comment should be updated so it stays accurate and doesn’t mislead readers about the current synchronization strategy.
This issue also appears on line 2491 of the same file.
/// Flushing a partial result before the delay is what exposes the retained lock:
/// localCompletion completes on that first packet, the continuation enters endFunc while
/// holding the monitor, and blocks reading the rest. TdsParserStateObject.Cancel() needs
/// that same monitor to send the attention signal, so cancellation is silently dropped
/// and ExecuteReaderAsync returns a reader once WAITFOR expires.
Description
Fixes #4424
Fixes #44
When using
CancellationTokenwith async operations likeExecuteReaderAsync/ExecuteNonQueryAsync, cancellation fails to send a TDS attention signal to SQL Server if the server is blocked (e.g., infiniteWHILEloop,WAITFOR DELAY, or partial results fromRAISERROR WITH NOWAITfollowed by a blocking operation). The cancellation hangs until the query completes naturally — which may be forever for infinite loops.Root Cause
EndExecuteReaderAsync(and the equivalent NonQuery/XmlReader methods) heldlock(_stateObj)while calling intoEndExecute*Internal. When the server hadn't yet sent result metadata (blocked on a long-running or infinite query),TryConsumeMetaDataperformed a synchronous network read (_syncOverAsync = true), blocking the thread while holding the monitor lock.Meanwhile,
stateObj.Cancel()usesMonitor.TryEnter(this, 100ms)in a polling loop on the same stateObj instance. Since the lock was held byEndExecute*Asyncfor the entire duration of the server-side wait,Cancel()could never acquire the monitor and never sent the attention signal.Fix
Remove
lock(_stateObj)from the user-facing async end methods:EndExecuteReaderAsyncEndExecuteNonQueryAsyncEndExecuteXmlReaderAsyncThe lock is unnecessary in these paths because concurrent access is already handled by:
Monitor.TryEnterwith parser state guards in its loopTryRunchecking parserBroken/ClosedstateAlso remove
lock(_stateObj)fromCreateLocalCompletionTask(the internal-end/retry path used when column encryption is enabled and parameter metadata came from the cache).An earlier revision of this PR kept that lock and justified it on the grounds that the continuation runs only after the response is buffered. That was wrong.
localCompletionis completed fromBeginExecuteReaderInternalReadStage->_stateObj.ReadSni(completion), which resolves as soon as a packet arrives. AndFinishExecuteReaderonly early-returns on the_internalEndExecuteInitiatedfast path when!isInternal, so the internal call falls through to a blockingTryRun(RunBehavior.UntilDone, ...). Holding the monitor across that read starvesTdsParserStateObject.Cancel(), which pollsMonitor.TryEnterfor the same monitor to send attention.Commit
ac8acd72had reverted this because AETestCancellationTokenfailed. The exception on the unlocked path is aSqlExceptionwithNumber == 0and two errors (A severe error occurred on the current command.thenOperation cancelled by user.) — a correct cancellation, where the first error is whatTryRunadds on consuming the attention ack. The test asserted an exact whole-message match, so that assertion is relaxed toAssert.Containsrather than reverting the fix.Testing
Which tests fail without the source fix?
Measured per-test against a live SQL Server, not inferred:
main?CancellationSendsAttention_WhenPartialResultsReceivedRAISERROR ... WITH NOWAITflushes,WAITFORblocks; reader returned instead of cancellingCancellationOfInfiniteWhileLoop_DoesNotHangcts.Cancel()never returnsCancellationOnInternalEndExecutePath_SendsAttentionCancellationDuringExecuteXmlReaderAsync_SendsAttentionCancellationDuringExecuteReaderAsync_SendsAttentionWAITFORfirst, nothing flushed, End path never runs — genuine guard testDataReaderCancellationTesttotals: 4 failed / 3 passed onmain; 7 passed in 1s with the fix.Note on the infinite
WHILEcase: it is not merely slow onmain. The token callback runsSqlCommand.Cancel()synchronously on the cancelling thread, soCancellationTokenSource.Cancel()itself blocks indefinitely (observed >20 minutes). With the fix it returns in 4ms.Suggested release note entry
Release notes in this repo are batched into dedicated release PRs rather than added per bugfix, so no file is added here. Suggested entry:
WHILEloop repro from Canceling SQL Server query with while loop hangs forever #44 — fails pre-fixCancellationOnInternalEndExecutePath_SendsAttention(forces the internal-end path via the DEBUG_forceInternalEndQueryhook, so it runs without an AE setup and skips against a Release driver) — fails pre-fixCancellationDuringExecuteXmlReaderAsync_SendsAttention, closing theExecuteXmlReaderAsynccoverage gap (FOR XML RAW;FOR XML AUTOfails at compile time beforeRAISERRORruns) — fails pre-fixTestCancellationTokenexact-message assertion toAssert.Contains(attention ack adds a leading error)execTaskon the watchdog branch to avoid unobserved task exceptions leaking into unrelated testsInfoMessagearrival (asserted), not a fixed timerTestSqlCommandCancellationToken) passChanges
SqlCommand.Reader.cslock(_stateObj)inEndExecuteReaderAsyncSqlCommand.NonQuery.cslock(_stateObj)inEndExecuteNonQueryAsyncSqlCommand.Xml.cslock(_stateObj)inEndExecuteXmlReaderAsyncSqlCommand.cslock(_stateObj)inCreateLocalCompletionTask(internal-end path)DataReaderCancellationTest.csCancellationSendsAttention_WhenPartialResultsReceived,CancellationDuringExecuteReaderAsync_SendsAttention,CancellationOfInfiniteWhileLoop_DoesNotHang,CancellationOnInternalEndExecutePath_SendsAttentionandCancellationDuringExecuteXmlReaderAsync_SendsAttentiontestsCommandHelper.cs/DataTestUtility.cs_forceInternalEndQueryhook so the internal-end path can be tested without an AE setupApiShould.csTestAsyncCancellationSendsAttention_WithAlwaysEncryptedCommandtest