Skip to content

Bun.gzipSync/deflateSync/gunzipSync/inflateSync: throw instead of aborting when an output buffer cannot be allocated - #39043

Closed
robobun wants to merge 1 commit into
mainfrom
farm/3774c71d/zlib-sync-oom-throw
Closed

robobun wants to merge 1 commit into
mainfrom
farm/3774c71d/zlib-sync-oom-throw

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.gzipSync, Bun.deflateSync, Bun.gunzipSync and Bun.inflateSync abort the whole process when the output buffer they allocate cannot be obtained: memory allocation of 314572800 bytes failed, then panic(main thread): abort() called and a crash report (exit 134). Before the port to Rust these threw a JS out-of-memory error (try ...initCapacity(...) in the Zig version).
  • Every output reservation in these functions is infallible (Vec::with_capacity / reserve_exact / reserve), and all of them are sized by the caller's data:
    • src/runtime/api/BunObject.rs gunzip_or_inflate_sync: the gzip trailer's ISIZE field (any value below 256 MiB is honored, so 8 bytes of untrusted input pick the size), else the input length.
    • src/runtime/api/BunObject.rs gzip_or_deflate_sync: the input length, then libdeflate's compression bound.
    • src/zlib/lib.rs ZlibCompressorArrayList::init: deflateBound(input); ZlibReaderArrayList::read_all / ZlibCompressorArrayList::read_all: the growth step taken whenever zlib fills the buffer.
    • src/libdeflate_sys/libdeflate.rs decompress_to_vec_grow: the doubling step on InsufficientSpace.
  • Repro on the release build: ulimit -v 1048576, then Bun.gunzipSync / gzipSync / inflateSync of a 300 MiB buffer (with either library) aborts instead of throwing. For gunzipSync the requested size can also come from the trailer of a gzip a few hundred bytes long (anything up to 256 MiB), so whether untrusted gzip data takes the process down depends only on the host's headroom.

