Skip to content

Commit 23fdc83

Browse files
committed
Fixed races in cancellation tests by switching from timer-based to flag-based with retries.
1 parent 451e64a commit 23fdc83

4 files changed

Lines changed: 86 additions & 37 deletions

File tree

docs/claude/tds-endpoint.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,11 @@ Batch-scoped variables go with the ended batch either way; connection-scoped tem
429429
`SimulatedDbCommand.Cancel()` routes to the same machinery, so an in-process `Cancel()` from another thread interrupts a running `WAITFOR` / long batch identically (the reader then drains already-materialized rows, nothing left in flight — the documented in-process reaction bound).
430430
Oracle: `AttentionTests` (Tests.SqlClient), `WaitForDelayTests.Delay_InterruptedByInProcessCancel_ReturnsPromptly` (Tests).
431431

432+
**Partial response packets don't flush early (probed 2026-07-20).**
433+
The server accumulates response tokens into a TDS packet and sends it only when the packet fills or the response ends — real SQL Server behaves identically: for `select @p; waitfor delay '00:00:30'` the one-row first result set sits in the send buffer for the full 30 seconds and the client's first `ReadAsync` blocks until the batch ends, on real and sim alike.
434+
Two consequences: a client can't observe an early small result set to sequence a cancel after (the cancel must land while the client read is parked — `AttentionTests` retries via `CancelUntilComplete` for exactly this reason), and the attention path is the only way a blocked mid-batch client wakes early.
435+
A `SqlCommand.Cancel()` racing execution *start* is different: a cancel before the batch begins executing is a documented client-side no-op, and one landing inside `Execute*Async`'s setup surfaces SqlClient's own `InvalidOperationException` instead of the cancel `SqlException` — client-side races independent of the server, so tests never fire a single timer-based cancel.
436+
432437
## MARS (Multiple Active Result Sets)
433438

434439
`MultipleActiveResultSets=True` lets a client run a second command while a reader is still open — the EF-lazy-load shape (iterate a parent query, touch a navigation per row).

tests/SqlServerSimulator.Tests.SqlClient/AttentionTests.cs

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,22 @@ public sealed class AttentionTests
2121

2222
private const string LongWait = "waitfor delay '00:00:30'";
2323

24-
/// <summary>Cancels the command shortly after execution starts, off the calling thread.</summary>
25-
private static void CancelAfter(SqlCommand command, int millis) =>
26-
_ = Task.Run(async () =>
24+
/// <summary>
25+
/// Cancels the command repeatedly until the in-flight execution completes.
26+
/// A cancel landing before the batch starts executing is a documented
27+
/// <see cref="SqlCommand.Cancel"/> no-op, so a single timer-fired cancel
28+
/// can miss on a stalled runner and let the 30-second WAITFOR run to
29+
/// natural completion; retrying until the task transitions guarantees an
30+
/// attention lands mid-execution.
31+
/// </summary>
32+
private static async Task CancelUntilComplete(SqlCommand command, Task execution, CancellationToken cancellationToken)
33+
{
34+
while (!execution.IsCompleted)
2735
{
28-
await Task.Delay(millis);
36+
await Task.Delay(100, cancellationToken);
2937
command.Cancel();
30-
});
38+
}
39+
}
3140

3241
private static async Task AssertSessionReusableAsync(SqlConnection connection, CancellationToken cancellationToken)
3342
{
@@ -61,9 +70,9 @@ public async Task Cancel_DuringWaitfor_RaisesCancelAndSessionStaysReusable()
6170

6271
await using (var command = new SqlCommand(LongWait, connection) { CommandTimeout = 0 })
6372
{
64-
CancelAfter(command, 200);
65-
var error = await ThrowsExactlyAsync<SqlException>(
66-
async () => await command.ExecuteNonQueryAsync(TestContext.CancellationToken));
73+
var execution = command.ExecuteNonQueryAsync(TestContext.CancellationToken);
74+
await CancelUntilComplete(command, execution, TestContext.CancellationToken);
75+
var error = await ThrowsExactlyAsync<SqlException>(async () => await execution);
6776
AreEqual(0, error.Number);
6877
}
6978

@@ -80,10 +89,18 @@ public async Task Cancel_MidLargeResult_AbortsAndSessionStaysReusable()
8089
const string bigResult = "select value, replicate('x', 200) from generate_series(1, 500000)";
8190
await using (var command = new SqlCommand(bigResult, connection) { CommandTimeout = 0 })
8291
{
83-
CancelAfter(command, 50);
92+
// Cancel only after the first row arrives, so the command is
93+
// provably mid-execution. A timer-based cancel races two ways on a
94+
// slow runner: landing before execution starts is a documented
95+
// Cancel() no-op (the drain then completes with no exception), and
96+
// landing during ExecuteReaderAsync's setup trips a client-side
97+
// SqlClient race that surfaces InvalidOperationException instead
98+
// of the cancel SqlException.
8499
_ = await ThrowsExactlyAsync<SqlException>(async () =>
85100
{
86101
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
102+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
103+
command.Cancel();
87104
while (await reader.ReadAsync(TestContext.CancellationToken))
88105
{
89106
}
@@ -106,9 +123,9 @@ public async Task Cancel_WithOpenTransaction_DefaultXactAbort_LeavesTransactionO
106123

107124
await using (var command = new SqlCommand(LongWait, connection) { CommandTimeout = 0 })
108125
{
109-
CancelAfter(command, 200);
110-
_ = await ThrowsExactlyAsync<SqlException>(
111-
async () => await command.ExecuteNonQueryAsync(TestContext.CancellationToken));
126+
var execution = command.ExecuteNonQueryAsync(TestContext.CancellationToken);
127+
await CancelUntilComplete(command, execution, TestContext.CancellationToken);
128+
_ = await ThrowsExactlyAsync<SqlException>(async () => await execution);
112129
}
113130

114131
// XACT_ABORT OFF (the default): the transaction survives the cancel.
@@ -136,9 +153,9 @@ public async Task Cancel_WithOpenTransaction_XactAbortOn_RollsTransactionBack()
136153

137154
await using (var command = new SqlCommand(LongWait, connection) { CommandTimeout = 0 })
138155
{
139-
CancelAfter(command, 200);
140-
_ = await ThrowsExactlyAsync<SqlException>(
141-
async () => await command.ExecuteNonQueryAsync(TestContext.CancellationToken));
156+
var execution = command.ExecuteNonQueryAsync(TestContext.CancellationToken);
157+
await CancelUntilComplete(command, execution, TestContext.CancellationToken);
158+
_ = await ThrowsExactlyAsync<SqlException>(async () => await execution);
142159
}
143160

144161
// XACT_ABORT ON: the cancel rolls the transaction back.
@@ -162,9 +179,9 @@ public async Task Cancel_MidBatch_DiscardsRemainingStatements()
162179
var batchText = $"insert t values (1); {LongWait}; insert t values (2)";
163180
await using (var batch = new SqlCommand(batchText, connection) { CommandTimeout = 0 })
164181
{
165-
CancelAfter(batch, 200);
166-
_ = await ThrowsExactlyAsync<SqlException>(
167-
async () => await batch.ExecuteNonQueryAsync(TestContext.CancellationToken));
182+
var execution = batch.ExecuteNonQueryAsync(TestContext.CancellationToken);
183+
await CancelUntilComplete(batch, execution, TestContext.CancellationToken);
184+
_ = await ThrowsExactlyAsync<SqlException>(async () => await execution);
168185
}
169186

170187
await using var rows = new SqlCommand("select count(*) from t", connection);
@@ -181,18 +198,26 @@ public async Task Cancel_DuringParameterizedRpc_AbortsAndSessionStaysReusable()
181198
await using (var command = new SqlCommand($"select @p; {LongWait}", connection) { CommandTimeout = 0 })
182199
{
183200
_ = command.Parameters.AddWithValue("@p", 7);
184-
CancelAfter(command, 200);
185-
_ = await ThrowsExactlyAsync<SqlException>(async () =>
201+
// The drain blocks inside the first ReadAsync: the server holds
202+
// the tiny first result set in its TDS send buffer until the
203+
// batch ends (probe-confirmed against real SQL Server), so a
204+
// cancel can't be sequenced after an observed row — it has to
205+
// land while the read is parked, retried until it takes.
206+
var drain = DrainAsync(command, TestContext.CancellationToken);
207+
await CancelUntilComplete(command, drain, TestContext.CancellationToken);
208+
_ = await ThrowsExactlyAsync<SqlException>(async () => await drain);
209+
210+
static async Task DrainAsync(SqlCommand command, CancellationToken cancellationToken)
186211
{
187-
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
212+
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
188213
do
189214
{
190-
while (await reader.ReadAsync(TestContext.CancellationToken))
215+
while (await reader.ReadAsync(cancellationToken))
191216
{
192217
}
193218
}
194-
while (await reader.NextResultAsync(TestContext.CancellationToken));
195-
});
219+
while (await reader.NextResultAsync(cancellationToken));
220+
}
196221
}
197222

198223
await AssertSessionReusableAsync(connection, TestContext.CancellationToken);
@@ -211,7 +236,7 @@ public async Task Cancel_RacingNaturalCompletion_DoesNotHang()
211236
for (var i = 0; i < 30; i++)
212237
{
213238
await using var command = new SqlCommand("select 1", connection) { CommandTimeout = 5 };
214-
CancelAfter(command, 0);
239+
_ = Task.Run(command.Cancel, TestContext.CancellationToken);
215240
try
216241
{
217242
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
@@ -243,9 +268,9 @@ public async Task SequentialCommands_AfterCancel_AllSucceed()
243268

244269
await using (var command = new SqlCommand(LongWait, connection) { CommandTimeout = 0 })
245270
{
246-
CancelAfter(command, 200);
247-
_ = await ThrowsExactlyAsync<SqlException>(
248-
async () => await command.ExecuteNonQueryAsync(TestContext.CancellationToken));
271+
var execution = command.ExecuteNonQueryAsync(TestContext.CancellationToken);
272+
await CancelUntilComplete(command, execution, TestContext.CancellationToken);
273+
_ = await ThrowsExactlyAsync<SqlException>(async () => await execution);
249274
}
250275

251276
for (var i = 1; i <= 5; i++)

tests/SqlServerSimulator.Tests.SqlClient/MarsTests.cs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,22 @@ public sealed class MarsTests
2121

2222
private const string MarsExtra = ";MultipleActiveResultSets=True";
2323

24-
private static void CancelAfter(SqlCommand command, int millis) =>
25-
_ = Task.Run(async () =>
24+
/// <summary>
25+
/// Cancels the command repeatedly until the in-flight execution completes.
26+
/// A cancel landing before the batch starts executing is a documented
27+
/// <see cref="SqlCommand.Cancel"/> no-op, so a single timer-fired cancel
28+
/// can miss on a stalled runner and let the 30-second WAITFOR run to
29+
/// natural completion; retrying until the task transitions guarantees an
30+
/// attention lands mid-execution.
31+
/// </summary>
32+
private static async Task CancelUntilComplete(SqlCommand command, Task execution, CancellationToken cancellationToken)
33+
{
34+
while (!execution.IsCompleted)
2635
{
27-
await Task.Delay(millis);
36+
await Task.Delay(100, cancellationToken);
2837
command.Cancel();
29-
});
38+
}
39+
}
3040

3141
private static Simulation Seeded(int rows = 5)
3242
{
@@ -243,9 +253,9 @@ public async Task Cancel_OneOfTwoActiveCommands_LeavesOtherReaderUsable()
243253

244254
await using (var b = new SqlCommand("waitfor delay '00:00:30'", connection) { CommandTimeout = 0 })
245255
{
246-
CancelAfter(b, 200);
247-
_ = await ThrowsExactlyAsync<SqlException>(
248-
async () => await b.ExecuteNonQueryAsync(TestContext.CancellationToken));
256+
var execution = b.ExecuteNonQueryAsync(TestContext.CancellationToken);
257+
await CancelUntilComplete(b, execution, TestContext.CancellationToken);
258+
_ = await ThrowsExactlyAsync<SqlException>(async () => await execution);
249259
}
250260

251261
var remaining = new List<int> { readerA.GetInt32(0) };

tests/SqlServerSimulator.Tests/WaitForDelayTests.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,24 @@ public void Delay_InterruptedByInProcessCancel_ReturnsPromptly()
7474
connection.Open();
7575
using var command = connection.CreateCommand();
7676
command.CommandText = "waitfor delay '00:00:30'";
77+
// Retry the cancel until ExecuteNonQuery returns: a cancel landing
78+
// before the execution scope opens targets the prior scope's token
79+
// and is dropped, so a single timer-fired cancel can miss on a
80+
// stalled runner and let the 30-second wait run to completion.
81+
var done = false;
7782
var canceller = new Thread(() =>
7883
{
79-
Thread.Sleep(200);
80-
command.Cancel();
84+
while (!Volatile.Read(ref done))
85+
{
86+
Thread.Sleep(100);
87+
command.Cancel();
88+
}
8189
});
8290

8391
var start = Stopwatch.GetTimestamp();
8492
canceller.Start();
8593
_ = command.ExecuteNonQuery();
94+
Volatile.Write(ref done, true);
8695
canceller.Join();
8796
var elapsed = Stopwatch.GetElapsedTime(start);
8897

0 commit comments

Comments
 (0)