Throw instead of aborting when child output does not fit in a Buffer - #39564
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
💤 Files with no reviewable changes (1)
Included review availability: Your plan includes up to 5 reviews per rolling hour; 0 remain after this review. WalkthroughThe PR enforces the maximum JavaScript buffer size, propagates buffer and subprocess conversion errors, preserves subprocess cleanup, and forwards shell interpreter errors without wrapping them in ChangesBuffer and error propagation
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
StatusReady for review. CI is green on cff9d8c. Reproduced on the current release build (
With this branch all three throw or reject with The Tests: |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it touches memory-safety-critical native paths across several subsystems — the DECLARE_TOP_EXCEPTION_SCOPE → DECLARE_THROW_SCOPE swap and deallocator-before-throw in JSBuffer.cpp, the finalize-before-propagate reordering in spawn_maybe_sync, and the shell reject callback contract change between interpreter.rs and builtins/shell.ts — a human look would still be worthwhile.
What was reviewed:
JSBuffer__bufferFromPointerAndLengthAndDeinit/JSBuffer__fromMmap: the length guard runs beforecreateFromBytes, releases the caller's bytes (deallocator /munmap) exactly once, and the throw scope change is required forBun::throwError; the existingRETURN_IF_EXCEPTIONtail still applies.create_buffer_from_boxpassesusizestraight through with the sameMarkedArrayBuffer_deallocator, so theu32hop is gone and the too-large path frees the box via the C++ deallocator branch.spawn_maybe_sync:outputis fully computed beforefinalize, and the destructuredJSValues are GC-owned so they survive the subprocess teardown; the error path now runsfinalizewhere it previously leaked.- Shell:
reject_get_cachedhas a single call site andtake_errorunwraps theJSC::Exceptioncell so the JS callback receives the thrown value; exit-code rejection still routes throughresolve→ShellError.
Extended reasoning...
Overview
The PR replaces process-killing aborts/panics with a catchable ERR_BUFFER_TOO_LARGE RangeError when native bytes handed to the Buffer adoption path exceed kMaxLength (2^32). It touches:
src/jsc/bindings/JSBuffer.cpp: adds a length guard +throwBufferTooLargehelper to both adopting entry points, swapping a top scope for a throw scope in one of them, and factoring the mmap unmap into a lambda so it can run on the reject path.src/jsc/array_buffer.rs:to_js_buffer_from_fd/to_js_buffer_from_memfdnow returnJsResultand propagateErr(Thrown)instead ofOk(JSValue::ZERO); the mmap arm is wrapped infrom_js_host_call.src/runtime/api/bun/subprocess/{Readable,SubprocessPipeReader}.rs: switch toJSValue::create_buffer_from_box, which routes through the same C++ entry point without theu32::try_from(...).expectpanic.src/runtime/api/bun/js_bun_spawn_bindings.rs: builds stdout/stderr/resource-usage first, runsSubprocessT::finalizeunconditionally, then propagates any error — fixing a leak of the subprocess and the sibling stream's buffer on the throw path.src/runtime/shell/interpreter.rs+src/js/builtins/shell.ts: the interpreter now passes the unwrapped thrown value toreject, and the JSrejectcallback forwards it directly to the promise instead of trying to build aShellErrorfrom three args it never receives.- Three test files add subprocess-isolated coverage for the ffi, spawnSync, and shell paths at and just above the limit.
Security risks
None identified. The change turns a hard abort into a recoverable RangeError; the bytes are still released on the reject path (deallocator or munmap), so there is no new leak or use-after-free surface. No auth, crypto, or trust-boundary parsing is touched.
Level of scrutiny
High. This is native, memory-owning code at the JSC boundary: an exception-scope kind change, deallocator-before-throw ordering, a lambda whose capture must survive into a createSharedTask, a finalize/propagate reordering around a raw Box::from_raw, and a Rust↔JS-builtin callback contract change. Each is small but sits squarely in the categories the repo's review guide flags as most-blocked (paired acquisition/release, exception checks, error paths that must complete the operation). The PR description is unusually thorough and I found the changes internally consistent, but this is exactly the kind of diff a maintainer should sign off on.
Other factors
- Verified
create_buffer_from_box(JSValue.rs:608) handsusizelength +MarkedArrayBuffer_deallocatorstraight toJSBuffer__bufferFromPointerAndLengthAndDeinit, so the too-large branch there frees the box and the at-limit case (2^32) still passes. - Verified
reject_get_cachedhas exactly one call site, so the JSrejectarity change has no other producer to update. WTF::moveon the unmap lambda matches existing usage in the bindings (BunProcess.h,BunString.cpp).- Tests cover the boundary (exactly 2^32 succeeds, 2^32 + 1 throws) and are gated on POSIX + ≥16 GiB RAM for the heavy cases; the ffi test exercises the same C++ path with no memory cost on all platforms. The heavy tests carry 120 s per-test timeouts, which is long but justified given ~11 s debug-build runtime per case with 8 GiB peak RSS.
|
The automated review above reports no findings, so nothing changes because of it. One update is coming. #39558 now adds the same length check to |
JSBuffer__bufferFromPointerAndLengthAndDeinit and JSBuffer__fromMmap hand their bytes to ArrayBuffer::createFromBytes, which RELEASE_ASSERTs above MAX_ARRAY_BUFFER_SIZE. Both now release the bytes and throw the RangeError an allocation of that size throws. These two hunks are the same as in the open PR for Bun.file().arrayBuffer(), so the two merge in either order. The spawnSync output arms build the Buffer with create_buffer_from_box, so the length reaches that check instead of a u32 conversion that panicked at 2^32 bytes. spawnSync finalizes the subprocess when building the output throws. to_js_buffer_from_memfd returns Err when the C++ side throws. The shell rejects its promise with the thrown value: the interpreter takes the error value, and the JS reject callback, which expected an exit code and two buffers, forwards the value as is.
81ed59c to
a8bae27
Compare
|
Updated 10:29 AM PT - Aug 18th, 2026
✅ @robobun, your commit cff9d8c68bf9559e812ecae5c0e97d37a3a5014c passed in 🧪 To try this PR locally: bunx bun-pr 39564That installs a local version of the PR into your bun-39564 --bun |
There was a problem hiding this comment.
I re-reviewed after cff9d8c and found no new issues — the pre-existing has_exception() early return now runs finalize too, so the sibling leak is closed. Given this touches native ownership hand-off (deallocator on the throw path in JSBuffer.cpp, the TOP_EXCEPTION_SCOPE → THROW_SCOPE change), subprocess finalize/error-propagation ordering around Box::from_raw, and the shell's reject callback contract, a human look is still worthwhile.
What was reviewed:
rejectBytesNoCopyAboveArrayBufferLimitreleases via the caller's deallocator before throwing;JSBuffer__fromMmaproutes throughunmapBufferByteswith the length packed into the context so the reject path unmaps too.create_buffer_from_boxcallsJSBuffer__bufferFromPointerAndLengthAndDeinitwith ausizelength andMarkedArrayBuffer_deallocator, so the spawnSync/pipe paths no longer trip theu32cast and the throw path frees the box.spawn_maybe_syncnow builds(stdout, stderr, resource_usage)into a singleResult, runsfinalize, then?s — no borrow ofsubprocesssurvives pastBox::from_raw; both early returns after the wait loop finalize.interpreter.rsusestake_error(unwraps theJSC::Exceptioncell) andshell.tsrejectforwards the value straight torej; the sole caller passes exactly one arg.
Extended reasoning...
Overview
Eleven files across the C++ JSC bindings, Rust JSC glue, subprocess/shell error paths, and three test files. The core change adds a MAX_ARRAY_BUFFER_SIZE guard to the two adopting Buffer entry points in JSBuffer.cpp (mirrored from #39558), then plumbs the newly-reachable throw through every producer: spawnSync's output arms move to create_buffer_from_box (no u32 hop), spawn_maybe_sync defers the ? until after finalize, to_js_buffer_from_fd/from_memfd return JsResult instead of JSValue::ZERO with a pending exception, and the shell's interpreter/JS reject pair carries the thrown value to the promise instead of the Exception cell.
Security risks
None identified. The added guard rejects oversized adopted buffers (turning a process abort into a catchable RangeError) and releases the bytes on the reject path, so it strictly reduces the DoS surface. No auth/crypto/permissions code touched.
Level of scrutiny
High. This is native memory ownership across the Rust/C++ boundary: the new reject path calls the caller's deallocator on bytes the caller already gave up, JSBuffer__bufferFromPointerAndLengthAndDeinit changes from a top-exception scope to a throw scope, and spawn_maybe_sync reorders Box::from_raw-based finalization against fallible calls. The shell change alters the reject callback's contract (from (code, stdout, stderr) — which was never actually reachable — to (error)). None of this is mechanical.
Other factors
My earlier inline comment (the pre-existing has_exception() return leaking the boxed subprocess) was addressed in cff9d8c; the author's reply correctly scopes the fix to the post-wait state and explains why earlier returns need different teardown. The PR carries tests for all three user-visible entry points (ffi runs everywhere; spawnSync/shell gate on 16 GiB RAM + POSIX). The JSBuffer.cpp/.h hunks are byte-identical to #39558 by design. All comment-cop threads are resolved. Deferring rather than approving because the memory-safety reasoning here (deallocator on throw, finalize ordering, exception-scope change) warrants a maintainer's eyes.
|
Scope note after the merge (from the self-review of this PR), for whoever picks up the rest of this class:
|
… above 4 GiB (#37243) ### Problem - `await (await fetch(url)).arrayBuffer()` or `.bytes()` aborts the process when the body exceeds 2^32 bytes. A 4 MiB gzip response is enough. Debug builds print `ASSERTION FAILED: m_sizeInBytes <= (1ull << 32)` in `JSC::ArrayBufferContents::ArrayBufferContents`. `text()` rejects. - A `Response` string body with more than 2^32 bytes of utf8 aborts the same way. - Cause: `JSArrayBuffer__fromDefaultAllocator` and `JSUint8Array__fromDefaultAllocator` (`src/jsc/bindings/Uint8Array.cpp`) call `ArrayBuffer::createFromBytes`, which asserts above `MAX_ARRAY_BUFFER_SIZE`. ### Fix - Both functions first call `Bun::rejectBytesNoCopyAboveArrayBufferLimit`. It frees the bytes and throws the `RangeError: Out of memory` that `new ArrayBuffer(2 ** 32 + 1)` throws. #39558 and #39564 added it to the other four adopting functions. - `ArrayBuffer::from_default_allocator` wraps its `ArrayBuffer` arm in `call_zero_is_throw`, so callers get an `Err`. - Verified: `test/js/web/fetch/blob-oom.test.ts` (three new cases). Without the fix, every child dies with SIGABRT. The whole file passes. ### Background - JSC caps an `ArrayBuffer` at 2^32 bytes. Constructors that allocate throw above it. `createFromBytes` adopts bytes the caller owns, and asserts. - A natively buffered body reaches JSC through `Any::to_array_buffer_view` (`Blob.rs`) and `from_default_allocator`, without a copy. - Considered a check in each Rust caller, and a utf8 length pre-scan. The first misses the next caller. The second adds a pass over the input. ### Downsides - Per adopted buffer: +1 length comparison and +1 exception check. No allocation, no pass over the bytes. Text: +144 bytes before LTO (no `bloaty`, method in Notes). - A body above 4 GiB is still buffered in full before the rejection. - The two fetch cases cost about 15 s each under ASAN. The string case is release-only (7 s). <details><summary>Notes</summary> **Who reaches the two functions.** Every caller of `ArrayBuffer::from_default_allocator` and `JSUint8Array::from_bytes`: - `Any::to_array_buffer_view`, `InternalBlob` arm: a fetch response (read while it streams, or after it is buffered), a `Bun.serve` request body when `maxRequestBodySize` allows more than 4 GiB, a native stream read through `new Response(stream)` or `Bun.readableStreamToArrayBuffer` / `ToBytes`, HTMLRewriter output. - The same function, `WTFStringImpl` arm: a string body whose utf8 encoding is owned (not ASCII). - `TextEncoderStream` chunks. For fetch the path is `FetchTasklet::on_body_received` -> `Body::Value::resolve` -> `AnyBlob::to_array_buffer_transfer` / `to_uint8_array_transfer` -> `Any::to_array_buffer_view` -> `from_default_allocator`. **Repro for the fetch case** (about 7 GiB of free memory). On bun 1.4.3 and on main it exits 134. With this PR it prints the RangeError. `text()` rejects with `ERR_STRING_TOO_LONG` and `blob()` resolves with 4362076160 bytes, before and after. ```js const member = Bun.gzipSync(Buffer.alloc(64 * 1024 * 1024)); const body = Buffer.concat(Array(65).fill(member)); // 4 MiB, inflates to 2^32 + 64 MiB const server = Bun.serve({ port: 0, fetch: () => new Response(body, { headers: { "Content-Encoding": "gzip" } }) }); await (await fetch(server.url)).arrayBuffer(); ``` **Repro for the string case.** `await new Response("\u20ac".repeat(1431655766)).arrayBuffer()` (3 utf8 bytes per character, 2^32 + 2 bytes). It aborts on main and on 1.4.3, and rejects with the RangeError here, for `arrayBuffer()` and `bytes()`. It is a test, for release builds only: one call takes 28 s in a debug ASAN build (65 s for the case) and about 2 s in a release build (7 s for the case). **The tests.** One fetch case per guarded function, at exactly 2^32 + 1 bytes. The body is 64 gzip members of 64 MiB of zeros and one member of a single byte, so the child builds it in 0.3 s (`Bun.gzipSync` once per member size) where a streamed level 9 gzip of 4 GiB takes 70 s in a debug build. If fetch ever stops at the first gzip member, the call resolves and the test fails on `unexpectedByteLength`. Measured here under ASAN: 15 s to 18 s per case, 6.6 GiB to 8.8 GiB of peak RSS, so the cases stay out of the concurrent batch and use the same 10 GiB gate as the 4 GiB file cases. In a release build a case takes about 11 s and 4.5 GiB to 6.1 GiB. **The boundary.** The comparison is in `rejectBytesNoCopyAboveArrayBufferLimit`, and the two functions only call it. The file cases already pin both sides of that comparison (exactly 2^32 bytes is returned whole, 2^32 + 1 rejects). Through fetch, by hand with this build: a body of exactly 2^32 bytes resolves from `arrayBuffer()` and from `bytes()` with `byteLength` 4294967296, and 2^32 + 1 rejects from both. The at-limit side is not a test here. Each case is one more 4 GiB transfer (11 s in release, 16 s under ASAN, up to 9 GiB of RSS) on a shared agent. **Size number.** `ninja -C build/release obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o` with this PR's and main's `Uint8Array.cpp`, each lowered with `clang++ -O3 -c -x ir`: text 162942 against 162798. The two functions grow from 387 to 480 bytes and from 442 to 488 bytes. **The other callers of `createFromBytes` / `createAdopted`.** - Guarded by the helper already: `Bun__makeArrayBufferWithBytesNoCopy`, `Bun__makeTypedArrayWithBytesNoCopy`, `JSBuffer__bufferFromPointerAndLengthAndDeinit`, `JSBuffer__fromMmap`. `ArrayBuffer__fromSharedMemfd` has its own length check. - Cannot reach the cap: `BunObject.cpp` (heap snapshot as `arraybuffer`) and `SerializedScriptValue::toArrayBuffer` pass a `WTF::Vector<uint8_t>`, whose capacity is 31 bits. `DirectByteBuffer::tryGrowTo` refuses to grow above `MAX_ARRAY_BUFFER_SIZE`. `JSX509Certificate.cpp` passes the DER bytes of one certificate. - Still unguarded, not in this PR: `napi.cpp` (external arraybuffer and buffer, the length comes from the addon) and `NodeSqlite.cpp` (`adoptSqliteBuffer`, a `serialize()` above 4 GiB). The helper frees the bytes before it throws, which is wrong for napi, where the caller keeps ownership on failure. Those two need their own error contract and are tracked separately. **Earlier shapes of this PR.** The first version checked the size at three Rust and C++ call sites, before the shared helper existed. A later one also computed the utf8 length of a string ahead of encoding, which was removed at review because it is an extra pass over the input. Rebases over #39558, #39564, #40410 and #40962 moved the Rust plumbing and the test helpers to main, so the PR is now the guard in `Uint8Array.cpp`, one wrapped arm in `array_buffer.rs`, and the tests. The last rebase conflict was in `blob-oom.test.ts` only (#40962 rewrote its helpers). The new cases use those helpers. </details> <!-- robobun:evidence:begin --> --- **no test proof** · iteration 5 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/blob-oom.test.ts <!-- robobun:evidence:end -->
Problem
await Bun.$`head -c 4294967297 /dev/zero`.quiet()dies withpanic(main thread): abort() called(SIGABRT).Bun.spawnSync({ cmd: ["head", "-c", "4294967297", "/dev/zero"] })dies withpanic: int cast: TryFromIntError(PosOverflow). So does an output of exactly 2^32 bytes, which is a valid Buffer length.bun:ffitoBuffer(ptr, 0, 2 ** 32 + 1)reaches the same abort with no memory at all.JSBuffer__bufferFromPointerAndLengthAndDeinit(src/jsc/bindings/JSBuffer.cpp:383on main) andJSBuffer__fromMmap(JSBuffer.cpp:2588) hand their bytes toArrayBuffer::createFromByteswithout a length check. JSC RELEASE_ASSERTs there aboveMAX_ARRAY_BUFFER_SIZE(vendor/WebKit/Source/JavaScriptCore/runtime/ArrayBuffer.cpp:150).subprocess/Readable.rs:294,SubprocessPipeReader.rs:333) went throughArrayBuffer::from_owned_bytes/from_bytes, which convert the length throughu32withexpect. That panic fires at 2^32 and above, before the bytes reach JSC.spawn_maybe_sync(js_bun_spawn_bindings.rs:2041) returns withoutfinalize, which leaks the subprocess. The shell (interpreter.rs:1227, added in One termination signal, one fold: Err(Thrown) always means an exception is pending, and each event-loop dispatcher takes it in one place #37275) rejects with theJSC::Exceptioncell instead of the thrown value, and the JSrejectcallback inbuiltins/shell.tsexpects(code, stdout, stderr), so it throws a TypeError and the promise never settles. In a debug build the child dies on an assertion inJSCell::toStringSlowCase.Fix
JSBuffer.cpprelease the bytes (the deallocator, ormunmap) and throwRangeError: Out of memory, the errornew ArrayBuffer(2 ** 32 + 1)throws, for a length aboveMAX_ARRAY_BUFFER_SIZE. TheJSBuffer.cppandJSBuffer.hhunks are byte for byte the ones in Bun.file().arrayBuffer(): stop panicking at 4 GiB, throw above the ArrayBuffer limit #39558 (same blobs), which adds the check to the ArrayBuffer and typed array entry points at the same time. The two PRs merge in either order. The rest of this PR is what that check makes reachable.Bun.$,bun:ffi,bun:sqliteserialize, zlib,Bun.Archive,Bun.Image. A length of exactly 2^32 still passes. JSC accepts it, andtoBuffer(ptr, 0, 2 ** 32)already works today.length == 0branch of the same function already does this. The repro's RSS drops to about 340 MiB after the catch.JSValue::create_buffer_from_box. It makes the same C++ call as before with the same deallocator, without theu32hop. Theu32conversions themselves stay as they are: Bun.file().arrayBuffer(): stop panicking at 4 GiB, throw above the ArrayBuffer limit #39558 removes them for theBun.file()path, and this PR does not depend on that.spawn_maybe_syncbuilds stdout, stderr and the resource usage object first, runsfinalize, and propagates an error after that. The pre-existing return for an exception that is already pending after the wait (a termination) runsfinalizetoo.finalizereleases the other stream's buffer and the abort signal reference, and it does not run JS, so a pending exception does not affect it.to_js_buffer_from_memfdandto_js_buffer_from_fdreturnErrwhen they throw. Before, they returnedOk(JSValue::ZERO)with the exception pending, and spawnSync would have stored that empty value in the result object. Nothing reaches this path from JS since spawnSync: drain piped stdio to EOF after the direct child exits #33832 (spawnSync no longer uses a memfd for stdout), so it has no test of its own.take_error, which unwraps theJSC::Exception, and the JSrejectcallback forwards the value to the promise.rejecthas exactly one caller, this path. Exit codes still go throughresolve, which builds theShellError.test/js/bun/ffi/ffi.test.js("toBuffer at the Buffer length limit"): on main the child aborts, with the fix it throws a RangeError and a view of exactly 2^32 bytes is still created. This case costs no memory and runs on every platform. It only checks the error class, so it holds with ffi: toArrayBuffer/toBuffer throw RangeError instead of aborting on a huge byteLength #33353 too.test/js/bun/spawn/spawnSync.test.ts("spawnSync output at the Buffer length limit"): 2^32 + 1 bytes throw the RangeError, 2^32 bytes come back as a Buffer. On main both cases die with theint castpanic. About 11 s per case in a debug build, 8.3 GiB peak RSS in the child, so the block skips below 16 GiB of RAM and is POSIX only (head -c). The glibc CI test machines have 8 GB and skip it, the ASAN machines have 64 GB and run it.test/js/bun/shell/shelloutput.test.ts("stdout at the Buffer length limit"): the promise rejects with the RangeError. On main the child aborts. With the entry point check alone the child dies on thetoStringSlowCaseassertion. Same size gate.bun bd testpasses onspawnSync.test.ts,spawn.test.ts,spawn-maxbuf.test.ts,spawnsync-isolated-event-loop.test.ts,spawnsync-no-microtask-drain.test.ts,ffi.test.js,bunshell.test.ts,shelloutput.test.ts,throw.test.tsandtest/internal/source-lints. The repros are clean underBUN_JSC_validateExceptionChecks=1.cargo checkforx86_64-pc-windows-msvcand clippy are clean.bun:ffiin front of the entry point), Reject instead of aborting when arrayBuffer() or bytes() reads a body above 4 GiB #37243 (a check inBuffer.from(string)in front of it). They apply on top of this change.Background
A Buffer is a Uint8Array over a JSC
ArrayBuffer. JSC stores the byte length assize_tbut caps it atMAX_ARRAY_BUFFER_SIZE(2^32 on 64-bit,PageCount.h). Bun exposes the cap asrequire("buffer").kMaxLength. JSC's allocating constructors return null above the cap, and the callers turn that intoRangeError: Out of memory. The adopting constructor,createFromBytes, takes bytes that already exist and asserts instead, so the check has to happen before the hand-off.Adopting means JSC takes ownership of a byte range that native code allocated, and frees it through a deallocator passed along with the bytes when the object is collected. spawnSync and the shell use this to return their captured output without a copy.
bun:ffitoBufferuses it with a deallocator that frees nothing, which is why it can describe any length without owning that much memory.In Rust,
JsResultisResult<JSValue, JsError>, andErr(Thrown)means an exception is pending on the VM.from_js_host_callturns a C++ call that returns an empty value with an exception pending into thatErr.take_exceptionremoves the pending exception and returns JSC'sExceptioncell, the wrapper that carries the thrown value and its stack.take_errorreturns the thrown value itself, which is what a JS callback has to receive. The promise helpers unwrap the cell themselves, which is why the othertake_exceptioncallers are fine.Earlier version of this PR
The first push threw
RangeErrorwith codeERR_BUFFER_TOO_LARGEfrom the two entry points, with its own helper. #39558 added its check to the same two functions in the meantime, withRangeError: Out of memoryfor all four entry points. This PR now carries that PR's hunks unchanged instead, and the tests expect that error.