Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

Packet multiplexing: async nvarchar(max) read returns corrupted value, throws ArgumentException or spins when the value starts in a packet with header ID 0

オープン
#4,925 コメント 5 件 リアクション 2 件 担当者 2 名 GitHub で見る

メンテナーはふだん 2 日以内に返信

@cheenamalhotra がすでに取り組んでいます。

2026年10月27日 から。

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
50/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
csharp, postgresql, sql
領域
backend, databases

調査の方向性

Start in src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Parser/TdsParser.cs, especially IncrementSnapshotDataSize and the currentPacketId initialization identified in the issue. Trace how packet IDs and snapshot byte counts are handled when an async PLP read resumes, then add or locate focused coverage for nvarchar(max) reads across packet ID 0. Done means the repro no longer corrupts values, throws, or hangs with packet multiplexing enabled.

索引モデルが issue の本文から書いたものです。

説明

Describe the bug

With packet multiplexing enabled (UseCompatibilityAsyncBehaviour and UseCompatibilityProcessSni both set to false), an async read of an nvarchar(max) column sometimes goes wrong when the value starts in a TDS packet whose header packet ID is 0. There are three symptoms, depending on the data:

  1. ReadAsync throws ArgumentException from Buffer.BlockCopy in TryReadPlpUnicodeChars.
  2. GetString returns a value longer than the stored one, with no exception. This is silent data corruption.
  3. ReadAsync never completes, and one CPU core stays at 100% inside TryReadPlpUnicodeChars.

The packet ID is one byte, so it wraps from 255 to 0. Every 256th packet of a large response has ID 0. That is why this is rare in production but easy to hit with a large result set.

We hit symptom 1 in production on 7.0.0. The repro below shows all three on 7.0.0 and 7.1.1. The code is unchanged on main at 2704794c3.

Exception message: Offset and length were out of bounds for the array or count is greater than the number of elements from index to the end of the source collection.
Stack trace (7.0.0, production):
   at System.Buffer.BlockCopy(Array src, Int32 srcOffset, Array dst, Int32 dstOffset, Int32 count)
   at Microsoft.Data.SqlClient.TdsParser.TryReadPlpUnicodeCharsWithContinue(TdsParserStateObject stateObj, Int32 length, String& resultString)
   at Microsoft.Data.SqlClient.TdsParser.TryReadSqlStringValue(SqlBuffer value, Byte type, Int32 length, Encoding encoding, Boolean isPlp, TdsParserStateObject stateObj)
   at Microsoft.Data.SqlClient.TdsParser.TryReadSqlValue(SqlBuffer value, SqlMetaDataPriv md, Int32 length, TdsParserStateObject stateObj, SqlCommandColumnEncryptionSetting columnEncryptionOverride, String columnName, SqlCommand command)
   at Microsoft.Data.SqlClient.SqlDataReader.TryReadColumn(Int32 i, Boolean setTimeout, Boolean allowPartiallyReadColumn, Boolean forStreaming)
   at Microsoft.Data.SqlClient.SqlDataReader.ReadAsyncExecute(Task task, Object state)
   at Microsoft.Data.SqlClient.SqlDataReader.ContinueAsyncCall[T](Task task, SqlDataReaderBaseAsyncCallContext`1 context)

Stack trace (7.1.1, repro below):
   at System.Buffer.BlockCopy(Array src, Int32 srcOffset, Array dst, Int32 dstOffset, Int32 count)
   at Microsoft.Data.SqlClient.TdsParser.TryReadSqlValue(SqlBuffer value, SqlMetaDataPriv md, Int32 length, TdsParserStateObject stateObj, SqlCommandColumnEncryptionSetting columnEncryptionOverride, String columnName, SqlCommand command)
   at Microsoft.Data.SqlClient.SqlDataReader.TryReadColumnInternal(Int32 i, Boolean readHeaderOnly, Boolean forStreaming)
   at Microsoft.Data.SqlClient.SqlDataReader.ReadAsyncExecute(Task task, Object state)
   at Microsoft.Data.SqlClient.SqlDataReader.ContinueAsyncCall[T](Task task, SqlDataReaderBaseAsyncCallContext`1 context)
Cause

TryReadPlpUnicodeChars keeps a running count of how many bytes of the value each snapshot packet holds. When a replay starts from the beginning (startOffsetByteCount == 0), IncrementSnapshotDataSize has to throw away the count left over from the previous replay. It decides whether a packet is new by comparing its header packet ID with the ID it saw last:

https://github.com/dotnet/SqlClient/blob/2704794c350ed24c117e9af73971957ed1be98b9/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Parser/TdsParser.cs#L14104-L14121

But "the ID it saw last" starts at 0:

https://github.com/dotnet/SqlClient/blob/2704794c350ed24c117e9af73971957ed1be98b9/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Parser/TdsParser.cs#L13945

So when the first packet of the value has header ID 0, it counts as already seen. The stale count from the previous replay is added instead of reset. Every offset after that is too large. The next resume then starts writing past the real data. If the buffer has to grow, Buffer.BlockCopy(buff, 0, newbuf, 0, offst * 2) (L13991) copies more than the buffer holds and throws. If it doesn't have to grow, the string comes back too long. I have not traced the infinite loop in detail, but it shares the same trigger (see the experiment below).

To trigger it you need:

  • a response of more than 255 packets before the value, so a packet with ID 0 exists;
  • a value that starts in that packet and continues into the next one;
  • at least two pends while reading that value, so that a replay from the start sees counts from an earlier replay.
Suggested fix

Start the "last seen" ID at a value no packet can have:

-            int currentPacketId = 0;
+            int currentPacketId = -1;

I have not built SqlClient with this change. The --packet-ids nozero run below is the equivalent test: when no packet has ID 0, the check can never match by accident, and all three symptoms go away. Two consecutive packets always have different IDs, so after this change only a real repeat visit of the same packet counts as "already seen".

To reproduce

The program creates a database plp_repro with 3000 nvarchar(max) rows (about 19 MB). It reads them through a small TCP proxy that hands the server's bytes to the client in random pieces with short pauses. That makes reads pend in the middle of values, as they do on a real network. Every value is compared with what was stored.

With --packet-ids nozero the proxy renumbers the packet IDs 1..255, so no packet has ID 0. In 7.0.0, SqlClient reads the packet ID only for IncrementSnapshotDataSize (via GetSnapshotPacketID), so this changes nothing else.

Results on SQL Server 2016 (13.0.6490), Windows 10, .NET 10, 40 full reads per run, 4 in parallel:

Run Corrupt values ArgumentException Reads that never completed
7.1.1, switches false 52 8 6
7.1.1, switches false, --packet-ids nozero 0 0 0
7.1.1, --compat (switches true) 0 0 0

The same happens on 7.0.0. In a separate run where the proxy set every packet ID to 0, errors went up sharply (20 reads: 33 corrupt values, 12 ArgumentException, 4 IndexOutOfRangeException, 4 hangs).

The same rows fail again and again (for example row 1804, 23907 chars, comes back with 24018 to 25239 chars), because the result depends on where the data sits in the packets.

PlpRepro.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.1" />
  </ItemGroup>
</Project>
Program.cs
// Repro: async reads of nvarchar(max) with packet multiplexing enabled return corrupted strings,
// throw ArgumentException from TryReadPlpUnicodeChars, or spin forever, when a value starts in a
// TDS packet whose header packet ID is 0.
//
// Usage: dotnet run -c Release -- [--server 127.0.0.1,1433] [--packet-ids asis|nozero] [--compat]
// Auth: Integrated Security, or set SQL_AUTH="User ID=...;Password=...".
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Data.SqlClient;

bool compat = args.Contains("--compat");
AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseCompatibilityAsyncBehaviour", compat);
AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseCompatibilityProcessSni", compat);

string server = Arg("--server", "127.0.0.1,1433");
string packetIds = Arg("--packet-ids", "asis");
int iterations = int.Parse(Arg("--iterations", "40"));
string auth = Environment.GetEnvironmentVariable("SQL_AUTH") ?? "Integrated Security=true";
const string Db = "plp_repro";
const int Rows = 3000;

int[] lengths = Lengths(Rows);
await Setup($"Server={server};{auth};Encrypt=False;Database=master;Pooling=false");

using var proxy = new ChoppyProxy(server, packetIds);
string cs = $"Server=127.0.0.1,{proxy.Port};{auth};Encrypt=False;Database={Db};Pooling=false;Command Timeout=600";
Console.WriteLine($"SqlClient {FileVersionInfo.GetVersionInfo(typeof(SqlConnection).Assembly.Location).ProductVersion}, compat={compat}, packet IDs {packetIds}");

