Skip to content

Handle multi-frame zstd in async ZIP read paths#3771

Merged
jjallaire merged 7 commits into
UKGovernmentBEIS:mainfrom
METR:fix/zstd-multi-frame-async-decompress
Apr 28, 2026
Merged

Handle multi-frame zstd in async ZIP read paths#3771
jjallaire merged 7 commits into
UKGovernmentBEIS:mainfrom
METR:fix/zstd-multi-frame-async-decompress

Conversation

@rasmusfaber

@rasmusfaber rasmusfaber commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

This PR contains:

  • New features
  • Changes to dev-tools e.g. CI config / github tooling
  • Docs
  • Bug fixes
  • Code refactor

What is the current behavior? (You can also link to an open issue here)

#3746 made the writer cap each ZIP_ZSTANDARD zstd frame at 200 MiB of input, producing multi-frame zstd streams for large eval samples. The synchronous zipfile read path was patched (_MultiFrameZstdDecompressObj in src/inspect_ai/_util/zipfile.py), but the async read paths were not.

Two async classes still used a single zstandard.ZstdDecompressionObj and either crashed (ZstdError: cannot use a decompressobj multiple times) or silently dropped trailing frames on multi-frame input:

  1. compression.py::ZstdDecompressor - used by AsyncZipReader via CompressedToUncompressedStream. Production failure observed when read_log_sample hit a sample whose JSON exceeded 200 MiB:
    File "inspect_ai/_util/compression.py", line 50, in decompress_next
        decompressed = self._decompressor.decompress(chunk)
    ZstdError: cannot use a decompressobj multiple times
    
  2. compression_transcoding.py::_ZstdDecompressIterator - used by CompressedToDeflateStream to transcode zstd → deflate for browsers (which support Content-Encoding: deflate but not zstd). Same bug pattern.

What is the new behavior?

Both async classes now mirror the _MultiFrameZstdDecompressObj pattern that PR #3746 already established for the sync path: track a _pending byte buffer for unused_data carryover, and replace the inner decompressobj on every frame boundary.

Does this PR introduce a breaking change? (What changes might users need to make in their application due to this PR?)

No. Public API and behavior on single-frame zstd entries are unchanged.

rasmusfaber and others added 5 commits April 26, 2026 17:12
No behavior change. Moves moderately_compressible_payload and
read_raw_compressed_entry out of test_zipfile_multiframe.py so the
forthcoming async multi-frame tests can reuse them.
A single zstandard decompressobj only spans one frame; on eof it
rejects further input. The previous ZstdDecompressor crashed with
'cannot use a decompressobj multiple times' on the second chunk of
any multi-frame entry -- exactly the entries PR UKGovernmentBEIS#3746's writer
produces for samples whose JSON exceeds 200 MiB.

Mirror the _MultiFrameZstdDecompressObj pattern from _util/zipfile.py
inside ZstdDecompressor: track a _pending buffer for unused_data
carryover and replace the inner decompressobj on every frame boundary.

Adds an integration test through AsyncZipReader that monkey-patches
the per-frame cap to 64 KiB so the round-trip is fast (<1s).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Same fix as the previous commit, applied to _ZstdDecompressIterator
in compression_transcoding.py. This class is used by
CompressedToDeflateStream when the viewer requests a sample whose
on-disk encoding is zstd: we decompress zstd, recompress to deflate
for the browser. Without this fix, multi-frame entries crashed at
the second chunk on the same 'cannot use a decompressobj multiple
times' error.

Adds direct unit tests of the iterator with synthetic multi-frame
input. Chunk sizes deliberately don't align to frame boundaries so
the unused_data carryover is exercised.
Three small post-review cleanups, no behavior change for in-spec input:

1. compression_transcoding.py::_ZstdDecompressIterator: drop the
   dead `or self._obj is None` half of the entry guard. `_obj` was
   only ever nulled immediately followed by `_exhausted = True` on
   the same path, so the two conditions are equivalent — `_exhausted`
   alone is sufficient. Tighten the `_obj` annotation to non-Optional
   and drop the now-redundant `self._obj = None` from the
   StopAsyncIteration handler.

2. compression.py::ZstdDecompressor.decompress_next: add a symmetry
   guard `if self._exhausted: raise StopAsyncIteration` at the top.
   Previously, calling decompress_next again after exhaustion (with
   a fresh source iterator) would silently lazy-reinit and start
   decoding the new bytes — the `Decompressor` Protocol advertises
   exhaustion as a one-way door, but the implementation didn't
   enforce it. Currently dormant in production because
   CompressedToUncompressedStream gates on `exhausted` first; this
   closes the latent footgun.

3. tests/test_helpers/zstd.py: annotate `name_len: int` and
   `extra_len: int` on the struct.unpack results. struct.unpack
   returns `tuple[Any, ...]`, so without the annotations the Any
   was leaking into the subsequent f.read() call.
The earlier commits in this branch added more prose than the code
warrants. Pare back to one-line docstrings and drop comments that
restate what the code already says, including:

- The PR-reference paragraph in the integration test's docstring.
- The 4-line monkey-patch explanation (kept the WHY one-liner).
- The 4-line sanity-check rationale (the assertion message says it).
- "Inner frame complete; carry leftover bytes..." (variable names
  and class context already convey it).
- "Use a chunk size that does NOT align..." (chunk size is small/odd
  by inspection).
- The "Used by:" callers list in tests/test_helpers/zstd.py.
- Multi-paragraph class docstrings in ZstdDecompressor and
  _ZstdDecompressIterator.

Also restores the pre-existing "Note: Unlike zlib, ..." comment in
ZstdDecompressor to its original 3-line form (this branch had
extended it).
@rasmusfaber
rasmusfaber marked this pull request as ready for review April 27, 2026 09:35
@jjallaire
jjallaire merged commit 745b2ec into UKGovernmentBEIS:main Apr 28, 2026
15 checks passed
@jjallaire
jjallaire deleted the fix/zstd-multi-frame-async-decompress branch April 28, 2026 20:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants