Environment
- RustPython
d31ccae04d039ca3ab294742f2493aa5f2b63ff7, host build (x86_64-unknown-linux-gnu,
debug), zlib.ZLIB_VERSION = 1.3.0-zlib-rs-0.6.8.
crates/common/src/compression/zlib.rs and crates/stdlib/src/zlib.rs on main
(a7d75d25a7b2c291720411dc65445accd972e69d, 2026-09-29) are byte-identical to d31ccae, so the
line numbers below hold for both.
- Reference: CPython 3.13.5.
Reproducer
import zlib
data = zlib.compress(b"x" * 20000) + b"NEXT"
d = zlib.decompressobj()
out = d.decompress(data, 100)
print("call 1:", len(out), d.eof, len(d.unconsumed_tail), d.unused_data)
out = d.decompress(d.unconsumed_tail, 100000)
print("call 2:", len(out), d.eof, d.unconsumed_tail, d.unused_data)
print("flush:", d.flush(), d.unconsumed_tail, d.unused_data)
CPython 3.13.5:
call 1: 100 False 28 b''
call 2: 19900 True b'NEXT' b'NEXT'
flush: b'' b'NEXT' b'NEXTNEXT'
RustPython d31ccae:
call 1: 100 False 29 b''
call 2: 19900 True b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe8\xc0\x00eG\xa1\x1dNEXT' b'NEXT'
flush: b'' b'' b'NEXT\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe8\xc0\x00eG\xa1\x1dNEXT'
After call 2 the stream is at its end. CPython reports only the bytes after the stream end in
unconsumed_tail. RustPython reports the whole unconsumed_tail of call 1: compressed bytes that
call 2 already consumed, followed by NEXT. flush() then appends those stale bytes to
unused_data. (The call 1 tail length, 28 vs 29, is a separate difference in how far inflate reads
ahead; this report does not depend on it.)
Source
crates/common/src/compression/zlib.rs, Decompressor::decompress_inner, lines 674–683:
let unconsumed = &data[consumed..];
if !unconsumed.is_empty() {
if stream_end {
unused_data.extend_from_slice(unconsumed);
} else {
*unconsumed_tail = unconsumed.to_vec();
}
} else if !unconsumed_tail.is_empty() {
unconsumed_tail.clear();
}
When stream_end is true and input is left over, the branch updates unused_data and leaves
unconsumed_tail unchanged, so it still holds the previous call's tail. Decompressor::flush
(line 702) takes that stale unconsumed_tail as its input, and decompress_inner moves it to
unused_data. CPython's observed result sets unconsumed_tail to the bytes after the stream end.
Impact
urllib3 2.x (checked against 2.8.0) GzipDecoder.decompress continues a multi-member body with
self._obj.unconsumed_tail or self._obj.unused_data and opens a new decompressobj when
self._obj.eof is true. On RustPython the stale tail is fed to the new object as the next gzip
member. Measured with urllib3 2.8.0's GzipDecoder class copied into a script (no network),
body gzip.compress(b"x" * 20000) * 12, drained with max_length=10240:
|
output bytes |
final _state |
| CPython 3.13.5 |
240 000 |
OTHER_MEMBERS |
RustPython d31ccae |
20 000 |
SWALLOW_DATA |
On RustPython the second member raises zlib.error inside the decoder, the decoder switches to
SWALLOW_DATA, and 11 of 12 members are dropped with no error to the caller.
The reproducers above were run locally on RustPython and CPython.
🤖 Written by Claude Code (2.1.283) using model claude-opus-5-5
Environment
d31ccae04d039ca3ab294742f2493aa5f2b63ff7, host build (x86_64-unknown-linux-gnu,debug),
zlib.ZLIB_VERSION=1.3.0-zlib-rs-0.6.8.crates/common/src/compression/zlib.rsandcrates/stdlib/src/zlib.rsonmain(
a7d75d25a7b2c291720411dc65445accd972e69d, 2026-09-29) are byte-identical tod31ccae, so theline numbers below hold for both.
Reproducer
CPython 3.13.5:
RustPython
d31ccae:After call 2 the stream is at its end. CPython reports only the bytes after the stream end in
unconsumed_tail. RustPython reports the wholeunconsumed_tailof call 1: compressed bytes thatcall 2 already consumed, followed by
NEXT.flush()then appends those stale bytes tounused_data. (The call 1 tail length, 28 vs 29, is a separate difference in how far inflate readsahead; this report does not depend on it.)
Source
crates/common/src/compression/zlib.rs,Decompressor::decompress_inner, lines 674–683:When
stream_endis true and input is left over, the branch updatesunused_dataand leavesunconsumed_tailunchanged, so it still holds the previous call's tail.Decompressor::flush(line 702) takes that stale
unconsumed_tailas its input, anddecompress_innermoves it tounused_data. CPython's observed result setsunconsumed_tailto the bytes after the stream end.Impact
urllib3 2.x (checked against 2.8.0)
GzipDecoder.decompresscontinues a multi-member body withself._obj.unconsumed_tail or self._obj.unused_dataand opens a newdecompressobjwhenself._obj.eofis true. On RustPython the stale tail is fed to the new object as the next gzipmember. Measured with urllib3 2.8.0's
GzipDecoderclass copied into a script (no network),body
gzip.compress(b"x" * 20000) * 12, drained withmax_length=10240:_stateOTHER_MEMBERSd31ccaeSWALLOW_DATAOn RustPython the second member raises
zlib.errorinside the decoder, the decoder switches toSWALLOW_DATA, and 11 of 12 members are dropped with no error to the caller.The reproducers above were run locally on RustPython and CPython.
🤖 Written by Claude Code (2.1.283) using model claude-opus-5-5