int corrupt = 0, hung = 0, shown = 0, shownErrors = 0;
var errors = new ConcurrentDictionary<string, int>();
await Parallel.ForAsync(0, iterations, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (_, ct) =>
{
    var read = Task.Run(ReadAll, ct);
    // A stuck read spins inside SqlClient and ignores cancellation, so it is abandoned.
    if (await Task.WhenAny(read, Task.Delay(TimeSpan.FromSeconds(30), ct)) != read)
    {
        Interlocked.Increment(ref hung);
    }
});

Console.WriteLine($"Reads: {iterations}, corrupt values: {corrupt}, hung reads: {hung}, server packets with ID 0: {proxy.PacketsWithIdZero}");
foreach (var (error, count) in errors)
{
    Console.WriteLine($"  {count} x {error}");
}

Console.Out.Flush();
Environment.Exit(corrupt + hung + errors.Count == 0 ? 0 : 1);

async Task ReadAll()
{
    try
    {
        await using var conn = new SqlConnection(cs);
        await conn.OpenAsync();
        await using var cmd = new SqlCommand("SELECT Id, Body FROM dbo.items ORDER BY Id", conn);
        await using var reader = await cmd.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
            int id = reader.GetInt32(0);
            string body = reader.GetString(1);
            if (body != Expected(id, lengths[id]))
            {
                Interlocked.Increment(ref corrupt);
                if (Interlocked.Increment(ref shown) <= 5)
                {
                    Console.WriteLine($"Row {id}: expected {lengths[id]} chars, got {body.Length}");
                }
            }
        }
    }
    catch (Exception ex)
    {
        errors.AddOrUpdate($"{ex.GetType().Name}: {ex.Message}", 1, static (_, n) => n + 1);
        if (Interlocked.Increment(ref shownErrors) == 1)
        {
            Console.WriteLine(ex);
        }
    }
}

string Arg(string name, string fallback)
{
    int i = Array.IndexOf(args, name);
    return i >= 0 && i + 1 < args.Length ? args[i + 1] : fallback;
}

static int[] Lengths(int rows)
{
    var rnd = new Random(1);
    return Enumerable.Range(0, rows).Select(_ =>
    {
        double p = rnd.NextDouble();
        return p < 0.60 ? rnd.Next(0, 400) : p < 0.90 ? rnd.Next(400, 4000) : p < 0.98 ? rnd.Next(4000, 20000) : rnd.Next(20000, 120000);
    }).ToArray();
}

static string Expected(int id, int length)
{
    const string pattern = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    var sb = new StringBuilder(length);
    for (int k = 0; k < length; k++)
    {
        sb.Append(pattern[(id + k) % pattern.Length]);
    }

    return sb.ToString();
}

async Task Setup(string master)
{
    await using (var conn = new SqlConnection(master))
    {
        await conn.OpenAsync();
        await Exec(conn, $"IF DB_ID('{Db}') IS NOT NULL BEGIN ALTER DATABASE [{Db}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{Db}]; END; CREATE DATABASE [{Db}];");
    }

    await using var c = new SqlConnection(master.Replace("Database=master", $"Database={Db}"));
    await c.OpenAsync();
    await Exec(c, "CREATE TABLE dbo.items (Id int NOT NULL PRIMARY KEY, Len int NOT NULL, Body nvarchar(max) NOT NULL DEFAULT N'');");
    for (int start = 0; start < Rows; start += 1000)
    {
        var values = Enumerable.Range(start, Math.Min(1000, Rows - start)).Select(i => $"({i},{lengths[i]})");
        await Exec(c, "INSERT INTO dbo.items (Id, Len) VALUES " + string.Join(",", values));
    }

    // Same content as Expected(): the pattern rotated by Id, cut to Len.
    await Exec(c, "UPDATE dbo.items SET Body = SUBSTRING(REPLICATE(CAST(N'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' AS nvarchar(max)), Len / 36 + 2), Id % 36 + 1, Len);");

    static async Task Exec(SqlConnection c, string sql)
    {
        await using var cmd = new SqlCommand(sql, c) { CommandTimeout = 600 };
        await cmd.ExecuteNonQueryAsync();
    }
}

