diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.NonQuery.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.NonQuery.cs
index 311c26b4e5..884a3e41af 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.NonQuery.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.NonQuery.cs
@@ -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);
}
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs
index bffe17baf9..acbea38b52 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs
@@ -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.
return EndExecuteReaderInternal(asyncResult);
}
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Xml.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Xml.cs
index 447627375e..4e1971175e 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Xml.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Xml.cs
@@ -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);
}
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs
index 76399749a0..e65c8f4520 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs
@@ -2451,7 +2451,14 @@ private void CreateLocalCompletionTask(
Debug.Assert(!_internalEndExecuteInitiated);
_internalEndExecuteInitiated = true;
- // Lock on _stateObj prevents races with close/cancel
+ // Lock on _stateObj serializes this internal-end path with
+ // close/cancel. Note: stateObj.Cancel() also acquires this monitor,
+ // so this lock CAN block Cancel() while endFunc is executing.
+ // 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.
lock (_stateObj)
{
endFunc(this, task, /*isInternal:*/ true, endMethod);
diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ApiShould.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ApiShould.cs
index 6f564250ce..6af7b84dda 100644
--- a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ApiShould.cs
+++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ApiShould.cs
@@ -2276,6 +2276,124 @@ public void TestSqlCommandCancellationToken(string connection, int initalValue,
}
+ ///
+ /// 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 exercises the EndExecuteReaderAsync path where the lock on _stateObj previously
+ /// prevented Cancel() from sending attention. See GitHub issue #4424.
+ /// Synapse: Incompatible query.
+ ///
+ [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.IsTargetReadyForAeWithKeyStore))]
+ [ClassData(typeof(AEConnectionStringProvider))]
+ public async Task TestAsyncCancellationSendsAttention_WithAlwaysEncryptedCommand(string connection)
+ {
+ CleanUpTable(connection, _tableName);
+
+ IList