Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
564e0db
Fix async cancellation failing to send TDS attention signal
cheenamalhotra Jul 9, 2026
71d63e3
Add manual test for async cancellation with partial results
cheenamalhotra Jul 9, 2026
5ca7e0f
Address review: fix test to cancel before ExecuteReaderAsync and acce…
cheenamalhotra Jul 9, 2026
89b51a9
Add AE test for async cancellation via CreateLocalCompletionTask path
cheenamalhotra Jul 9, 2026
7590ffe
Address review feedback: fix test accuracy
cheenamalhotra Jul 9, 2026
b30baf4
Potential fix for pull request finding
cheenamalhotra Jul 9, 2026
c4ab8ec
Address review: add CTS assertions, fix AE test query order
cheenamalhotra Jul 9, 2026
ac8acd7
Revert CreateLocalCompletionTask lock removal to fix AE test
cheenamalhotra Jul 9, 2026
9431172
Address review: add infinite WHILE loop test, fix CTS timing, clarify…
cheenamalhotra Jul 9, 2026
22528a8
Address review: clarify lock comment, add watchdog to while-loop test
cheenamalhotra Jul 9, 2026
bd0f7e5
Address flakiness by canceling from another thread.
cheenamalhotra Jul 15, 2026
59fc6a6
Apply suggestions from code review
cheenamalhotra Aug 10, 2026
885c66f
Add cancellation coverage for the internal-end execute path
mdaigle Aug 17, 2026
b16406c
Address review: subscribe InfoMessage before dispatch and assert it a…
cheenamalhotra Aug 17, 2026
b33eb08
Skip the internal-end cancellation test instead of passing it silently
mdaigle Aug 18, 2026
87cfe5f
Subscribe InfoMessage before dispatch in the new cancellation tests
mdaigle Aug 18, 2026
120ba67
Merge branch 'dev/automation/pr4435-internal-end-cancellation-tests' …
cheenamalhotra Aug 18, 2026
81b95f0
Remove lock(_stateObj) from CreateLocalCompletionTask internal-end path
cheenamalhotra Aug 19, 2026
00f0710
Merge branch 'main' into dev/cheena/fix-async-cancel-attention
cheenamalhotra Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -360,17 +360,9 @@ private int EndExecuteNonQueryAsync(IAsyncResult asyncResult)
}

ThrowIfReconnectionHasBeenCanceled();
// lock on _stateObj prevents races with close/cancel.
// If we have already initiated the End call internally, we have already done that, so
// no point doing it again.
if (!_internalEndExecuteInitiated)
{
lock (_stateObj)
{
return EndExecuteNonQueryInternal(asyncResult);
}
}

// Note: We intentionally do NOT lock on _stateObj here.
// See comment in EndExecuteReaderAsync and GitHub issue #4424 for details.
return EndExecuteNonQueryInternal(asyncResult);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -671,15 +671,16 @@ private SqlDataReader EndExecuteReaderAsync(IAsyncResult asyncResult)

ThrowIfReconnectionHasBeenCanceled();

// Lock on _stateObj prevents race with close/cancel
if (!_internalEndExecuteInitiated)
{
lock (_stateObj)
{
return EndExecuteReaderInternal(asyncResult);
}
}

// Note: We intentionally do NOT lock on _stateObj here.
// Taking lock(_stateObj) would prevent Cancel() from acquiring the stateObj
// monitor to send a TDS attention signal while FinishExecuteReader may be
// blocked on a synchronous network read (e.g., waiting for metadata after
// partial results like RAISERROR WITH NOWAIT). This caused cancellation to
// hang until the full query completed. See GitHub issue #4424.
//
// Concurrent close is handled by parser state checks within TryRun
// (detects Broken/Closed state). Cancel() uses Monitor.TryEnter with polling
// and checks parser state in its loop, so it handles concurrency safely.
Comment thread
cheenamalhotra marked this conversation as resolved.
return EndExecuteReaderInternal(asyncResult);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,17 +393,8 @@ private XmlReader EndExecuteXmlReaderAsync(IAsyncResult asyncResult)

ThrowIfReconnectionHasBeenCanceled();

// Locking _stateObj prevents races with close/cancel.
// If we have already initiated the End call internally, we have already done that, so
// no point doing it again.
if (!_internalEndExecuteInitiated)
{
lock (_stateObj)
{
return EndExecuteXmlReaderInternal(asyncResult);
}
}

