Skip to content

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

Description

@akley

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):

// 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<SqlCommand, CancellationTokenSource, Task> 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:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
  </ItemGroup>
</Project>

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    To triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions