Skip to content

Commit a1deff5

Browse files
committed
decompressionobj: refactor decompress()
The loop termination logic and handling of `unused_data` was a bit convoluted. The logic was subtle enough that it warrants documenting, which I did in the CFFI backend. We now only set `unused_data` after a frame is decoded. I'm not sure if this actually changed meaningful behavior. But that is the semantic intent of the attribute and we should have the logic mirror that. We also change a loop termination condition to avoid a `Zstd_decompressStream()` on input input in the case of a partially filled output buffer. I believe this is safe per the zstd API docs. One change here is we now append chunks before testing `zresult == 0`. If chunk append raises, this could change behavior so we no longer mark the decompressor as finalized. I believe the behavior was sufficiently undefined to not explicitly document in the changelog. Hopefully Hyrum's Law doesn't manifest.
1 parent 30bf0bf commit a1deff5

File tree

4 files changed

+40
-26
lines changed

4 files changed

+40
-26
lines changed

c-ext/decompressobj.c

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,12 @@ static PyObject *DecompressionObj_decompress(ZstdDecompressionObj *self,
6363
ZSTD_decompressStream(self->decompressor->dctx, &output, &input);
6464
Py_END_ALLOW_THREADS
6565

66-
if (ZSTD_isError(zresult)) {
66+
if (ZSTD_isError(zresult)) {
6767
PyErr_Format(ZstdError, "zstd decompressor error: %s",
6868
ZSTD_getErrorName(zresult));
6969
goto except;
7070
}
7171

72-
if (0 == zresult) {
73-
self->finished = 1;
74-
}
75-
7672
if (output.pos) {
7773
if (result) {
7874
resultSize = PyBytes_GET_SIZE(result);
@@ -93,15 +89,21 @@ static PyObject *DecompressionObj_decompress(ZstdDecompressionObj *self,
9389
}
9490
}
9591

96-
if (zresult == 0 || (input.pos == input.size && output.pos == 0)) {
92+
if (0 == zresult) {
93+
self->finished = 1;
94+
9795
/* We should only get here at most once. */
9896
assert(!self->unused_data);
9997
self->unused_data = PyBytes_FromStringAndSize((char *)(input.src) + input.pos, input.size - input.pos);
10098

10199
break;
102100
}
103-
104-
output.pos = 0;
101+
else if (input.pos == input.size && output.pos == 0) {
102+
break;
103+
}
104+
else {
105+
output.pos = 0;
106+
}
105107
}
106108

107109
if (!result) {

docs/news.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ Changes
9191
and previous release(s) likely worked with 3.11 without any changes.
9292
* CFFI's build system now respects distutils's ``compiler.preprocessor`` if it
9393
is set. (#179)
94+
* The internal logic of ``ZstdDecompressionObj.decompress()`` was refactored.
95+
This may have fixed unconfirmed issues where ``unused_data`` was set
96+
prematurely. The new logic will also avoid an extra call to
97+
``ZSTD_decompressStream()`` in some scenarios, possibly improving performance.
9498

9599
0.18.0 (released 2022-06-20)
96100
============================

rust-ext/src/decompressionobj.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,27 +62,28 @@ impl ZstdDecompressionObj {
6262
.decompress_into_vec(&mut dest_buffer, &mut in_buffer)
6363
.map_err(|msg| ZstdError::new_err(format!("zstd decompress error: {}", msg)))?;
6464

65-
if zresult == 0 {
66-
self.finished = true;
67-
// TODO clear out decompressor?
68-
}
69-
7065
if !dest_buffer.is_empty() {
7166
// TODO avoid buffer copy.
7267
let chunk = PyBytes::new(py, &dest_buffer);
7368
chunks.append(chunk)?;
7469
}
7570

76-
if zresult == 0 || (in_buffer.pos == in_buffer.size && dest_buffer.is_empty()) {
71+
if zresult == 0 {
72+
self.finished = true;
73+
// TODO clear out decompressor?
74+
7775
if let Some(data) = data.as_slice(py) {
7876
let unused = &data[in_buffer.pos..in_buffer.size];
7977
self.unused_data = unused.iter().map(|x| x.get()).collect::<Vec<_>>();
8078
}
8179

8280
break;
81+
} else if in_buffer.pos == in_buffer.size && dest_buffer.len() < dest_buffer.capacity()
82+
{
83+
break;
84+
} else {
85+
dest_buffer.clear();
8386
}
84-
85-
dest_buffer.clear();
8687
}
8788

8889
let empty = PyBytes::new(py, &[]);

zstandard/backend_cffi.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2974,22 +2974,29 @@ def decompress(self, data):
29742974
"zstd decompressor error: %s" % _zstd_error(zresult)
29752975
)
29762976

2977-
if zresult == 0:
2978-
self._finished = True
2979-
self._decompressor = None
2980-
2977+
# Always record any output from decompressor.
29812978
if out_buffer.pos:
29822979
chunks.append(ffi.buffer(out_buffer.dst, out_buffer.pos)[:])
29832980

2984-
if zresult == 0 or (
2985-
in_buffer.pos == in_buffer.size and out_buffer.pos == 0
2986-
):
2987-
# Preserve any remaining input to be exposed via `unused_data`.
2981+
# 0 is only seen when a frame is fully decoded *and* fully flushed.
2982+
# But there may be extra input data: make that available to
2983+
# `unused_input`.
2984+
if zresult == 0:
2985+
self._finished = True
2986+
self._decompressor = None
29882987
self._unused_input = data[in_buffer.pos : in_buffer.size]
2989-
29902988
break
29912989

2992-
out_buffer.pos = 0
2990+
# We're not at the end of the frame *or* we're not fully flushed.
2991+
2992+
# The decompressor will write out all the bytes it can to the output
2993+
# buffer. So if the output buffer is partially filled and the input
2994+
# is exhausted, there's nothing more to write. So we've done all we
2995+
# can.
2996+
elif in_buffer.pos == in_buffer.size and out_buffer.pos < out_buffer.size:
2997+
break
2998+
else:
2999+
out_buffer.pos = 0
29933000

29943001
return b"".join(chunks)
29953002

0 commit comments

Comments
 (0)