Skip to content

Make ArrayBuffer::from_bytes unsafe and add owning constructors - #31174

Open
Jarred-Sumner wants to merge 3 commits into
mainfrom
claude/s0-2-arraybuffer-from-bytes
Open

Make ArrayBuffer::from_bytes unsafe and add owning constructors#31174
Jarred-Sumner wants to merge 3 commits into
mainfrom
claude/s0-2-arraybuffer-from-bytes

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

What

ArrayBuffer::from_bytes(bytes: &mut [u8], ..) (src/jsc/array_buffer.rs) stores bytes.as_mut_ptr() in a lifetime-less struct that becomes a JS-visible ArrayBuffer. The signature borrows, but the semantics transfer ownership to JSC — safe code could pass a stack or short-lived buffer and produce a dangling JS object. MarkedArrayBuffer::from_bytes had the identical problem (and additionally mi_frees the pointer in destroy()).

This PR makes the borrowing constructor unsafe fn with a # Safety contract and migrates every caller that actually owns its buffer to an ownership-taking constructor, so the transfer is visible in the type system.

Sites converted to safe owning constructors

  • ArrayBuffer::from_owned_vec(Vec<u8>) (new, next to the existing from_owned_bytes(Box<[u8]>)): adopts a Vec's allocation without reallocating — the deallocator installed by to_js* frees the whole block from its data pointer, so the capacity is discarded rather than shrunk. Used by:
    • streams.rs Start::to_js / StreamResult::to_js (owned chunk delivery, was ManuallyDrop + slice_mut())
    • ReadableStream::drain_from_js
    • TextEncoder__encode8/16 heap paths (was Vec::leak())
    • JSZlib gzip/gunzip/deflate/inflate ×4 (was Vec::leak())
    • node.rs to_array_buffer / MaybeToJs for Vec<u8> (was Vec::leak())
  • MarkedArrayBuffer::from_bytes(&mut [u8])from_owned_bytes(Box<[u8]>): callers in Terminal.rs, SubprocessPipeReader.rs, shell/subproc.rs, node_fs.rs (×3), and MarkedArrayBuffer::from_string no longer Box::leak/into_raw + reborrow by hand.
  • Blob Lifetime::Temporary: reclaims the leaked Box<[u8]> with heap::take and hands it to from_owned_bytes.
  • ArrayBufferSink::end_from_js done-path: empty boxed slice instead of &mut [].

Sites left unsafe (cannot transfer ownership)

These pass memory that is not a default-allocator Box/Vec and is released by a custom deallocator; they now call unsafe { ArrayBuffer::from_bytes(..) } with a // SAFETY: comment naming the owner:

  • FFIObject::to_array_buffer — caller-owned FFI memory with an optional user finalizer.
  • Blob Lifetime::Share / Lifetime::Transfer — store-owned bytes released by blob_store_array_buffer_deallocator.
  • Image.rs Deliver::Uint8Array — codec-owned allocation released by the codec's free.

Cost

Zero. from_owned_vec keeps the exact pointer/length the old code passed (no into_boxed_slice/shrink_to_fit), and from_owned_bytes callers already had a Box<[u8]> in hand. The generated FFI calls and installed deallocators are identical before and after.

Unsafe count

rg -o -e 'unsafe \{' -e 'unsafe fn' ... over the touched files: 624 → 623. (from_bytes itself becomes unsafe fn (+1) and the two callers that keep it gain an unsafe {} block (+2), offset by the removed reborrow blocks in MarkedArrayBuffer::from_string and node_fs.rs (−4).) The point of the change is not the count but that the remaining unsafe is at sites whose preconditions genuinely live outside Rust.

Scope note

The brief scoped this to array_buffer.rs plus callers of ArrayBuffer::from_bytes. MarkedArrayBuffer::from_bytes is one of those callers and is itself the same bug, so its own callers (Terminal.rs, SubprocessPipeReader.rs, shell/subproc.rs, node_fs.rs) had to change as part of the same ripple.

