From b074778f4b870dda34d489cbe632bd8d4dca127f Mon Sep 17 00:00:00 2001 From: Reid Morrison Date: Thu, 15 Oct 2026 00:01:19 -0400 Subject: [PATCH] Decompress every frame passed to StreamingDecompress#decompress 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 #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 --- ext/zstdruby/streaming_decompress.c | 5 ++--- spec/zstd-ruby-streaming-decompress_spec.rb | 25 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/ext/zstdruby/streaming_decompress.c b/ext/zstdruby/streaming_decompress.c index 58d623c..0528282 100644 --- a/ext/zstdruby/streaming_decompress.c +++ b/ext/zstdruby/streaming_decompress.c @@ -119,9 +119,8 @@ rb_streaming_decompress_decompress(VALUE obj, VALUE src) if (output.pos > 0) { rb_str_cat(result, output.dst, output.pos); } - if (ret == 0 && output.pos == 0) { - break; - } + /* Carry on after the end of a frame, even one without output such as an + empty or skippable frame: the rest of the input can hold more frames. */ } return result; } diff --git a/spec/zstd-ruby-streaming-decompress_spec.rb b/spec/zstd-ruby-streaming-decompress_spec.rb index 053c4ed..8a17068 100644 --- a/spec/zstd-ruby-streaming-decompress_spec.rb +++ b/spec/zstd-ruby-streaming-decompress_spec.rb @@ -17,6 +17,31 @@ end end + describe 'streaming decompress of several frames' do + it 'decompresses every frame' do + input = Zstd.compress('Hello, ') + Zstd.compress('World!') + expect(Zstd::StreamingDecompress.new.decompress(input)).to eq('Hello, World!') + end + + it 'decompresses the frames after an empty frame' do + input = Zstd.compress('') + Zstd.compress('Hello, ') + Zstd.compress('') + Zstd.compress('World!') + expect(Zstd::StreamingDecompress.new.decompress(input)).to eq('Hello, World!') + end + + it 'decompresses the frames after a skippable frame' do + # pzstd writes a skippable frame before each frame. + skippable = [0x184D2A50, 4].pack('VV') + 'meta' + input = skippable + Zstd.compress('Hello, ') + skippable + Zstd.compress('World!') + expect(Zstd::StreamingDecompress.new.decompress(input)).to eq('Hello, World!') + + # Split within the frame that follows the first skippable frame + stream = Zstd::StreamingDecompress.new + result = stream.decompress(input.byteslice(0, 15)) + result << stream.decompress(input.byteslice(15..-1)) + expect(result).to eq('Hello, World!') + end + end + describe 'decompress_with_pos' do it 'should return decompressed data and consumed input position' do str = "hello world test data"