Repro:
import brotlicffi
# Highly compressible data over a SMALL window (lgwin=12 = 4 KiB): a tiny
# compressed prefix expands hugely, forcing the many-tiny-buffer decode path.
original = b"".join(bytes([65 + (i % 26)]) * 4096 for i in range(16)) # 64 KiB
compressed = brotlicffi.compress(original, lgwin=12)
assert len(compressed) > 64 # 64 is a genuine mid-stream prefix
d = brotlicffi.Decompressor()
d.decompress(compressed[:64]) # feed a mid-stream prefix, NO output_buffer_limit
# The prefix was fully consumed and nothing is buffered, yet the decoder claims
# it cannot accept more input:
assert not d.can_accept_more_data() # BUG: should be True
d.decompress(compressed[64:]) # raises: can_accept_more_data() is False
Output:
Traceback (most recent call last):
File "/home/ubuntu/hacking/aiohttp/test.py", line 15, in <module>
d.decompress(compressed[64:]) # raises: can_accept_more_data() is False
~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
File "/home/ubuntu/.local/lib/python3.14/site-packages/brotlicffi/_api.py", line 437, in decompress
chunks = self._decompress(data, output_buffer_limit)
File "/home/ubuntu/.local/lib/python3.14/site-packages/brotlicffi/_api.py", line 444, in _decompress
raise error(
...<2 lines>...
)
brotlicffi._api.error: brotli: decoder process called with data when 'can_accept_more_data()' is False
Description (AI summary)
- No limit → tiny output buffers. With no limit and non-empty input, the buffer for each decode iteration is sized at just 5 * len(input):
_calculate_buffer_size, no-limit branch → 5 * input_data_len (
|
else: |
|
# Allocate a buffer that's hopefully overlarge, but if it's not we |
|
# don't mind: we'll spin around again. |
|
return 5 * input_data_len |
)
For a high-expansion chunk (e.g. 1 KiB compressed → ~1 MiB out, especially with a small lgwin), that buffer fills long before the input is exhausted, so the loop spins many times on NEEDS_MORE_OUTPUT.
-
On every iteration where input remains, it stashes the remainder. Whenever available_in > 0 after a decode step, the leftover compressed bytes are copied into self._unconsumed_data:
the save-unconsumed block (
|
if available_in[0] > 0: |
|
remaining_input = ffi.buffer(next_in[0], available_in[0])[:] |
|
self._unconsumed_data = remaining_input |
)
-
The final iteration consumes the rest but never clears the stash. When a later iteration consumes the last bytes and the decoder returns NEEDS_MORE_INPUT, the loop breaks — and that break asserts available_in
== 0, so the save-block in step 2 does not run on this pass:
the NEEDS_MORE_INPUT break (
|
if rc == lib.BROTLI_DECODER_RESULT_NEEDS_MORE_INPUT: |
|
assert available_in[0] == 0 |
|
break |
)
Nothing ever resets self._unconsumed_data, so it keeps a stale copy of bytes that were already decoded into the output. (My trace: 1024-byte prefix → 205 iterations of 5120-byte buffers; iter 204 had
available_in=4 and stashed those 4 bytes, iter 205 consumed them to 0 and broke — leaving the stale 4 bytes behind, with HasMoreOutput False.)
-
can_accept_more_data() now lies. It reports "not ready" purely because the stash is non-empty, even though the decoder actually finished the input and has no buffered output:
can_accept_more_data → False when _unconsumed_data is non-empty (
|
if len(self._unconsumed_data) > 0: |
|
ret = False |
)
-
The next call blows up. Either path fails:
- Feeding new data hits the guard that assumes non-empty _unconsumed_data means the caller ignored the protocol, and raises can_accept_more_data() is False: the guard
(
|
if self._unconsumed_data and data: |
|
raise error( |
|
"brotli: decoder process called with data when " |
|
"'can_accept_more_data()' is False" |
|
) |
)
- Feeding b"" (to "drain") re-feeds the stale, already-consumed bytes into the decoder, which is now past that stream position → BrotliDecoderDecompressStream returns an error (ERROR_FORMAT*).
Repro:
Output:
Description (AI summary)
_calculate_buffer_size, no-limit branch → 5 * input_data_len (
brotlicffi/src/brotlicffi/_api.py
Lines 411 to 414 in 3fd4d20
For a high-expansion chunk (e.g. 1 KiB compressed → ~1 MiB out, especially with a small lgwin), that buffer fills long before the input is exhausted, so the loop spins many times on NEEDS_MORE_OUTPUT.
On every iteration where input remains, it stashes the remainder. Whenever available_in > 0 after a decode step, the leftover compressed bytes are copied into self._unconsumed_data:
the save-unconsumed block (
brotlicffi/src/brotlicffi/_api.py
Lines 501 to 503 in 3fd4d20
The final iteration consumes the rest but never clears the stash. When a later iteration consumes the last bytes and the decoder returns NEEDS_MORE_INPUT, the loop breaks — and that break asserts available_in
== 0, so the save-block in step 2 does not run on this pass:
the NEEDS_MORE_INPUT break (
brotlicffi/src/brotlicffi/_api.py
Lines 512 to 514 in 3fd4d20
Nothing ever resets self._unconsumed_data, so it keeps a stale copy of bytes that were already decoded into the output. (My trace: 1024-byte prefix → 205 iterations of 5120-byte buffers; iter 204 had
available_in=4 and stashed those 4 bytes, iter 205 consumed them to 0 and broke — leaving the stale 4 bytes behind, with HasMoreOutput False.)
can_accept_more_data() now lies. It reports "not ready" purely because the stash is non-empty, even though the decoder actually finished the input and has no buffered output:
can_accept_more_data → False when _unconsumed_data is non-empty (
brotlicffi/src/brotlicffi/_api.py
Lines 596 to 597 in 3fd4d20
The next call blows up. Either path fails:
(
brotlicffi/src/brotlicffi/_api.py
Lines 443 to 447 in 3fd4d20