Skip to content

HttpClient / ZstandardStream silently truncates multi-frame zstd responses to the first frame (.NET 11 preview 4) #129038

@christosk92

Description

@christosk92

TL;DR

After upgrading a desktop app from .NET 10 to .NET 11 preview 4, large HTTP responses started getting silently truncated. It looks like the new zstd decompression path treats a single completed frame as the end of the whole stream, so any Content-Encoding: zstd response made of multiple concatenated frames loses everything after the first frame — with no exception.

How I ran into it

I maintain a WinUI 3 client that talks to Spotify's Pathfinder GraphQL endpoint (https://api-partner.spotify.com/pathfinder/v2/query). My HttpClient handler is configured the obvious way:

new SocketsHttpHandler { AutomaticDecompression = DecompressionMethods.All }

This worked fine on .NET 10. The moment I retargeted to .NET 11 preview 4 (11.0.100-preview.4.26230.115), the bigger GraphQL calls (home feed, artist overview) began failing while small ones kept working. The failure surfaced in System.Text.Json as a body that just ends partway through:

System.Text.Json.JsonException: Expected end of string, but instead reached end of data.
Path: $.data.home.sectionContainer.sections.items[3].sectionItems.items[0].content.data
| LineNumber: 0 | BytePositionInLine: 65536.
   ...
   at System.Text.Json.JsonSerializer.Deserialize[TValue](String json, JsonTypeInfo`1 jsonTypeInfo)
   at <my code>.PathfinderClient.QueryAsync[T](...)

Always at BytePositionInLine: 65536 (the body is single-line minified JSON, hence LineNumber: 0). It truncated identically whether I read via ReadAsStringAsync() + JsonSerializer.Deserialize(string) or ReadAsStreamAsync() + DeserializeAsync(stream), so the body was already 64 KB by the time my code touched it — upstream of my read.

What changed between .NET 10 and 11 for this handler: DecompressionMethods.All is -1 (all bits), and .NET 11 added DecompressionMethods.Zstandard = 8. So on .NET 11 my client started advertising Accept-Encoding: …, zstd and the server switched to zstd. Confirmed with a raw request in Postman:

Request : Accept-Encoding: zstd
Response: content-encoding: zstd
          content-length: 10086      <- the full compressed body (~10 KB) arrives intact

So the compressed body is delivered completely (this is not an HTTP/2 / transport truncation), but it decompresses to well over 64 KB of JSON and gets cut at exactly 65536 bytes — i.e. the server emits the payload as multiple concatenated zstd frames (~64 KB each) and .NET stops after the first one.

Minimal repro (no Spotify, no auth, deterministic)

using System.IO.Compression;

static byte[] Frame(byte[] data)
{
    using var ms = new MemoryStream();
    using (var z = new ZstandardStream(ms, CompressionMode.Compress, leaveOpen: true))
        z.Write(data, 0, data.Length);
    return ms.ToArray();
}

var a = new byte[100_000]; var b = new byte[100_000];
Array.Fill(a, (byte)'A'); Array.Fill(b, (byte)'B');

// Two concatenated zstd frames = one valid zstd stream (RFC 8878 §3).
byte[] body = [.. Frame(a), .. Frame(b)];

using var input = new MemoryStream(body);
using var z = new ZstandardStream(input, CompressionMode.Decompress);
using var outMs = new MemoryStream();
z.CopyTo(outMs);

Console.WriteLine(outMs.Length);   // expected 200000, actual 100000

Expected: 200000 — both frames (A×100000 then B×100000).
Actual: 100000 — first frame only; the second frame is silently dropped.

I also reproduced the exact path my app hits — HttpClient with AutomaticDecompression = DecompressionMethods.Zstandard against a local HttpListener that serves the same two-frame body with Content-Encoding: zstd. Full matrix from the repro:

Runtime      : .NET 11.0.0-preview.4.26230.115
Architecture : OS=Arm64, Process=Arm64

PASS  ZstandardStream  | single frame (200 KB)  expected=  200000  actual=  200000
FAIL  ZstandardStream  | 2 concatenated frames  expected=  200000  actual=  100000
PASS  HttpClient(zstd) | single frame (200 KB)  expected=  200000  actual=  200000
FAIL  HttpClient(zstd) | 2 concatenated frames  expected=  200000  actual=  100000

A single frame larger than 64 KB decompresses correctly, so this is not an output-buffer-size limit — it is strictly the second frame. Runnable repro project: https://github.com/christosk92/zstd-net11-repro

Where it might be

I haven't dug into the internals, but it looks like it's in the new zstd decompression support (the System.IO.Compression.Zstandard stream/decoder used by HttpClient automatic decompression, around #123531) — specifically the decoder appearing to stop at the first frame rather than continuing across concatenated frames, which ZSTD_decompressStream supports natively. Happy to dig further if useful; I'll leave the exact spot to you.

Why this is worth fixing before RTM

  • Silent data corruption — no exception, just a short result. In my case it only showed up as a downstream System.Text.Json parse error; a consumer not parsing strict JSON would get truncated data with no signal at all.
  • Only large responses are affected (small single-frame bodies work), so it slips through casual testing and bites at production-sized payloads.
  • DecompressionMethods.All silently opts everyone in. Code that set AutomaticDecompression = DecompressionMethods.All on .NET ≤10 had no zstd; on .NET 11 the same line negotiates zstd, so existing apps inherit this the moment they retarget — against any server/CDN that emits multi-frame Content-Encoding: zstd (common; many encoders flush a frame per buffer).

Workaround (for anyone who lands here)

Exclude zstd until this is fixed; the server falls back to brotli, which decodes correctly:

AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli

Configuration

  • .NET SDK 11.0.100-preview.4.26230.115 / runtime 11.0.0-preview.4.26230.115
  • Windows 11, ARM64
  • Affected: System.IO.Compression.ZstandardStream, HttpClient / SocketsHttpHandler AutomaticDecompression = DecompressionMethods.Zstandard (and therefore DecompressionMethods.All)

Related

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions