Fix ZstandardStream truncating multi-frame zstd responses to the first frame - #129047
Conversation
|
Tagging subscribers to this area: @karelz, @dotnet/area-system-io-compression |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Updates Zstandard decompression to correctly handle multiple concatenated zstd frames (common in HTTP Content-Encoding: zstd) without truncation, and adds regression coverage for multi-frame and trailing-data scenarios.
Changes:
- Extend
ZstandardStreamdecompression to continue decoding across concatenated frames and distinguish frames vs. trailing non-zstd data. - Add tests to verify full output for concatenated frames (including across fragmented reads) and correct handling of trailing data after the final frame.
- Add decoder support method to clear managed end-of-frame state between frames.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/libraries/System.IO.Compression/tests/Zstandard/CompressionStreamUnitTests.Zstandard.cs | Adds regression tests for concatenated frames, across-read boundaries, and trailing-data handling. |
| src/libraries/System.IO.Compression/src/System/IO/Compression/Zstandard/ZstandardStream.Decompress.cs | Implements multi-frame decoding logic and adjusts truncation detection at frame boundaries. |
| src/libraries/System.IO.Compression/src/System/IO/Compression/Zstandard/ZstandardDecoder.cs | Adds PrepareForNextFrame to clear managed end-of-frame state for concatenated streams. |
|
@dotnet-policy-service agree |
rzikm
left a comment
There was a problem hiding this comment.
LGTM in general, left some comments.
Since multiple concatenated frames are actually common, I am not sure it makes sense to do hard stop in ZstandardDecoder at the end of the frame anymore, but since we have Reset() then users can just call that to get the decoder ready for the next frame, and it becomes just a matter of documentation.
|
makes sense. plan from the comments:
i'll push these as a few separate commits. |
Is there any risk with moving the logic into the decoder so that dealing with such streams "just works" there as well? |
I am not sure how that would work, what if the frame happens to end at the end of supplied buffer, do we report
If we stick to returning But this actually brings up another idea: instead of detecting the zstd headers in managed code, we could just call Decompress on the bytes that follow and watch out for |
Hi, Thanks for the replies. I was busy the entire week so I haven't been able to take a look at the comments. I'll take a look at the suggested changes over the weekend. Happy coding |
|
Hey, @christosk92, gentle ping here, did you have time to take a look on the review comments? |
unfortunately not, im sorry. I will take a look at them this evening so expect an update tmrw. |
|
fwiw this is exactly what the gzip path we're mirroring does: after a member ends, Inflater.ResetStreamForLeftoverInput peeks the next bytes for the gzip header id (0x1f 0x8b) and only continues if they match, otherwise it treats the leftover as trailing data and stops. and because it keys off the magic, a trailing chunk that does start like a member but is corrupt still gets decoded and throws, rather than being silently dropped. that's the throw-vs-rewind split i want to keep for zstd, and dropping the magic check loses it. |
@MihaZupan yeah it's a common one, that's basically why i opened this. for the streaming case (ZstandardStream) it does just work after this change, which is the scenario people actually hit (httpclient, Content-Encoding: zstd, etc). for the low level ZstandardDecoder i think @rzikm 's point is the real blocker: if a frame ends right at the end of the supplied buffer the decoder can't tell whether another frame follows, so it'd either report NeedMoreData on input that's actually complete, or report Done without consuming anything when there's just trailing garbage. neither is a good default. so the plan is to keep the per-frame Done on the decoder (now documented, and Reset() readies it for the next frame) and let ZstandardStream own the continuation, which also keeps it consistent with the other Decoder classes. |
|
@christosk92 Another ping here, did you manage to take a look a the feedback? We would like to get this in soon-ish so that it gets released in one of the next previews (I think it is too late for preview 6 already) I understand that you do this outside of your job, so it's fine to not have time to invest into it If you don't want to spend the time to drag this into completion than I am okay with taking it over. |
|
hi @rzikm. Sorry It was just a bad timing with some deadlines. it is now better so I will address your comments and come with a fix by end of the weekend so you can take a look at them by monday. |
we have those for compression, but not decompression, yeah |
rzikm
left a comment
There was a problem hiding this comment.
LGTM, but I still want to try avoiding recognizing Zstd headers in managed code, if that does not pan out, I will merge this as-is
…Zstandard/ZstandardStream.Decompress.cs
Implementing this looks a bit above my paygrade however Im willing to give this a shot, so I ran your request in copilot and it came up with these potential risks:
Please let me know if these risks are worth the change and I will go ahead and try to implement it, will also be a nice learning experience for me. |
|
Both are solved by feeding the decoder only 4 bytes (the magic prefix)
I have a patch ready locally, but I can wait so that you can give it a try if you want. |
|
@rzikm |
|
@christosk92 You have replied to my comments but did not push any new changes, is that intentional? |
hi, yes i was awaiting some confirmation on some points before i make changes. |
|
I replied now |
|
thanks, will make these changes today and test it e2e with couple of zstd streams |
|
hi, I pushed the inline version. I dropped IsFrameStart from the decoder. the check is now inlined in AdvanceToNextFrame and feeds the next 4 magic bytes straight into the public Decompress, with the scratch byte from the buffer's free space (can't stackalloc sadly in an async method). a valid magic comes back NeedMoreData so i Discard it and keep decoding, anything else is trailing data. on testing, i went a bit overboard haha. i put the full Read/ReadAsync/AdvanceToNextFrame path through a heavy stress test that exercises it e2e against zstd over a large matrix of multi-frame streams (3100+ distinct streams, 325 assertions), sync and async, over seekable, single-byte-per-read, and chunked non-seekable base streams. all passed.
|
|
thanks, and again i appreciate your patience on this. |
…t frame (#129047) Fixes #129038. ## Problem A zstd stream can be several frames concatenated back to back (RFC 8878 §3). Many encoders and CDNs emit one frame per buffer, so a large `Content-Encoding: zstd` response often arrives as multiple frames. `ZstandardStream` only decoded the first frame and dropped the rest, which surfaced downstream as truncated data. In the linked issue, `System.Text.Json` fails at exactly `BytePositionInLine: 65536`. The cause is in `ZstandardDecoder.Decompress`: ```csharp if (result == 0) { _finished = true; return OperationStatus.Done; } ``` A `0` return from `ZSTD_decompressStream` means the current frame is done, not that the whole stream is done (`lib/zstd.h`: "0 when a frame is completely decoded and fully flushed"). Because `_finished` is a one-way latch and `Decompress` returns early once it is set, finishing the first frame permanently stops decoding and every later frame is discarded. ## Fix `ZstandardStream` now continues into the next frame instead of stopping at the first. This follows the pattern `GZipStream` and `DeflateStream` already use for concatenated members, adapted to zstd. - `TryDecompress` decodes a single frame and returns when the decoder reports `Done`. The frame-to-frame loop now lives in `Read` and `ReadAsync`, with a single `AdvanceToNextFrame(bool async, ...)` helper handling the boundary for both. - At a frame boundary, `AdvanceToNextFrame` reads up to the next 4-byte frame magic and feeds exactly those bytes to the decoder through the public `ZstandardDecoder.Decompress` (after a session-only `Reset()`). A valid magic comes back as `NeedMoreData`, so the stream discards the magic from its buffer and keeps decoding; anything else is trailing data after the final frame. Feeding only the magic (not the whole buffer) means a frame whose magic is valid but whose body is corrupt is not mistaken for trailing data: the magic is accepted and the corrupt body is rejected by the following decode. - When the boundary check decides the stream is finished it sets `_endOfStream`, so later reads return 0 without re-entering the decoder (which may have been left in an error state by feeding it non-magic bytes). On a seekable stream the position is rewound so trailing data is still readable by the caller. - `Reset()` is `ZSTD_DCtx_reset` in session-only mode, so window size and any referenced dictionary carry over to the next frame. It also releases the single-use prefix, which is only valid for one frame anyway. - An empty frame (one that produces no output) is no longer mistaken for end of stream, so an empty leading or middle frame doesn't cut decoding short. - End of input counts as a clean end only when the last frame finished on a frame boundary, so the existing truncation check still fires for a genuinely truncated frame. The one-shot helpers were already correct across frames (`ZstandardDecoder.TryDecompress` calls `ZSTD_decompress` and `TryGetMaxDecompressedLength` calls `ZSTD_decompressBound`, both multi-frame aware), so they are unchanged. ## Tests Added to `CompressionStreamUnitTests.Zstandard.cs`, each run sync and async: - `ZstandardStream_ConcatenatedFrames_DecompressesAllFrames`: two concatenated frames, asserts the whole payload comes back (only the first frame decoded before the fix). - `ZstandardStream_ConcatenatedFrames_AcrossReads_DecompressesAllFrames`: a non-seekable stream that returns one byte per read, so a frame magic can land split across reads. Covers the HttpClient style path with no rewind. - `ZstandardStream_EmptyFramesAmongFrames_DecompressesAllFrames`: empty frames mixed in with real ones, to confirm an empty frame isn't read as end of stream. - `ZstandardStream_FrameFollowedByShortTrailingData_StopsAtEndOfFrame`: a frame followed by 1 to 3 trailing bytes (shorter than the magic), checks the stream ends cleanly and the trailing bytes stay available on the base stream. - `ZstandardStream_FrameFollowedByCorruptFrame_Throws`: a frame followed by a second frame with a valid magic but a corrupt body, asserts `InvalidDataException` so a corrupt continuation isn't swallowed as trailing data. - `ZstandardStream_SkippableFrameBetweenFrames_DecompressesAllFrames`: a skippable frame between two real frames, asserts both real frames decode. Existing tests, including `StreamTruncation_IsDetected`, still pass. ## Notes Next-frame detection feeds the 4 boundary bytes straight to the decoder rather than recognizing the magic in managed code: a valid frame magic (standard or skippable) comes back as `OperationStatus.NeedMoreData`, anything else as `InvalidData`. This keeps the managed side out of knowing the frame-format magics, and a corrupt frame body is still surfaced as `InvalidDataException` by the subsequent decode rather than being swallowed as trailing data. Repro: https://github.com/christosk92/zstd-net11-repro --------- Co-authored-by: Radek Zikmund <32671551+rzikm@users.noreply.github.com>
Fixes #129038.
Problem
A zstd stream can be several frames concatenated back to back (RFC 8878 §3). Many encoders and CDNs emit one frame per buffer, so a large
Content-Encoding: zstdresponse often arrives as multiple frames.ZstandardStreamonly decoded the first frame and dropped the rest, which surfaced downstream as truncated data. In the linked issue,System.Text.Jsonfails at exactlyBytePositionInLine: 65536.The cause is in
ZstandardDecoder.Decompress:A
0return fromZSTD_decompressStreammeans the current frame is done, not that the whole stream is done (lib/zstd.h: "0 when a frame is completely decoded and fully flushed"). Because_finishedis a one-way latch andDecompressreturns early once it is set, finishing the first frame permanently stops decoding and every later frame is discarded.Fix
ZstandardStreamnow continues into the next frame instead of stopping at the first. This follows the patternGZipStreamandDeflateStreamalready use for concatenated members, adapted to zstd.TryDecompressdecodes a single frame and returns when the decoder reportsDone. The frame-to-frame loop now lives inReadandReadAsync, with a singleAdvanceToNextFrame(bool async, ...)helper handling the boundary for both.AdvanceToNextFramereads up to the next 4-byte frame magic and feeds exactly those bytes to the decoder through the publicZstandardDecoder.Decompress(after a session-onlyReset()). A valid magic comes back asNeedMoreData, so the stream discards the magic from its buffer and keeps decoding; anything else is trailing data after the final frame. Feeding only the magic (not the whole buffer) means a frame whose magic is valid but whose body is corrupt is not mistaken for trailing data: the magic is accepted and the corrupt body is rejected by the following decode._endOfStream, so later reads return 0 without re-entering the decoder (which may have been left in an error state by feeding it non-magic bytes). On a seekable stream the position is rewound so trailing data is still readable by the caller.Reset()isZSTD_DCtx_resetin session-only mode, so window size and any referenced dictionary carry over to the next frame. It also releases the single-use prefix, which is only valid for one frame anyway.The one-shot helpers were already correct across frames (
ZstandardDecoder.TryDecompresscallsZSTD_decompressandTryGetMaxDecompressedLengthcallsZSTD_decompressBound, both multi-frame aware), so they are unchanged.Tests
Added to
CompressionStreamUnitTests.Zstandard.cs, each run sync and async:ZstandardStream_ConcatenatedFrames_DecompressesAllFrames: two concatenated frames, asserts the whole payload comes back (only the first frame decoded before the fix).ZstandardStream_ConcatenatedFrames_AcrossReads_DecompressesAllFrames: a non-seekable stream that returns one byte per read, so a frame magic can land split across reads. Covers the HttpClient style path with no rewind.ZstandardStream_EmptyFramesAmongFrames_DecompressesAllFrames: empty frames mixed in with real ones, to confirm an empty frame isn't read as end of stream.ZstandardStream_FrameFollowedByShortTrailingData_StopsAtEndOfFrame: a frame followed by 1 to 3 trailing bytes (shorter than the magic), checks the stream ends cleanly and the trailing bytes stay available on the base stream.ZstandardStream_FrameFollowedByCorruptFrame_Throws: a frame followed by a second frame with a valid magic but a corrupt body, assertsInvalidDataExceptionso a corrupt continuation isn't swallowed as trailing data.ZstandardStream_SkippableFrameBetweenFrames_DecompressesAllFrames: a skippable frame between two real frames, asserts both real frames decode.Existing tests, including
StreamTruncation_IsDetected, still pass.Notes
Next-frame detection feeds the 4 boundary bytes straight to the decoder rather than recognizing the magic in managed code: a valid frame magic (standard or skippable) comes back as
OperationStatus.NeedMoreData, anything else asInvalidData. This keeps the managed side out of knowing the frame-format magics, and a corrupt frame body is still surfaced asInvalidDataExceptionby the subsequent decode rather than being swallowed as trailing data.Repro: https://github.com/christosk92/zstd-net11-repro