Skip to content

Allocate stream block buffers only as block data arrives - #167

Merged
yawkat merged 1 commit into
mainfrom
fix/defer-block-allocation
Oct 22, 2026
Merged

yawkat merged 1 commit into
mainfrom
fix/defer-block-allocation

Conversation

@yawkat

@yawkat yawkat commented Oct 22, 2026

Copy link
Copy Markdown
Owner

LZ4BlockInputStream sized its output buffer from the originalLen and its compressed buffer from the compressedLen of a block header before reading any of the block data. A 21 byte header could therefore make it allocate about 64 MiB, which stays referenced while the underlying stream stalls (e.g. a peer that sends a header and then nothing). LZ4FrameInputStream did the same with two buffers of the frame's maximum block size (up to 8 MiB) for a block header of any size. The allocations are bounded by the maximum block size of the formats, but not by the amount of data actually received.

Changes

  • LZ4Utils.readFullyGrowing reads block data into a buffer that only grows as the data arrives, to at most twice the data read so far (at least min(len, 64 KiB)). Beyond the requested length, it grows geometrically up to the maximum block size, so that a sequence of slightly larger blocks (e.g. from flushing growing messages) does not reallocate every time.
  • The output buffer of a compressed block is only allocated after the compressed data was read, sized for the maximum LZ4 compression ratio of 255 (MAX_COMPRESSION_RATIO, moved from LZ4DecompressorWithLength to LZ4Constants). LZ4BlockInputStream now rejects block headers whose original length exceeds that ratio; valid LZ4 data cannot exceed it.
  • The output buffer growth of LZ4BlockInputStream is capped at the block's maximum size (it could previously reach 48 MiB).
  • A truncated block in LZ4FrameInputStream is still reported as a plain IOException("Stream ended prematurely"), not as an EOFException that callers may take for a clean end of data.
  • An Error such as OutOfMemoryError while reading a block now also makes both streams fail permanently, since the block was already partially consumed.
  • The acceptOversizedBlocks javadoc no longer warns about attacker-controlled allocations: oversized blocks can now only allocate what was actually read.

With this, the memory a stream holds is proportional to the data it received (at most 255× for highly compressible blocks, which is inherent to decompression). Measured with a header-only input: 67 MB → 64 KiB for the block stream, 8 MiB → 64 KiB for the frame stream.

Tests

  • Truncated blocks claiming the maximum block size allocate less than 1 MiB (both streams, compressed and uncompressed).
  • Header rejection at exactly 255:1 / just above.
  • Maximally compressible data (all zeros) at the maximum block size round-trips with the fast and HC compressors, to make sure the ratio check does not reject valid data.
  • Sequences of slowly growing blocks do not reallocate per block.
  • testBlockBuffersReusedAcrossFrames now uses blocks that need the full 4 MiB output buffer; with the smaller buffers it could no longer detect missing reuse.

🤖 Generated with Claude Code

LZ4BlockInputStream sized its output buffer from the originalLen and its
compressed buffer from the compressedLen of a block header before reading
any of the block data. A 21 byte header could therefore make it allocate
about 64 MiB, which stays referenced while the underlying stream stalls.
LZ4FrameInputStream did the same with two buffers of the frame's maximum
block size, up to 8 MiB, for a block header of any size.

Read block data into buffers that only grow as the data arrives, to at
most twice the data read so far, and grow them geometrically up to the
maximum block size, so that a sequence of slightly larger blocks does not
reallocate every time. Allocate the output buffer of a compressed block
only once the compressed data has been read, and size it for the maximum
LZ4 compression ratio of 255, which LZ4BlockInputStream now also checks
in the block header. The memory a peer can make a stream hold is now
proportional to the data it actually sent. The block stream's output
buffer growth is also capped at the maximum block size now.

A truncated block in LZ4FrameInputStream is still reported as a plain
IOException, not as an EOFException that callers may take for a clean end
of data. An Error such as OutOfMemoryError while reading a block now also
makes the stream fail, since the block was already partially consumed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yawkat
yawkat merged commit e6bec4c into main Oct 22, 2026
3 checks passed
@yawkat
yawkat deleted the fix/defer-block-allocation branch October 22, 2026 08:58
dongjoon-hyun added a commit to apache/spark that referenced this pull request Oct 28, 2026
### What changes were proposed in this pull request?

This PR aims to upgrade `at.yawk.lz4:lz4-java` to 1.12.1.

### Why are the changes needed?

To bring the security fix of `v1.12.1`, which was released as a security release for [GHSA-48mj-8f7c-7qfm](GHSA-48mj-8f7c-7qfm) (severity: low, availability impact only).

- https://github.com/yawkat/lz4-java/releases/tag/v1.12.1 (2026-10-22)
  - [LZ4BlockInputStream and LZ4FrameInputStream allocate block buffers from the block header before the block data arrives](GHSA-48mj-8f7c-7qfm): the streams now grow their buffers only as block data actually arrives (yawkat/lz4-java#167).
  - [Throw LZ4Exception for input shorter than the length prefix](yawkat/lz4-java#160)
  - [Bound the compressed length of oversized LZ4 blocks](yawkat/lz4-java#162)
  - [Use the buffer limit for the safe decompress(ByteBuffer, int, ByteBuffer, int)](yawkat/lz4-java#161)

Note that Apache Spark's `LZ4CompressionCodec` and `Statistics` read streams via `LZ4BlockInputStream`, which is one of the code paths fixed by `GHSA-48mj-8f7c-7qfm`.

**Full Changelog**: yawkat/lz4-java@v1.12.0...v1.12.1

### Does this PR introduce _any_ user-facing change?

No. There is no behavior change for valid LZ4 streams.

### How was this patch tested?

- Regenerated the dependency manifest with `dev/test-dependencies.sh --replace-manifest`; the only diff is the `lz4-java` line.
- `build/sbt core/compile`
- `build/sbt 'core/testOnly org.apache.spark.io.CompressionCodecSuite org.apache.spark.shuffle.sort.UnsafeShuffleWriterSuite org.apache.spark.FileSuite org.apache.spark.deploy.history.EventLogFileWritersSuite'` (87 tests passed)
- Pass the CIs.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Sonnet 5.5

Closes #59870 from dongjoon-hyun/SPARK-60628.

Authored-by: Dongjoon Hyun <dongjoon@apache.org>
Signed-off-by: Dongjoon Hyun <dongjoon@apache.org>
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.

1 participant