// Note: We intentionally do NOT lock on _stateObj here.
// See comment in EndExecuteReaderAsync and GitHub issue #4424 for details.
return EndExecuteXmlReaderInternal(asyncResult);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2451,11 +2451,9 @@ private void CreateLocalCompletionTask(
Debug.Assert(!_internalEndExecuteInitiated);
_internalEndExecuteInitiated = true;

// Lock on _stateObj prevents races with close/cancel
lock (_stateObj)
{
endFunc(this, task, /*isInternal:*/ true, endMethod);
}
// Note: We intentionally do NOT lock on _stateObj here.
// See comment in EndExecuteReaderAsync and GitHub issue #4424.
endFunc(this, task, /*isInternal:*/ true, endMethod);
Comment thread
cheenamalhotra marked this conversation as resolved.
Outdated

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.

I'm seeing SNI tests fail due to deadlock. I wonder if this had unintended consequences.


globalCompletion.TrySetResult(task.Result);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2276,6 +2276,108 @@ public void TestSqlCommandCancellationToken(string connection, int initalValue,
}


/// <summary>
/// Validates that async cancellation via CancellationToken sends a TDS attention signal
/// when an AE-enabled command is blocked on a server-side wait (e.g., WAITFOR DELAY).
/// This covers the internal-end path in CreateLocalCompletionTask where the lock on
/// _stateObj previously prevented Cancel() from sending attention.
/// See GitHub issue #4424.
/// Synapse: Incompatible query.
/// </summary>
[ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.IsTargetReadyForAeWithKeyStore))]
[ClassData(typeof(AEConnectionStringProvider))]
public async Task TestAsyncCancellationSendsAttention_WithAlwaysEncryptedCommand(string connection)
{
CleanUpTable(connection, _tableName);

IList<object> values = GetValues(dataHint: 60);
int numberOfRows = 10;
int rowsAffected = InsertRows(tableName: _tableName, numberofRows: numberOfRows, values: values, connection: connection);
Assert.True(rowsAffected == numberOfRows, "number of rows affected is unexpected.");

using (SqlConnection sqlConnection = new SqlConnection(connection))
{
await sqlConnection.OpenAsync();

// Use the same CommandText for both warmup and cancellation test so the
// query metadata cache key matches and the second execution goes through
// the CreateLocalCompletionTask internal-end path.
// WAITFOR is placed BEFORE SELECT so that EndExecuteReaderInternal blocks
// waiting for result-set metadata — this is the exact CreateLocalCompletionTask
// code path we need to exercise.
string commandText = $"WAITFOR DELAY @Delay; SELECT CustomerId, FirstName, LastName FROM [{_tableName}] WHERE FirstName = @FirstName AND CustomerId = @CustomerId;";

Comment thread
cheenamalhotra marked this conversation as resolved.
// Warmup: execute with zero delay to populate the metadata cache.
using (SqlCommand warmupCmd = new SqlCommand(
commandText, sqlConnection, null, SqlCommandColumnEncryptionSetting.Enabled))
{
warmupCmd.Parameters.AddWithValue("@CustomerId", values[0]);
warmupCmd.Parameters.AddWithValue("@FirstName", values[1]);
warmupCmd.Parameters.AddWithValue("@Delay", "00:00:00");

using (var reader = await warmupCmd.ExecuteReaderAsync())
{
while (await reader.ReadAsync()) { }
while (await reader.NextResultAsync()) { }
}
}

// Now execute the same command with a long delay and cancel it.
// With cached metadata, this goes through CreateLocalCompletionTask's internal-end path.
// The WAITFOR blocks before any result-set metadata is returned, so
// ExecuteReaderAsync should be cancelled before a reader is produced.
using (SqlCommand sqlCommand = new SqlCommand(
commandText, sqlConnection, null, SqlCommandColumnEncryptionSetting.Enabled))
{
sqlCommand.Parameters.AddWithValue("@CustomerId", values[0]);
sqlCommand.Parameters.AddWithValue("@FirstName", values[1]);
sqlCommand.Parameters.AddWithValue("@Delay", "00:01:00");
sqlCommand.CommandTimeout = 90;

Comment thread
cheenamalhotra marked this conversation as resolved.
using (CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)))
{
System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew();

Exception caughtException = null;
try
{
using (SqlDataReader reader = await sqlCommand.ExecuteReaderAsync(cts.Token))
{
// If we reach here, cancellation failed during EndExecuteReaderInternal.
Assert.Fail("ExecuteReaderAsync should have been cancelled before returning a reader.");
}
}
catch (OperationCanceledException ex)
{
caughtException = ex;
}
catch (SqlException ex)
{
// Attention ack from server manifests as SqlException
caughtException = ex;
}

stopwatch.Stop();

Assert.NotNull(caughtException);
Assert.True(cts.IsCancellationRequested,
"CancellationTokenSource was not cancelled; exception may be unrelated to cancellation.");
Assert.True(stopwatch.ElapsedMilliseconds < 30000,
Comment thread
paulmedynski marked this conversation as resolved.
Comment thread
cheenamalhotra marked this conversation as resolved.
$"Cancellation took {stopwatch.ElapsedMilliseconds}ms, expected < 30000ms. " +
"Attention signal may not have been sent during AE async execution.");
Comment thread
cheenamalhotra marked this conversation as resolved.
}
}

// Verify the connection is still usable after cancellation.
using (SqlCommand verifyCmd = new SqlCommand(
$"SELECT COUNT(*) FROM [{_tableName}]", sqlConnection))
{
object result = await verifyCmd.ExecuteScalarAsync();
Assert.Equal(numberOfRows, (int)result);
}
}
}