Fix

  • Each of those reservations goes through try_reserve / try_reserve_exact. In BunObject.rs a refusal becomes global.throw_out_of_memory() (the same RangeError: Out of memory JSC throws for its own failed allocations, and what the zstd async path and strings: don't abort on UTF-8 to UTF-16 output buffer allocation failure #33014 / TextEncoderStream: throw out of memory instead of aborting when a chunk's output buffer cannot be allocated #38941 already do for the same class of bug). The growth loops report the refusal to their caller instead: ZlibError::OutOfMemory from the zlib array lists (init errors already reach throw_error, which maps that variant to the OOM error; read_all errors now distinguish it from zlib data errors), and Result<_, AllocError> from decompress_to_vec_grow, whose only caller is this file.
  • The ISIZE reservation is only an estimate read from untrusted bytes, so when it is refused the decompressor falls back to the input-sized buffer and grows from there, as it already does for sizes above 256 MiB. The real output size then decides the outcome: a payload that is genuinely too large fails while growing, while a member followed by bytes that happen to claim a huge size still decodes. Only a refused fallback buffer throws.
  • The zlib compressor's input-sized initial buffer is gone: ZlibCompressorArrayList::init immediately re-reserves it to deflateBound, so that deflateBound reservation is now the single (fallible) allocation. The final buffer is unchanged since the result is shrunk to fit before it is handed to JSC.
  • Why this is the right shape: how large a buffer these calls may ask for depends on the host, not on any fixed limit, so the allocator has to be asked and its answer reported to the caller like every other per-call failure. Nothing changes while allocations succeed (try_reserve grows a Vec exactly like reserve does), and a refused allocation previously aborted, so no existing result is altered. The small fixed-size allocations (the boxed zlib stream, libdeflate's own state) keep the existing crash-on-OOM policy; this change is about the buffers whose size the caller's data controls.
  • Verified with test/js/bun/util/zstd.test.ts (new block gzip/deflate sync APIs throw when an output buffer cannot be allocated): one child under ASAN's per-allocation cap runs twelve cases, one per refused reservation (both libraries: the compression bounds, the input-sized decompression buffer, the zlib and libdeflate growth steps, a truthful ISIZE that falls back and then fails while growing, and a claimed size after a small member that falls back and decodes), checks each throws RangeError: Out of memory (or decodes, for the last pair) and that the process still round-trips afterwards. Without the fix the child aborts at the first case (exit 134); with it the test passes.
  • Also run: the rest of zstd.test.ts, test/js/node/zlib/zlib.test.js (round trips both libraries), test/js/web/fetch/fetch-gzip.test.ts, the gzip regression tests under test/regression/issue/, an ad-hoc script round-tripping 1008 combinations of sizes (0 to 3 MiB, including stored level 0 where the output exceeds the input), levels and library pairs on the debug build, and cargo clippy on bun_zlib, bun_libdeflate_sys and bun_runtime.
  • The zstd functions in the same file have the same bug; that is zstd: throw instead of aborting when the output buffer cannot be allocated #39038, which touches only the zstd code and a different part of the same test file.

Background

  • Vec::with_capacity, reserve and reserve_exact call Rust's allocation error handler when the allocator returns null, which prints memory allocation of N bytes failed and aborts; the try_ variants return Err instead and leave the Vec as it was. Both families grow a Vec by the same policy, so swapping them changes nothing when the allocation succeeds.
  • deflateBound (zlib) and libdeflate_*_compress_bound give the most output a compressor can produce for an input of a given size, slightly more than the input itself. Both backends here compress in one shot into a buffer of that size.
  • A gzip member ends with an 8-byte trailer: the CRC-32 of the uncompressed data and ISIZE, its length modulo 2^32. gunzipSync reads the last 4 bytes of whatever it is given as a guess for the output size. Both backends here decode one member and ignore whatever follows it (node also ignores trailing bytes that do not start another member, which is what the test appends), so those 4 bytes need not describe the data that gets decoded.
  • ZlibReaderArrayList / ZlibCompressorArrayList (src/zlib/lib.rs) drive zlib over a borrowed Vec<u8>: whenever zlib reports the buffer full (avail_out == 0) they grow the Vec and point zlib at the new spare capacity. reserve_expand_tail(0) is the existing helper for exposing already-reserved capacity to C. libdeflate has no streaming decode, so decompress_to_vec_grow instead retries from scratch with a buffer twice as large each time.
  • The test relies on ASAN's max_allocation_size_mb with allocator_may_return_null=1: under the debug/ASAN build every Rust allocation goes through ASAN's malloc, which refuses anything above the cap, while the Buffers the script creates are allocated by JSC's own allocator and are unaffected. That makes the failing allocation deterministic and cheap, so the block is describe.skipIf(!isASAN). The 12 MiB payloads are produced with node:zlib, which streams through chunk-sized buffers and so works under the cap.

…rting when an output buffer cannot be allocated

The sync zlib APIs size their output buffers from the caller's input
(deflateBound / libdeflate bound for compression, the gzip trailer's ISIZE
or the input length for decompression, then doubling growth). All of those
reservations were infallible, so an allocation the system refused aborted
the process with "memory allocation of N bytes failed" instead of throwing
the out-of-memory error the Zig implementation used to throw.

Reserve those buffers with try_reserve(_exact) and throw a JS OOM error when
a reservation fails. The zlib and libdeflate growth loops report the
failure to their caller (ZlibError::OutOfMemory / AllocError). The ISIZE
reservation is only a hint from untrusted data, so when it is refused the
decompressor falls back to the input-sized buffer and grows from there. The
zlib compressor's input-sized initial buffer is dropped since init replaces
it with the deflateBound one anyway.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f52cee86-3764-450c-acee-e116a4d4aa25

📥 Commits

Reviewing files that changed from the base of the PR and between 88a6398 and b6c32b2.

📒 Files selected for processing (4)
  • src/libdeflate_sys/libdeflate.rs
  • src/runtime/api/BunObject.rs
  • src/zlib/lib.rs
  • test/js/bun/util/zstd.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:04 PM PT - Aug 15th, 2026

❌ @robobun, your commit b6c32b2 has some failures in Build #98080 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 39043

That installs a local version of the PR into your bun-39043 executable, so you can run:

bun-39043 --bun

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed, ready for review.

  • Reproduced on the release build: ulimit -v 1048576 and Bun.gzipSync / gunzipSync / inflateSync of a 300 MiB buffer (either library) prints memory allocation of 314572800 bytes failed, panic(main thread): abort() called, exit 134.
  • Reproduced deterministically on the debug (ASAN) build with ASAN_OPTIONS=allocator_may_return_null=1:max_allocation_size_mb=8: each of the twelve cases in the new test aborts at a different reservation (16777216, 9437205, 16794014, 104857600 and 12527616 bytes, matching the input-sized buffer, deflateBound, the libdeflate bound, the claimed ISIZE and the growth step respectively).
  • Test: test/js/bun/util/zstd.test.ts, block gzip/deflate sync APIs throw when an output buffer cannot be allocated. Aborts (exit 134) without the fix, passes with it.
  • CI (build 98080): 177 of 179 jobs passed, including the ASAN lanes that run the new block. The only test failures are unrelated ones that passed on retry (spawn-maxbuf, child_process IPC, two install tests, metafile, inspect-error-leak, shell-pipe-read-fault, test-crypto-dh-leak, malformed-integrity-base64, an elysia stream test, two napi builds on Windows aarch64). The remaining job is darwin 14 aarch64 - test-bun, which expired without an agent and whose retry has been queued for hours; the new block is ASAN-only and would be skipped there anyway.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it reworks error-path cleanup across the zlib/libdeflate streaming loops and changes the ISIZE-hint fallback semantics in native code, a human look would still be worthwhile.

What was reviewed:

  • reserve_expand_tail(0) after try_reserve exposes the same spare capacity as the old reserve_expand_tail(N) call — checked vec_ext.rs:400, semantics are equivalent when the allocation succeeds.
  • decompress_to_vec_grow's only caller is BunObject.rs; the two other ZlibReaderArrayList::read_all callers (StandaloneModuleGraph.rs, create_command.rs) already treat any Err uniformly, so the new OutOfMemory variant is handled.
  • The new compressor OOM branch's end() + state = Error sequence matches the existing MemError arm; deflateEnd on a null state is a defined no-op, so the subsequent Drop is harmless.
  • ZlibCompressorArrayList::init still points next_out/avail_out at the reserved buffer after the caller's pre-allocation was removed.
Extended reasoning...

Overview

The PR converts every user-input-sized output-buffer reservation in Bun.gzipSync/deflateSync/gunzipSync/inflateSync from infallible (Vec::with_capacity/reserve/reserve_exact, which abort on allocator failure) to fallible (try_reserve/try_reserve_exact), surfacing refusals as JS RangeError: Out of memory instead of a process abort. It touches src/runtime/api/BunObject.rs (both sync entry points, both library backends), src/zlib/lib.rs (the growth step in both array-list drivers plus the deflateBound reservation in init), and src/libdeflate_sys/libdeflate.rs (the doubling loop in decompress_to_vec_grow, whose signature now returns Result<_, AllocError>). The gzip ISIZE hint reservation now falls back to the input-sized buffer when refused rather than throwing, which is a deliberate semantic choice. A new ASAN-gated subprocess test in zstd.test.ts exercises all twelve reservation sites deterministically via max_allocation_size_mb.

Security risks

This is itself a robustness/DoS fix: an untrusted gzip trailer could previously pick any allocation size up to 256 MiB and abort the process if the host lacked headroom. I verified the ISIZE fallback logic still bounds the up-front reservation and that the growth loops still respect max_output_size (the decompression-bomb guard) — the remaining_budget clamp in ZlibReaderArrayList::read_all is unchanged. No new user-controlled sizes reach an infallible allocation. No auth/crypto/permissions surface.

Level of scrutiny

High. This is native code adjacent to unsafe blocks (reserve_expand_tail, set_len) and adds new error paths that must correctly release zlib/libdeflate state. Per the repo's review guidance, native memory-safety error paths are the most-blocked category. I traced each new early return: the compressor's init OOM path drops zlib_reader (whose Drop runs deflateEnd); the read_all OOM branches mirror the existing MemError arms; the libdeflate OOM path in BunObject.rs drops list and lets decompressor's RAII Drop run on return. The try_reserve + reserve_expand_tail(0) split is behaviourally identical to the prior reserve_expand_tail(N) on the success path since try_reserve guarantees cap >= len + N and reserve_expand_tail(0) exposes cap - len.

Other factors

The signature change to decompress_to_vec_grow has exactly one caller (verified by grep). ZlibCompressorArrayList is only used by BunObject.rs, and the two other ZlibReaderArrayList callers already collapse any Err from read_all, so the additional OutOfMemory return site is transparent to them (and read_all could already return that variant from zlib's own Z_MEM_ERROR). The removed pre-allocation before ZlibCompressorArrayList::init is safe because init immediately reserves deflateBound and repoints next_out/avail_out afterward. The test follows repo conventions (spreads bunEnv, drains pipes concurrently, asserts a combined object, ASAN-gated with a stated reason). Given the number of new native error paths and the small ISIZE-fallback design decision, I'm deferring rather than shadow-approving so a maintainer can confirm the fallback semantics and the state/end() ordering match intent.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

On the two points left for a human:

  • ISIZE fallback: the value is the last 4 bytes of whatever was passed in, and both backends stop after the first member, so it does not necessarily describe the data that gets decoded (the test's unallocatable trailer size cases are an example that decodes fine). Treating a refused reservation as a reason to fail would turn those inputs into an OOM error, while a payload that really is that large still fails while growing, which is also what already happens today for sizes at or above 256 MiB. So the hint only ever affects the up-front reservation.
  • end() followed by state = Error in the compressor's new read_all branch is the same sequence as its existing MemError arm a few lines below; the reader's branch follows that struct's existing arms, which leave inflateEnd to Drop. Of the new zlib error paths, the test exercises the reader's growth branch (inflateSync zlib, growth) and the compressor's init branch (gzipSync zlib / deflateSync zlib), under ASAN, which would flag a double free or leak on either. The compressor's read_all growth branch is not reachable in practice, since init reserves deflateBound up front; it was changed for consistency only.

@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

#39417 now contains this change (the Bun.gzipSync / deflateSync / gunzipSync / inflateSync allocations are fallible there for both libraries, together with the other compressors and their fetch / CompressionStream consumers). If #39417 lands first, this PR is superseded; if this one lands first, #39417 rebases over it.

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this change landed on main as part of #39417 (8bc4d2a). The src/zlib/lib.rs and src/libdeflate_sys/libdeflate.rs hunks from this PR are on main as-is, and the gzipSync / deflateSync / gunzipSync / inflateSync paths in src/runtime/api/BunObject.rs now throw out of memory for the initial reservation, the growth steps, and the compression bound (for libdeflate the bound reservation moved into compress_to_vec, which returns an error the caller throws).

Verified by running this PR's test block unmodified against a debug (ASAN) build of main: all 12 cases pass, including the gzip trailer fallback cases.

@robobun robobun closed this Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant