dotnet / dotnet/SqlClient

Async cancellation sends no attention signal once the server has flushed an info message (RAISERROR ... WITH NOWAIT)

Open
#4,424 2 comments 1 reaction 1 assignee Claimed by @cheenamalhotra View on GitHub
Area\Async Repro Available :heavy_check_mark:
Dominant language
C#
Stars
989
Forks
340
Avg merge
4d 19h
Merged PRs (30d)
72

Description

## Describe the bug

When a T-SQL batch emits an informational message via `RAISERROR('...', 10, 1) WITH NOWAIT` **before** a long-running statement, cancelling the async execution (`ExecuteReaderAsync` with a `CancellationToken`) no longer aborts the query on the server.

The cancellation is only observed by the client **after the batch has run to completion** — the client then throws `TaskCanceledException`, but the server has already executed the entire batch. No attention signal appears to be sent to the server while it is still working.

The behavior is deterministic and only depends on whether the server has flushed the first response packet (the INFO token) before the cancellation is requested:

- `WITH NOWAIT` forces SQL Server to flush the message buffer immediately → first TDS response packet reaches the client mid-batch → cancellation is dead.
- Without `WITH NOWAIT` (or with `PRINT`), the message is buffered server-side → the client is still waiting for the first packet → cancellation works immediately.

This is not the known timing race from #1065 — it reproduces 100% of the time.

## To reproduce

Single-file console app (any SQL Server instance works, LocalDB shown here):

```csharp
// Repro for: Async cancellation sends no attention signal once the server has
// flushed an info message (RAISERROR ... WITH NOWAIT).
// Cancellation is requested after 3 s; WAITFOR DELAY is 15 s.
// Expected: every scenario aborts after ~3 s. Actual: scenario B runs the full 15 s.

using System.Diagnostics;
using Microsoft.Data.SqlClient;

const string connStr = @"Server=(localdb)\MSSQLLocalDB;Integrated Security=true;";
const string delay = "WAITFOR DELAY '00:00:15';";
const string sqlB = $"raiserror('bla',10,1) with nowait; {delay} SELECT 1;";

Console.WriteLine("=== Scenarios (async ExecuteReaderAsync, token cancelled after 3 s) ===");

var scenarios = new (string Name, string Sql)[]
{
("A: WAITFOR only (reference)", $"{delay} SELECT 1;"),
("B: RAISERROR WITH NOWAIT + WAITFOR", sqlB),
("C: RAISERROR (no NOWAIT) + WAITFOR", $"raiserror('bla',10,1); {delay} SELECT 1;"),
("D: PRINT + WAITFOR", $"print 'bla'; {delay} SELECT 1;"),
};

foreach (var (name, sql) in scenarios)
{
await RunAsync(name, sql, async (cmd, cts) =>
{
using SqlDataReader reader = await cmd.ExecuteReaderAsync(cts.Token);
while (await reader.ReadAsync(cts.Token)) { }
});
}

Console.WriteLine();
Console.WriteLine("=== Variations of scenario B (additional findings) ===");

await RunAsync("B + explicit cmd.Cancel() via Register", sqlB, async (cmd, cts) =>
{
cts.Token.Register(() => cmd.Cancel());
using SqlDataReader reader = await cmd.ExecuteReaderAsync(cts.Token);
while (await reader.ReadAsync(cts.Token)) { }
});

await RunAsync("B + conn.Close() via Register", sqlB, async (cmd, cts) =>
{
cts.Token.Register(() => cmd.Connection!.Close());
using SqlDataReader reader = await cmd.ExecuteReaderAsync(CancellationToken.None);
while (await reader.ReadAsync(CancellationToken.None)) { }
});

await RunAsync("B without InfoMessage handler", sqlB, async (cmd, cts) =>
{
using SqlDataReader reader = await cmd.ExecuteReaderAsync(cts.Token);
while (await reader.ReadAsync(cts.Token)) { }
}, subscribeInfoMessage: false);

await RunAsync("B sync ExecuteReader + cmd.Cancel()", sqlB, async (cmd, cts) =>
{
cts.Token.Register(() => cmd.Cancel());
await Task.Run(() =>
{
using SqlDataReader reader = cmd.ExecuteReader();
while (reader.Read()) { }
});
});

static async Task RunAsync(string name, string sql, Func body, bool subscribeInfoMessage = true)
{
using var conn = new SqlConnection(connStr);
await conn.OpenAsync();
if (subscribeInfoMessage)
{
conn.InfoMessage += (_, e) => Console.WriteLine($" [InfoMessage] {e.Message}");
}

using var cmd = new SqlCommand(sql, conn) { CommandTimeout = 60 };
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));

var sw = Stopwatch.StartNew();
string outcome;
try
{
await body(cmd, cts);
outcome = "completed (no cancellation!)";
}
catch (Exception e)
{
outcome = $"{e.GetType().Name}: {e.Message.Split('\n')[0].Trim()}";
}
sw.Stop();

Console.WriteLine($"{name,-42} -> {sw.Elapsed.TotalSeconds,5:F1}s | {outcome}");
}

```

CsProject-file for console app:

```xml


Exe
net10.0
enable
enable



```

Output (cancellation requested after 3 s, `WAITFOR DELAY` is 15 s):

```
A: WAITFOR only (reference) -> 3.0s | SqlException
B: RAISERROR WITH NOWAIT + WAITFOR -> 15.0s | TaskCanceledException
C: RAISERROR (no NOWAIT) + WAITFOR -> 3.0s | SqlException
D: PRINT + WAITFOR -> 3.0s | SqlException
```

Scenario B waits for the full 15 seconds — the server-side batch is never interrupted. The `TaskCanceledException` is only thrown once the server sends the next response data after the batch finished.

## Additional findings

Variations of scenario B, all cancelled after 3 s:

| Variation | Elapsed | Result |
|---|---|---|
| B as above (token only) | 15.0 s | `TaskCanceledException` after batch completed |
| B + explicit `cts.Token.Register(() => cmd.Cancel())` | 15.0 s | no improvement |
| B + `cts.Token.Register(() => conn.Close())` | 15.0 s | no improvement |
| B without an `InfoMessage` event handler subscribed | 15.0 s | no improvement (handler subscription is irrelevant) |
| B with **sync** `ExecuteReader()` in `Task.Run` + `cts.Token.Register(() => cmd.Cancel())` | **3.0 s** | works — server aborts immediately, `SqlException` ("A severe error occurred on the current command") |

The sync path sending the attention correctly while the async path does not suggests the problem is in the async read machinery: once the first response packet has been received (parser holding a partial-response snapshot), the cancellation path no longer transmits the attention packet; the cancellation is only surfaced when further response data arrives.

## Expected behavior

Cancelling the token (or calling `SqlCommand.Cancel()`) during async execution should send an attention signal to the server and abort the running batch promptly — exactly as it does when no partial response has been received yet, and as the sync path does in the same situation.

## Impact

Any application whose stored procedures emit progress messages via `RAISERROR ... WITH NOWAIT` (a common pattern for long-running procedures) effectively loses the ability to cancel those queries via async ADO.NET. The server keeps burning resources until the batch completes.

## Further technical details

- Microsoft.Data.SqlClient version: **7.0.1**
- .NET target: **net10.0** (SDK 10.0.301)
- SQL Server version: **SQL Server 2025 LocalDB (17.0.1000.7)** — also observed against a regular SQL Server instance
- Operating system: **Windows 11 Pro (10.0.26200)**

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.