Tests

  • New: text-encoder.test.js round-trips >2048-byte latin1 and UTF-16 encode results across a forced GC (heap path → from_owned_vec).
  • New: streams.test.js streams a 512 KB file, forces GC, and verifies the collected chunks round-trip (owned chunk delivery → from_owned_vec).
  • Existing suites run locally against the debug build: text-encoder.test.js (24 pass), streams.test.js (70 tests, 3 only fail under the 5 s default timeout on the ASAN debug build and pass with a longer timeout), arraybuffersink.test.ts (6 pass), zlib.test.js (374 pass; the 2 failures are the brotli/zstd streaming timing tests, which don't touch this code and exceed their 15 s timeout under ASAN), blob.test.ts (16 pass).
  • The JSC crate calls C++ at runtime, so Miri cannot cover it.

ArrayBuffer::from_bytes(&mut [u8]) stored the borrowed slice's pointer in a
lifetime-less struct that becomes a JS-visible ArrayBuffer, so safe code
could hand JSC a dangling pointer. Mark it unsafe fn with a # Safety
contract and migrate every caller that actually owns its buffer to an
ownership-taking constructor:

- ArrayBuffer::from_owned_vec(Vec<u8>): new safe constructor that adopts a
  Vec's allocation without reallocating (the installed deallocator frees
  from the data pointer, so capacity is not needed).
- MarkedArrayBuffer::from_bytes(&mut [u8]) -> from_owned_bytes(Box<[u8]>):
  same fix for the owning wrapper; callers no longer Box::leak by hand.

Callers that genuinely cannot transfer ownership (FFI.toArrayBuffer over
caller-owned memory, Blob share/transfer over store-owned bytes, image
codec output with a custom free) keep an explicit unsafe call with a
SAFETY comment.

No behavior change; the pointers handed to JSC and the installed
deallocators are identical before and after.
@robobun

robobun commented May 21, 2026

Copy link
Copy Markdown
Collaborator
Updated 3:51 AM PT - May 21st, 2026

@Jarred-Sumner, your commit dc39a8f has 2 failures in Build #56596 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31174

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

bun-31174 --bun

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Rate limit exceeded

@Jarred-Sumner has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 2 minutes and 27 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 870524cd-7807-4241-9b38-87675062df33

📥 Commits

Reviewing files that changed from the base of the PR and between 90aa7d0 and dc39a8f.

📒 Files selected for processing (1)
  • test/js/web/encoding/text-encoder.test.js

Walkthrough

Adds owned-allocation constructors for ArrayBuffer/MarkedArrayBuffer and replaces leak/manual-pointer patterns with ownership-transfer APIs across compression, I/O, file-system, text-encoding, blob/streaming, and FFI code; includes tests verifying large buffers survive explicit GC.

ArrayBuffer Owned-Allocation Refactoring

Layer / File(s) Summary
Core API: owned-allocation constructors and documentation
src/jsc/array_buffer.rs
New ArrayBuffer::from_owned_vec(Vec<u8>, JSType) and MarkedArrayBuffer::from_owned_bytes(Box<[u8]>, JSType) plus strengthened from_bytes docs; from_string updated to use owned constructor; destroy() docs updated.
Compression/decompression clients
src/runtime/api/BunObject.rs
Zlib and libdeflate branches replace list.leak() + from_bytes with from_owned_vec, passing array_buffer.ptr into to_js_with_context and preserving deallocator finalizers.
I/O and streaming clients
src/runtime/api/bun/Terminal.rs, src/runtime/api/bun/subprocess/SubprocessPipeReader.rs, src/runtime/shell/subproc.rs, src/runtime/webcore/ReadableStream.rs, src/runtime/webcore/streams.rs
Terminal, subprocess pipes, shell readers, and stream drains adopt from_owned_bytes/from_owned_vec to transfer ownership of read/drained buffers to JSC, replacing leaks and ManuallyDrop patterns.
File system operations
src/runtime/node/node_fs.rs, src/runtime/node.rs
Embedded files, pre-fstat, and post-read-loop paths in read_file_with_options and Vec conversions now use from_owned_bytes/from_owned_vec instead of raw-pointer/unsafe heap conversions.
Text encoding large-input paths
src/runtime/webcore/TextEncoder.rs
Large-input Latin-1 and UTF-16 → UTF-8 branches use from_owned_vec instead of leak() for heap-allocated outputs.
Complex lifetime and blob handling
src/runtime/webcore/Blob.rs, src/runtime/webcore/ArrayBufferSink.rs
Blob Share/Transfer lifetime unsafe scoping adjusted; Temporary lifetime reclaims ownership and uses from_owned_bytes; ArrayBufferSink uses from_owned_bytes for empty buffers.
Safety refinements and cleanup
src/runtime/ffi/FFIObject.rs, src/runtime/image/Image.rs, src/runtime/webcore/ReadableStream.rs
FFI to_array_buffer wrapped in explicit unsafe with ownership comments; Image path comments clarified; unused import removed.
GC survival and round-trip tests
test/js/web/encoding/text-encoder.test.js, test/js/web/streams/streams.test.js
New tests validate large TextEncoder output and stream chunk buffers survive explicit garbage collection and round-trip decoding/concatenation.

Suggested reviewers

  • RiskyMH
  • dylan-conway
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: making ArrayBuffer::from_bytes unsafe and adding owning constructors (from_owned_vec, from_owned_bytes).
Description check ✅ Passed The description comprehensively addresses both required template sections: 'What does this PR do?' is thoroughly covered with detailed rationale, implementation sites, and safety considerations; 'How did you verify your code works?' is addressed with specific test cases and local verification results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/encoding/text-encoder.test.js`:
- Around line 491-497: Replace the use of String.prototype.repeat for the test
input strings: instead of building latin1 with "héllo wörld å ".repeat(200) and
utf16 with "❤️ Red Heart ✨ Sparkles 🔥 Fire ".repeat(100), construct equivalent
repetitive strings using Buffer.alloc(count, fill).toString() so the tests that
reference variables latin1 and utf16 (used with encoder.encode, decoder.decode,
and getByteLength) follow the repo guideline and avoid debug-build performance
issues; ensure the fill argument matches the original substring and preserve the
same repetition counts (200 and 100).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6bb9b651-6fa3-489e-8d47-52928de7cd07

📥 Commits

Reviewing files that changed from the base of the PR and between 0b20408 and 22d746b.

📒 Files selected for processing (16)
  • src/jsc/array_buffer.rs
  • src/runtime/api/BunObject.rs
  • src/runtime/api/bun/Terminal.rs
  • src/runtime/api/bun/subprocess/SubprocessPipeReader.rs
  • src/runtime/ffi/FFIObject.rs
  • src/runtime/image/Image.rs
  • src/runtime/node.rs
  • src/runtime/node/node_fs.rs
  • src/runtime/shell/subproc.rs
  • src/runtime/webcore/ArrayBufferSink.rs
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/ReadableStream.rs
  • src/runtime/webcore/TextEncoder.rs
  • src/runtime/webcore/streams.rs
  • test/js/web/encoding/text-encoder.test.js
  • test/js/web/streams/streams.test.js

Comment thread test/js/web/encoding/text-encoder.test.js Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Bun 1.3.14 segfault in Response.body / ReadableStream finalizer on Linux x64 #31159 - Segfault in Response.body / ReadableStream finalizer; PR fixes memory ownership in ReadableStream/streams code paths that could cause use-after-free in finalizers
  2. Recurring segfault after POST to a Hono route mounting @hono/mcp StreamableHTTPTransport (Bun 1.3.13 and 1.3.14) #31004 - Recurring segfault after POST to streaming Hono route; PR fixes buffer ownership in HTTP server response/stream path where GC could collect dangling ArrayBuffers
  3. Crash on route handler returning SSE stream. #17947 - Crash returning SSE stream during structuredCloneForStream; PR fixes buffer ownership in ReadableStream that could cause GC to collect buffers mid-clone

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #31159
Fixes #31004
Fixes #17947

🤖 Generated with Claude Code

@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.

No bugs found, but this refactor touches memory-ownership semantics at the JSC FFI boundary across 14 source files (Blob lifetimes, streams, fs, subprocess, zlib, FFI), so it warrants a human pass — also note the CI unnecessary unsafe block failure at src/bun_core/lib.rs:3352 which may be a missed call site.

Extended reasoning...

Overview

This PR hardens ArrayBuffer::from_bytes by marking it unsafe (it stores a raw pointer with no lifetime into a JS-visible object) and introduces ArrayBuffer::from_owned_vec / MarkedArrayBuffer::from_owned_bytes as safe, ownership-taking alternatives. It then migrates ~16 call sites across src/jsc/array_buffer.rs, zlib (BunObject.rs), Terminal.rs, SubprocessPipeReader.rs, FFIObject.rs, Image.rs, node.rs, node_fs.rs, shell/subproc.rs, ArrayBufferSink.rs, Blob.rs, ReadableStream.rs, TextEncoder.rs, and streams.rs, plus two new GC round-trip tests.

Security risks

The change sits squarely on the Rust↔JSC ownership boundary where mistakes manifest as use-after-free or double-free. The new from_owned_vec discards Vec capacity and relies on the mimalloc-backed deallocator freeing the whole block from its data pointer — a correct but subtle invariant. The Blob Lifetime::Temporary arm now reclaims a leaked Box<[u8]> via heap::take before handing it to from_owned_bytes, which changes the ownership-transfer shape. These look correct on read-through, but they are exactly the class of change that benefits from a second pair of eyes.

Level of scrutiny

High. This is core runtime memory management touching GC finalizers, FFI deallocators, and buffer hand-off across a dozen subsystems. While each individual call-site edit is mostly mechanical (replace leak() + from_bytes with from_owned_vec/from_owned_bytes), the aggregate surface area and the criticality of getting allocator/deallocator pairing right put this well outside the auto-approve envelope.

Other factors

  • CI (Build #56592) reports unnecessary unsafe block at src/bun_core/lib.rs:3352 on four targets — that file is not in the diff, which suggests either a missed from_bytes caller that now needs adjustment, or pre-existing noise. Worth confirming before merge.
  • The bug-hunting system found no issues, and the PR description is thorough with a clear cost/unsafe-count accounting.
  • New tests exercise the heap-path GC round-trip for TextEncoder and file streams; existing suites pass locally per the description.
  • The one CodeRabbit nit (test string construction) was already addressed in dc39a8f.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

CI notes — two jobs needed retries, both for failures unrelated to this change:

alii pushed a commit that referenced this pull request Jun 9, 2026
Fixes #31970

### Problem

`make_array_buffer_with_bytes_no_copy` and
`make_typed_array_with_bytes_no_copy` in `src/jsc/array_buffer.rs` were
safe `pub fn`s accepting an arbitrary raw pointer, length, deallocator
fn pointer, and deallocator context. They forward those values to JSC,
which adopts the memory as the backing store of a JS-visible
ArrayBuffer/TypedArray: every JS read/write dereferences the pointer,
and GC later invokes `deallocator(ptr, ctx)`.

Safe Rust could therefore mint a JS object backed by a dangling pointer,
and get an arbitrary fn-pointer/context pair invoked at GC time, without
writing `unsafe`:

```rust
let backing = core::ptr::NonNull::<c_void>::dangling().as_ptr();
let _ = make_array_buffer_with_bytes_no_copy(global, backing, 1, None, ptr::null_mut());
```

The FFI imports were declared `safe fn` with a comment deferring the
validity obligation to "the wrapper layer", but the wrappers were
themselves safe and public, so the obligation was never discharged at an
`unsafe` boundary.

### Fix

- Mark both free functions `pub unsafe fn` with a documented `# Safety`
contract (`ptr` valid for reads and writes of `len` bytes until the
deallocator runs; deallocator/ctx a pair sound to invoke exactly once on
the JS thread at GC; `ptr` null only when `len == 0`).
- Mark `ArrayBuffer::to_js_with_context` `pub unsafe fn` as well. It
forwards a caller-supplied deallocator/ctx pair straight to the
functions above (same class), and clippy's deny-level
`not_unsafe_ptr_arg_deref` flags it once the callees are unsafe. `to_js`
and `to_js_unchecked` take no raw arguments and stay safe; the soundness
of the `ArrayBuffer` descriptor struct itself (`from_bytes` accepting
arbitrary `&mut [u8]`) is tracked separately per the issue and is being
fixed in #31174.
- Drop the `safe` marker from the `Bun__makeArrayBufferWithBytesNoCopy`
/ `Bun__makeTypedArrayWithBytesNoCopy` externs so the obligation lives
in the wrappers' contracts instead of being asserted away in the extern
block.
- Add `unsafe {}` blocks with per-site SAFETY justifications at every
caller: `ArrayBuffer::to_js_unchecked` / `to_js` / `to_js_with_context`,
`MarkedArrayBuffer::to_js`, `Bun.mmapFile` (BunObject.rs), the
zlib/libdeflate sync paths (BunObject.rs), Blob `Share`/`Transfer`
lifetimes (Blob.rs), `bun:ffi` `toArrayBuffer` (FFIObject.rs), and the
image codec delivery path (Image.rs). All in-tree callers already passed
valid pointers; this change is API contract only, no behavior change.

### Test

No test file: the change is contract-only with zero behavior delta, and
the enforcement is the compiler itself. Reverting any signature to safe
trips `unused_unsafe` (denied workspace-wide) at every call site, and
clippy's deny-level `not_unsafe_ptr_arg_deref` re-fires on
`to_js_with_context`. A source-lint test under `test/internal/` was
originally included and removed at maintainer request.

### Verification

- `cargo clippy -p bun_jsc -p bun_runtime` clean (was failing on
`to_js_with_context` mid-fix, which is what pulled it into scope)
- `bun run rust:check-all`: 10 ok, 0 failed
- `bun bd test` on suites covering every touched call site:
`test/js/bun/util/mmap.test.js` (8 pass),
`test/regression/issue/18413-all-compressions.test.ts` + `17793.test.ts`
(zlib paths), `test/js/web/fetch/blob.test.ts` + `blob-cow.test.ts` (27
pass), `test/js/bun/ffi/ffi.test.js`, `test/js/bun/image/image.test.ts`
(93 pass)
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