Repository navigation
Fix StreamingDecompress#decompress dropping the frames after an empty or skippable frame - #166
Open
reidmorrison wants to merge 1 commit into
Open
reidmorrison wants to merge 1 commit into
reidmorrison wants to merge 1 commit into
Conversation
StreamingDecompress#decompress stopped as soon as a call to
ZSTD_decompressStream ended a frame without producing output, and
silently dropped the rest of its input. A frame produces no output when
it is empty or skippable, so the frames after one were lost:
skippable = [0x184D2A50, 4].pack('VV') + 'meta'
stream = Zstd::StreamingDecompress.new
stream.decompress(skippable + Zstd.compress('abc')) # => "", not "abc"
pzstd writes a skippable frame before each frame, so decompress returns
nothing for a pzstd file passed in one call, and raises "Unknown frame
descriptor" when it is passed in chunks, as the next chunk starts in the
middle of the frame that was dropped.
The loop decoded every frame up to v2.0.0; the break arrived with SpringMT#116
in v2.0.1. Remove it. The loop still ends once all of the input is
consumed: each call consumes input, flushes output, or returns an error
after libzstd sees no progress over several calls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Zstd::StreamingDecompress#decompressstops as soon as a frame that produced no output ends, and silently drops the rest of its input. Empty and skippable frames produce no output, so every frame after one is lost:pzstd, the parallel compressor that ships with zstd, writes a skippable frame before each frame. For a file written bypzstd(400 KB of content),decompressreturns 0 bytes when it is given the whole file, and raisesUnknown frame descriptorwhen it is given 64 KiB chunks, as the second chunk starts in the middle of the frame that was dropped.Zstd.decompress,decompress_with_posand thezstdcommand line tool decode it correctly.I found it while adding
StreamingDecompress#frame_complete?(#165) for IOStreams, a streaming file library that reads.zstfiles withZstd::StreamingDecompress.Cause
A regression from #116, in v2.0.1. Up to v2.0.0 the loop was
while (input.pos < input.size). #116 first stopped it after any frame (do ... while (input.pos < input.size && ret > 0)), then changed that to the currentbreakwhenret == 0 && output.pos == 0, which still stops after a frame without output. Built from the tags, v2.0.0 returns"abc"for both examples above, and v2.0.1 returns"".Fix
Remove the
break, with a comment so that it does not come back. The loop still ends once all of the input is consumed: each call toZSTD_decompressStreamconsumes input, flushes output, or returns an error after libzstd sees no progress over several calls, and the early return it takes on a truncated frame header consumes the rest of the input. I checked 8,026 truncated inputs (39 files written by thezstdcommand line tool and by this gem, cut at many points, passed whole and in 64 KiB chunks):decompressreturns a prefix of the content for each, and it raises for 200 random inputs.Compatibility
Only input with a frame after an empty or skippable frame, in the same call, changes:
decompressnow returns those frames' data instead of dropping it. All other input returns the same as before.Tests
spec/zstd-ruby-streaming-decompress_spec.rbadds specs that pass several frames todecompressin one call: two frames, frames after empty frames, and frames after skippable frames, also split within the frame after the first skippable frame. The last two fail onmain.bundle exec rakepasses with 94 examples, 0 failures (91 onmain), on Ruby 3.2, 3.4 and 4.0 on macOS (arm64), with no new compiler warnings.🤖 Generated with Claude Code