Packet multiplexing: async nvarchar(max) read returns corrupted value, throws ArgumentException or spins when the value starts in a packet with header ID 0
I maintainer di solito rispondono entro 2 giorni
@cheenamalhotra ci sta già lavorando.
Dal 27/10/2026.
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 50/100
Direzione di ricerca
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.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
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.
- Lingua principale
- C#
- Stelle
- 1k
- Fork
- 352
- Merge medio
- 4g 12h
- PR unite (30g)
- 87
Preparare l'ambiente
Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di dotnet/SqlClient
-
Sync ExecuteReader on an STA thread deadlocks against a StateChange handler that marshals to the UI thread (OpenAsyncRetry path)Forse di nuovo libera @mdaigle l’ha presa 42 giorni fa e non c’è nessuna pull request aperta. ApertaExternal :link:
Difficoltà 2/5 1-3 ore Idoneità per principianti 64/100
dotnet/SqlClient#4534 · 2 commenti · 1 assegnatario ·
I maintainer di solito rispondono entro 2 giorni
-
Area\Tests
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
dotnet/SqlClient#4377 · 2 commenti ·
I maintainer di solito rispondono entro 2 giorni
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 62/100
dotnet/SqlClient#4957 · 2 commenti ·
I maintainer di solito rispondono entro 2 giorni
-
Difficoltà 3/5 1-2 giorni Idoneità per principianti 54/100
I maintainer di solito rispondono entro 2 giorni
-
Approval Needed Code Health :pill: Common Project 🚮 Design Review Needed Up-for-Grabs :raised_hands:
Difficoltà 5/5 Più di una settimana Idoneità per principianti 25/100
dotnet/SqlClient#4940 · 1 commento · 1 reazione ·
I maintainer di solito rispondono entro 2 giorni
Tutte le issue di dotnet/SqlClient
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 67/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 76/100
GameShowLDA/AskMkiM#1075 ·
I maintainer di solito rispondono entro 1 giorno
-
kind/bug triage/untriaged
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
unoplatform/uno.resizetizer#414 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 65/100
I maintainer di solito rispondono entro 1 giorno