Packet multiplexing: async nvarchar(max) read returns corrupted value, throws ArgumentException or spins when the value starts in a packet with header ID 0
メンテナーはふだん 2 日以内に返信
@cheenamalhotra がすでに取り組んでいます。
2026年10月27日 から。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 50/100
調査の方向性
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:
ReadAsyncthrowsArgumentExceptionfromBuffer.BlockCopyinTryReadPlpUnicodeChars.GetStringreturns a value longer than the stored one, with no exception. This is silent data corruption.ReadAsyncnever completes, and one CPU core stays at 100% insideTryReadPlpUnicodeChars.
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:
But "the ID it saw last" starts at 0:
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
環境構築
このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
dotnet/SqlClient のほかの issue
-
Sync ExecuteReader on an STA thread deadlocks against a StateChange handler that marshals to the UI thread (OpenAsyncRetry path)再び着手できるかも @mdaigle が 42 日前に担当しましたが、オープン中のプルリクエストはありません。 オープンExternal :link:
難易度 2/5 1〜3時間 初心者へのやさしさ 64/100
dotnet/SqlClient#4534 · コメント 2 件 · 担当者 1 名 ·
メンテナーはふだん 2 日以内に返信
-
Area\Tests
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
dotnet/SqlClient#4377 · コメント 2 件 ·
メンテナーはふだん 2 日以内に返信
-
難易度 4/5 3〜5日 初心者へのやさしさ 62/100
dotnet/SqlClient#4957 · コメント 2 件 ·
メンテナーはふだん 2 日以内に返信
-
難易度 3/5 1〜2日 初心者へのやさしさ 54/100
メンテナーはふだん 2 日以内に返信
-
Approval Needed Code Health :pill: Common Project 🚮 Design Review Needed Up-for-Grabs :raised_hands:
難易度 5/5 1週間以上 初心者へのやさしさ 25/100
dotnet/SqlClient#4940 · コメント 1 件 · リアクション 1 件 ·
メンテナーはふだん 2 日以内に返信
dotnet/SqlClient の issue をすべて見る
似ている issue
-
kind/bug triage/untriaged
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
unoplatform/uno.resizetizer#414 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
メンテナーはふだん 1 日以内に返信
-
possible bug
難易度 2/5 1〜3時間 初心者へのやさしさ 74/100
-
Include host.wslc.internal in the HTTPS development certificate対応中かも @afscrome が今日担当しました。 オープンarea-commandlinetools
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
dotnet/aspnetcore#70122 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
:1st_place_medal: good first issue :cowboy_hat_face: refactor :roller_coaster: enhancement
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
ApplETS/Backend-Hello#78 ·