[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsSGXEnclaveConnStringSetup))]
public void TestNoneAttestationProtocolWithSGXEnclave()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,132 @@ await Assert.ThrowsAsync<TaskCanceledException>(async () =>
Assert.True(stopwatch.ElapsedMilliseconds < 10000, "Cancellation did not trigger on time.");
}
}
/// <summary>
/// Validates that async cancellation sends a TDS attention signal to SQL Server
/// when the server has sent partial results (RAISERROR WITH NOWAIT at severity 10)
/// followed by a blocking operation (WAITFOR). Without the fix for GitHub issue #4424,
/// cancellation would hang until WAITFOR completed naturally.
/// Synapse: Incompatible query.
/// </summary>
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), nameof(DataTestUtility.IsNotAzureSynapse))]
public static async Task CancellationSendsAttention_WhenPartialResultsReceived()
{
// Severity 10 informational message flushed via NOWAIT sends a partial TDS
// response, then WAITFOR blocks for 60s. Cancellation should send attention
// and abort within seconds.
const string query = @"
RAISERROR('partial result', 10, 1) WITH NOWAIT;
WAITFOR DELAY '00:01:00';
SELECT 1 AS Result;";
Comment thread
Copilot marked this conversation as resolved.

using (var cts = new CancellationTokenSource())
using (var connection = new SqlConnection(DataTestUtility.TCPConnectionString))
{
await connection.OpenAsync();

using (var command = new SqlCommand(query, connection))
{
command.CommandTimeout = 90;

// Schedule cancellation BEFORE ExecuteReaderAsync so it fires while
// the async completion path may still be consuming metadata or while
// ReadAsync/NextResultAsync is blocked waiting for the server.
cts.CancelAfter(System.TimeSpan.FromSeconds(2));

Stopwatch stopwatch = Stopwatch.StartNew();

// Cancellation during async read may surface as either
// OperationCanceledException or SqlException (attention ack).
System.Exception caughtException = null;
try
{
using (var reader = await command.ExecuteReaderAsync(cts.Token))
{
// 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.");
}
}
catch (System.OperationCanceledException ex)
{
caughtException = ex;
}
catch (SqlException ex)
{
// Attention acknowledgment from server manifests as SqlException
caughtException = ex;
}

stopwatch.Stop();

Assert.NotNull(caughtException);
// Ensure the CTS actually fired — guards against false positives
// from unrelated SqlExceptions.
Assert.True(cts.IsCancellationRequested,
"CancellationTokenSource was not cancelled; exception may be unrelated to cancellation.");
// The key assertion: cancellation should complete well before the
// 60-second WAITFOR. Allow up to 30 seconds for CI variability.
Assert.True(stopwatch.ElapsedMilliseconds < 30000,
$"Cancellation took {stopwatch.ElapsedMilliseconds}ms, expected < 30000ms. " +
"Attention signal may not have been sent to the server.");
Comment thread
cheenamalhotra marked this conversation as resolved.
}
}
}

/// <summary>
/// Validates that cancellation during ExecuteReaderAsync itself sends a TDS attention
/// signal when the server is blocked before returning any result set metadata.
/// With WAITFOR as the first statement, ExecuteReaderAsync should never return a
/// reader — cancellation must abort the operation during the await.
/// Synapse: Incompatible query.
/// </summary>
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), nameof(DataTestUtility.IsNotAzureSynapse))]
public static async Task CancellationDuringExecuteReaderAsync_SendsAttention()
{
// WAITFOR as the first statement means no metadata is returned until it
// completes. ExecuteReaderAsync will be blocked in the async completion path.
const string query = "WAITFOR DELAY '00:01:00'; SELECT 1 AS Result;";

using (var cts = new CancellationTokenSource(System.TimeSpan.FromSeconds(2)))
using (var connection = new SqlConnection(DataTestUtility.TCPConnectionString))
{
await connection.OpenAsync();

Comment thread
cheenamalhotra marked this conversation as resolved.
Outdated
using (var command = new SqlCommand(query, connection))
{
command.CommandTimeout = 90;
Stopwatch stopwatch = Stopwatch.StartNew();

System.Exception caughtException = null;
try
{
// ExecuteReaderAsync should be cancelled via attention before
// a reader is ever returned.
using (var reader = await command.ExecuteReaderAsync(cts.Token))
{
// If we reach here, cancellation failed to abort ExecuteReaderAsync.
Assert.Fail("ExecuteReaderAsync should have been cancelled before returning a reader.");
}
}
catch (System.OperationCanceledException ex)
{
caughtException = ex;
}
catch (SqlException ex)
{
caughtException = ex;
}

stopwatch.Stop();

Assert.NotNull(caughtException);
Assert.True(cts.IsCancellationRequested,
"CancellationTokenSource was not cancelled; exception may be unrelated to cancellation.");
Assert.True(stopwatch.ElapsedMilliseconds < 30000,
$"Cancellation took {stopwatch.ElapsedMilliseconds}ms, expected < 30000ms. " +
"Attention signal may not have been sent during ExecuteReaderAsync.");
Comment thread
cheenamalhotra marked this conversation as resolved.
}
}
}
}
}
Loading