/// <summary>
/// TCP proxy that hands the server's bytes to the client in random small pieces with short pauses,
/// so async reads run out of data in the middle of a value, as on a real network.
/// With "nozero" it renumbers the TDS header packet IDs 1..255 so that no packet has ID 0.
/// </summary>
sealed class ChoppyProxy : IDisposable
{
    private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
    private readonly string _server;
    private readonly string _packetIds;
    private long _packetsWithIdZero;
    private int _connections;

    public ChoppyProxy(string server, string packetIds)
    {
        _server = server;
        _packetIds = packetIds;
        _listener.Start();
        _ = Task.Run(async () =>
        {
            while (true)
            {
                var client = await _listener.AcceptTcpClientAsync();
                _ = Task.Run(() => Handle(client, Interlocked.Increment(ref _connections)));
            }
        });
    }

    public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
    public long PacketsWithIdZero => Interlocked.Read(ref _packetsWithIdZero);

    private async Task Handle(TcpClient client, int seed)
    {
        using var _ = client;
        using var server = new TcpClient();
        string[] hostPort = _server.Split(',');
        await server.ConnectAsync(hostPort[0], hostPort.Length > 1 ? int.Parse(hostPort[1]) : 1433);
        client.NoDelay = server.NoDelay = true;
        var up = client.GetStream().CopyToAsync(server.GetStream());
        var down = Down(server.GetStream(), client.GetStream(), new Random(seed));
        await Task.WhenAny(up, down);
    }

    private async Task Down(NetworkStream from, NetworkStream to, Random rnd)
    {
        var buffer = new byte[65536];
        int headerPos = 0, packetLength = 0, payloadLeft = 0, nextId = 0;
        try
        {
            int n;
            while ((n = await from.ReadAsync(buffer)) > 0)
            {
                // Walk the 8-byte TDS packet headers: bytes 2-3 are the packet length, byte 6 is the packet ID.
                for (int i = 0; i < n; i++)
                {
                    if (payloadLeft > 0)
                    {
                        int skip = Math.Min(payloadLeft, n - i);
                        payloadLeft -= skip;
                        i += skip - 1;
                        continue;
                    }

                    switch (headerPos)
                    {
                        case 2:
                            packetLength = buffer[i] << 8;
                            break;
                        case 3:
                            packetLength |= buffer[i];
                            break;
                        case 6:
                            if (_packetIds == "nozero")
                            {
                                buffer[i] = (byte)(nextId = (nextId % 255) + 1);
                            }

                            if (buffer[i] == 0)
                            {
                                Interlocked.Increment(ref _packetsWithIdZero);
                            }

                            break;
                    }

                    if (++headerPos == 8)
                    {
                        headerPos = 0;
                        payloadLeft = packetLength - 8;
                    }
                }

                for (int offset = 0, chunk; offset < n; offset += chunk)
                {
                    chunk = Math.Min(n - offset, rnd.Next(1, 3001));
                    await to.WriteAsync(buffer.AsMemory(offset, chunk));
                    if (rnd.NextDouble() < 0.3)
                    {
                        // ~200 µs busy pause: the reader drains what it has and has to wait for more.
                        long until = Stopwatch.GetTimestamp() + (Stopwatch.Frequency / 5000);
                        while (Stopwatch.GetTimestamp() < until)
                        {
                        }
                    }
                }
            }
        }
        catch (IOException)
        {
        }
    }

    public void Dispose() => _listener.Stop();
}
Expected behavior

ReadAsync returns every value exactly as stored, as it does with the compatibility switches at their defaults.

Further technical details

Microsoft.Data.SqlClient version: 7.0.0 (production), 7.0.0 and 7.1.1 (repro)
.NET target: .NET 10.0
SQL Server version: production: SQL Server 2019; repro: SQL Server 2016 SP3 (13.0.6490)
Operating system: production: Windows; repro: Windows 10

Additional context

The earlier 6.1.0 reports in #3519 and #3463 show the same exception and the same kinds of hang in this code path, and were hard to reproduce. They may share this cause.

主要言語
C#
スター
1k
フォーク
352
平均マージ
4日 12時間
マージ済み PR(30日)
87

環境構築

Codespaces で開く

このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

dotnet/SqlClient のほかの issue

dotnet/SqlClient の issue をすべて見る

似ている issue

C# の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。