Skip to content

jsc: replace MarkedArrayBuffer::from_bytes with an owning constructor#32031

Open
alii wants to merge 7 commits into
mainfrom
ali/marked-array-buffer-owning-ctor
Open

jsc: replace MarkedArrayBuffer::from_bytes with an owning constructor#32031
alii wants to merge 7 commits into
mainfrom
ali/marked-array-buffer-owning-ctor

Conversation

@alii

@alii alii commented Jun 9, 2026

Copy link
Copy Markdown
Member

What

MarkedArrayBuffer::from_bytes(&mut [u8], JSType) (src/jsc/array_buffer.rs) was a safe, public constructor that stored a borrowed slice's pointer in a lifetime-less struct with owns_buffer: true. The safe destroy() then freed that pointer with the default allocator, so safe code could free a stack buffer:

let mut bytes = [0u8; 1];
let mut buffer = MarkedArrayBuffer::from_bytes(&mut bytes, JSType::Uint8Array);
buffer.destroy(); // mi_free / libc free on a stack address

to_js / to_node_buffer had the same problem deferred: they install MarkedArrayBuffer_deallocator over the stored pointer, so GC frees it later.

All in-tree callers upheld the contract manually (Box::leak / heap::into_raw a default-allocator Box<[u8]>, then reborrow), so there is no user-reachable corruption today; this closes the API-boundary soundness hole.

Fixes #31969

Fix

  • Replace MarkedArrayBuffer::from_bytes(&mut [u8]) with MarkedArrayBuffer::from_owned_bytes(Box<[u8]>, JSType), built on the existing ArrayBuffer::from_owned_bytes, so the ownership transfer is enforced by the type system. This is the same shape Make ArrayBuffer::from_bytes unsafe and add owning constructors #31174 proposed for this type (that PR also covers the separate ArrayBuffer::from_bytes hole, [Unsoundness] ArrayBuffer no-copy constructors trust raw backing pointers #31970, which this PR deliberately does not touch).
  • Route MarkedArrayBuffer::from_string through it.
  • Migrate every caller off the manual leak-and-reborrow dance: Terminal.rs, SubprocessPipeReader.rs, shell/subproc.rs, and node_fs.rs (x3, aliased as Buffer::from_bytes).
  • to_js / to_node_buffer now take &mut self and take the backing buffer out of self (leaving ArrayBuffer::EMPTY), so neither a destroy() after the handoff nor a repeated handoff on the same value can release the allocation twice (both flagged by review). The Readable.rs struct-literal construction was migrated to from_owned_bytes for the same reason.
  • destroy() now skips the free when byte_len == 0: an empty Box<[u8]> has no backing allocation (its pointer is dangling), so freeing it with the default allocator was itself an invalid free. Same reasoning as the existing empty-case guard in JSValue::create_buffer_from_box. destroy() also resets the handle to ArrayBuffer::EMPTY afterward, so the freed pointer cannot be observed through slice() or a later handoff (review finding). The dangling free is not reachable from JS (empty reads return the non-owning Buffer::EMPTY, and the JS-reachable destroy() call sites sit behind that path), so this is defense in depth for the new constructor's contract.

No behavior change: the pointers handed to JSC and the installed deallocators are identical before and after (callers already passed into_boxed_slice() allocations).

Test

A compile_fail,E0599 doctest on MarkedArrayBuffer::from_owned_bytes pins the contract: the exact unsound snippet above must fail to resolve. rustdoc verifies compile_fail doctests without linking, which is what makes a Rust-level test possible here at all: bun_jsc's non-doc test targets cannot link without the C++ side of the runtime.

A new Rust Tests workflow (.github/workflows/rust-test.yml, same setup as clippy.yml: configure + ninja codegen clone-lolhtml) runs cargo test --doc -p bun_jsc on PRs touching src/jsc/**, so the contract is actually checked in CI. Two pre-existing indented doc blocks in host_object.rs and JSGlobalObject.rs are now fenced as ignore: rustdoc treated them as doctests and they never compiled, which only surfaces once something runs --doc.

This replaces the earlier fixture-crate approach (a detached cargo crate with a committed 2.4k-line Cargo.lock, cargo-checked from inside bun:test), which was heavyweight and silently skipped on every CI runner without a cargo toolchain and build tree.

Mutation-tested: re-adding a safe from_bytes(&mut [u8]) makes the doctest fail (the snippet compiles); with the fix in place it passes.

Verification

  • cargo test --doc -p bun_jsc: 1 passed (the compile_fail doctest), and it fails when from_bytes is reintroduced.
  • Existing coverage over the migrated call sites, all passing under the ASAN debug build: test/js/bun/terminal/terminal.test.ts (87 pass), test/js/bun/spawn/spawnSync.test.ts (6 pass), test/js/node/fs/fs.test.ts -t readFile (20 pass), -t readlink (4 pass), the readdirSync({encoding: 'buffer', recursive: true}) destroy-path test (1 pass), and bunshell.test.ts -t quiet (0 fail).
  • Standalone-graph readFileSync Buffer path verified with a compiled executable reading an embedded file.
  • Empty-buffer paths (readFileSync of an empty file, multipart FormData with an empty file blob) verified under ASAN.
  • cargo clippy -p bun_jsc --no-deps clean; cargo fmt --all clean.

Replaces #31986 (adopted from #31981, which touched the same file).

Rebased onto main (post #32970). One conflict, in ret::Readdir::to_js's Buffers arm in node_fs.rs: main independently switched that arm from to_js to to_node_buffer so readdir({ encoding: "buffer" }) returns Buffer[] instead of Uint8Array[], while this PR changes both methods to take &mut self. Resolved by keeping main's to_node_buffer call over items.iter_mut(). Main's readdir with { encoding: 'buffer' } returns Buffer entries test passes on the result.

@robobun

robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Done (current head a1a13d1): the fixture crate and bun:test driver are replaced with a compile_fail,E0599 doctest on MarkedArrayBuffer::from_owned_bytes, run in CI by a new Rust Tests workflow (cargo test --doc -p bun_jsc, path-filtered to src/jsc/**). That lane runs on this PR and passes; the doctest is mutation-tested (fails when a borrowed-slice from_bytes is reintroduced, passes on the fix). Review findings are all addressed: the JSC handoff and destroy() now take the buffer out of self, and the branch is rebased onto main (conflict notes in the description).

CI: every lane that reports a test failure is unrelated to this diff. test-net-connect-memleak.js (a gc-then-assert-collected test) fails only on the two alpine lanes while passing on the glibc lanes of the same build, passes 10/10 locally on this branch under the ASAN debug build, and passes on main at the identical base commit; the darwin autobahn failure is a docker image-build error. A retry of those lanes or a maintainer judgment call is all that is left.

Comment thread test/regression/marked-array-buffer-ownership-soundness.test.ts Outdated
Comment thread test/regression/marked-array-buffer-ownership-soundness.test.ts Outdated

@alii alii left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

can we use rust tests for this rather than an entirely new separate crate that is built from within bun:test? seems wasteful. we have some other rust tests you could likely follow for reference. just make sure that they do still run in ci otherwise not useful. @robobun

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Make ArrayBuffer::from_bytes unsafe and add owning constructors #31174 - Also replaces ArrayBuffer::from_bytes with owning constructors and migrates the same call sites in array_buffer.rs, Terminal.rs, SubprocessPipeReader.rs, node_fs.rs, and shell/subproc.rs

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 51acbc5f-473c-473c-b314-7a973406e089

📥 Commits

Reviewing files that changed from the base of the PR and between 20e2596 and a1a13d1.

📒 Files selected for processing (2)
  • .github/workflows/CLAUDE.md
  • .github/workflows/rust-test.yml

Walkthrough

Refactors MarkedArrayBuffer to require owned bytes, updates destruction and JSC handoff semantics, migrates runtime buffer creation sites to owned slices, and adds Rust doctest CI plus documentation comment updates.

Changes

MarkedArrayBuffer ownership safety and migration

Layer / File(s) Summary
Core ownership contract
src/jsc/array_buffer.rs
MarkedArrayBuffer gains from_owned_bytes(Box<[u8]>, JSType) and from_string now delegates to it with an owning boxed slice.
Safe destruction and JSC handoff
src/jsc/array_buffer.rs
destroy(&mut self) now clears ownership and skips zero-length frees; to_node_buffer(&mut self) and to_js(&mut self) clear ownership before moving the buffer into JSC, and to_js uses MarkedArrayBuffer_deallocator on the non-empty path.
Runtime buffer transfers
src/runtime/api/bun/Terminal.rs, src/runtime/api/bun/subprocess/Readable.rs, src/runtime/api/bun/subprocess/SubprocessPipeReader.rs, src/runtime/shell/subproc.rs, src/runtime/node/node_fs.rs
Terminal, Readable, SubprocessPipeReader, subproc, and node_fs now build Node buffers from owned boxed slices instead of leaking or manually adopting raw pointers; node_fs also adds inline comments in Readdir::Buffers.
Rust doctest CI and docs
.github/workflows/rust-test.yml, .github/workflows/CLAUDE.md, src/jsc/JSGlobalObject.rs, src/jsc/host_object.rs
rust-test.yml adds a Rust doctest workflow with pinned toolchains and generated codegen, while JSGlobalObject and host_object update doc comment examples.

Possibly related PRs

  • oven-sh/bun#31981: Both PRs change src/jsc/array_buffer.rs ownership and deallocation behavior in MarkedArrayBuffer conversion paths.

Suggested reviewers

  • cirospaciari
  • dylan-conway
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: replacing from_bytes with an owning constructor.
Description check ✅ Passed The PR description covers the change and verification, though it uses non-template headings instead of the exact requested section names.
Linked Issues check ✅ Passed The implementation matches #31969 by removing the safe borrowed-slice constructor and replacing it with an owning Box<[u8]> API.
Out of Scope Changes check ✅ Passed The extra workflow and doc updates support the owning-constructor fix and doctest coverage, so no clearly unrelated changes are evident.

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

@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: 2

🤖 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 `@src/jsc/array_buffer.rs`:
- Around line 1036-1045: The method to_node_buffer currently only clears
owns_buffer but leaves self.buffer.ptr/byte_len live — change it to consume the
backing buffer by taking/replacing self.buffer with ArrayBuffer::EMPTY (or
equivalent take/null) before calling JSValue::create_buffer so the original
MarkedArrayBuffer no longer holds the allocation; apply the same take/replace
pattern in the sibling method to_js (and any other handoff sites) so ownership
is transferred exactly once and the source handle is neutralized.
- Around line 962-969: MarkedArrayBuffer::from_string currently uses
Box::from(str) which is infallible and will OOM-abort instead of returning
bun_alloc::AllocError; replace the infallible allocation with a fallible path
that returns Err(bun_alloc::AllocError) on OOM (e.g., allocate into a Vec<u8>
using try_reserve/try_reserve_exact then extend_from_slice and convert to
Box<[u8]> or call the in-tree fallible allocator helper such as allocator.dupe
for u8), then pass the owned bytes to MarkedArrayBuffer::from_owned_bytes so the
function honors its Result<..., bun_alloc::AllocError> contract and keeps
existing semantics with MarkedArrayBuffer_deallocator/default_alloc::free.
🪄 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: 3052f0c9-4b3c-42d4-baa8-ed10e6fb9acd

📥 Commits

Reviewing files that changed from the base of the PR and between 717542f and f2cdba8.

⛔ Files ignored due to path filters (1)
  • test/regression/marked-array-buffer-ownership-soundness-fixture/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (10)
  • src/jsc/array_buffer.rs
  • src/runtime/api/bun/Terminal.rs
  • src/runtime/api/bun/subprocess/Readable.rs
  • src/runtime/api/bun/subprocess/SubprocessPipeReader.rs
  • src/runtime/node/node_fs.rs
  • src/runtime/shell/subproc.rs
  • test/regression/marked-array-buffer-ownership-soundness-fixture/.gitignore
  • test/regression/marked-array-buffer-ownership-soundness-fixture/Cargo.toml
  • test/regression/marked-array-buffer-ownership-soundness-fixture/src/lib.rs
  • test/regression/marked-array-buffer-ownership-soundness.test.ts

Comment thread src/jsc/array_buffer.rs
Comment thread src/jsc/array_buffer.rs

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/regression/marked-array-buffer-ownership-soundness.test.ts (1)

23-26: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Add missing imports from harness.

The test uses bunExe() (lines 82, 115) and bunEnv (lines 99, 132) but never imports them. This will fail at runtime with ReferenceError.

🐛 Proposed fix
-import { spawn, which } from "bun";
+import { bunEnv, bunExe } from "harness";
+import { spawn, which } from "bun";
 import { expect, test } from "bun:test";
 import { existsSync } from "node:fs";
 import { join } from "node:path";
🤖 Prompt for 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.

In `@test/regression/marked-array-buffer-ownership-soundness.test.ts` around lines
23 - 26, The test references bunExe() and bunEnv but never imports them; add
missing imports for bunExe and bunEnv from the test harness module at the top of
test/regression/marked-array-buffer-ownership-soundness.test.ts so the functions
are available to the tests (import the named exports bunExe and bunEnv from the
harness module used by other tests).
🤖 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/regression/marked-array-buffer-ownership-soundness.test.ts`:
- Around line 74-139: Delete the duplicated test block that repeats the test
titled "empty buffers round-trip without freeing the dangling sentinel" so only
one instance remains; locate the two identical test(...) declarations (the
blocks starting with test("empty buffers round-trip without freeing the dangling
sentinel", async () => { ... })) and remove one of them (keep the other intact)
to eliminate the copy-paste duplicate while preserving the assertions and spawn
logic.

---

Outside diff comments:
In `@test/regression/marked-array-buffer-ownership-soundness.test.ts`:
- Around line 23-26: The test references bunExe() and bunEnv but never imports
them; add missing imports for bunExe and bunEnv from the test harness module at
the top of test/regression/marked-array-buffer-ownership-soundness.test.ts so
the functions are available to the tests (import the named exports bunExe and
bunEnv from the harness module used by other tests).
🪄 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: de0c920e-5573-41b0-a2b5-45bff4568055

📥 Commits

Reviewing files that changed from the base of the PR and between f2cdba8 and c9457e1.

📒 Files selected for processing (1)
  • test/regression/marked-array-buffer-ownership-soundness.test.ts

Comment thread test/regression/marked-array-buffer-ownership-soundness.test.ts Outdated
@alii

alii commented Jun 9, 2026

Copy link
Copy Markdown
Member Author

@robobun? adopt plz

@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 @.github/workflows/rust-test.yml:
- Line 42: The action metadata for setup-bun uses unsupported `type:` keys on
inputs which breaks workflow parsing; open the action metadata for the setup-bun
local action and remove the `type:` entries from the inputs definitions (for
inputs named `bun-version`, `baseline`, and `download-url`) so the inputs remain
declared without `type` fields and the workflow can parse the action correctly.
🪄 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: 39531e4c-ccd2-4776-a299-8a1f4580c2f5

📥 Commits

Reviewing files that changed from the base of the PR and between bc5c330 and 20e2596.

📒 Files selected for processing (4)
  • .github/workflows/rust-test.yml
  • src/jsc/JSGlobalObject.rs
  • src/jsc/array_buffer.rs
  • src/jsc/host_object.rs

Comment thread .github/workflows/rust-test.yml
robobun and others added 6 commits June 29, 2026 03:59
MarkedArrayBuffer::from_bytes(&mut [u8]) was a safe public constructor
that stored a borrowed slice's pointer with owns_buffer: true, so safe
code could mark a stack or short-lived buffer as allocator-owned and
destroy() would later free it with the default allocator.

Replace it with from_owned_bytes(Box<[u8]>, JSType) so the ownership
transfer is enforced by the type system, route from_string through it,
and migrate the callers (Terminal.rs, SubprocessPipeReader.rs,
shell/subproc.rs, node_fs.rs x3) off their manual Box::leak /
heap::into_raw dances.

destroy() now skips the free for empty buffers: an empty Box<[u8]> has
no backing allocation, so freeing its dangling pointer with the default
allocator was itself an invalid free.

test/regression/marked-array-buffer-ownership-soundness.test.ts runs
cargo check over a fixture crate containing the unsound pattern and
asserts it no longer resolves.

Fixes #31969
Review follow-ups:
- to_js/to_node_buffer now take &mut self and clear owns_buffer at the
  handoff, so a later destroy() cannot free the allocation JSC now owns.
- Migrate the Readable.rs struct-literal construction to
  MarkedArrayBuffer::from_owned_bytes so the destroy() SAFETY comment
  holds for every owns_buffer producer.
- Make the soundness test self-diagnosing when the fixture Cargo.lock
  goes stale, and document the regeneration command.
An empty Box<[u8]> has no backing allocation - its pointer is
NonNull::dangling(), not a heap address - so destroy() and the JSC
handoff must skip the free when byte_len == 0. Exercises empty
readFileSync round-trips plus GC in a subprocess; on the ASAN debug
build, removing the guard makes the free of the dangling sentinel
abort, so the test discriminates (verified by mutation).
Mutation-testing the byte_len guard out of destroy() leaves the test
green - the empty readFileSync path hands ownership to JSC before any
destroy(), so the dangling-pointer free is not reachable from JS here.
The guard's claim was verified at the Rust level instead (an empty
Box<[u8]> performs no allocation and its pointer is NonNull::dangling).
The MarkedArrayBuffer ownership contract test moves from a detached
cargo fixture crate driven by bun:test into a compile_fail,E0599
doctest on from_owned_bytes. rustdoc verifies compile_fail doctests
without linking, so this works for bun_jsc even though its non-doc
test targets cannot link without the C++ side of the runtime.

A new Rust Tests workflow runs cargo test --doc -p bun_jsc on PRs
touching src/jsc/**, so the contract is actually checked in CI (the
fixture test skipped on every runner without a cargo toolchain and
build tree). Two pre-existing indented doc blocks in host_object.rs
and JSGlobalObject.rs are fenced as ignore; rustdoc treated them as
doctests and they never compiled.

to_js/to_node_buffer also take the backing buffer out of self at the
JSC handoff (leaving ArrayBuffer::EMPTY), so neither a later destroy()
nor a repeated handoff can release the same allocation twice.
@robobun robobun force-pushed the ali/marked-array-buffer-owning-ctor branch from 20e2596 to 299c27b Compare June 29, 2026 04:07
@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator
Updated 1:22 AM PT - Jun 29th, 2026

@robobun, your commit a1a13d1 has 2 failures in Build #66653 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32031

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

bun-32031 --bun

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto main and force-pushed (299c27b). One conflict, in the ret::Readdir::to_js Buffers arm in node_fs.rs: main independently switched it from to_js to to_node_buffer so readdir({ encoding: "buffer" }) returns Buffer[], while this PR makes both methods take &mut self. Kept main's to_node_buffer over items.iter_mut(); details added to the description.

Re-verified on the rebased tree: cargo test --doc -p bun_jsc passes, the debug build is clean, and main's readdir with { encoding: 'buffer' } returns Buffer entries test plus the terminal/spawnSync/readFile suites pass under ASAN. Main still had the unsound from_bytes, so nothing here was superseded.

Comment thread src/jsc/array_buffer.rs
Comment thread .github/workflows/rust-test.yml
…lchain pin

destroy() now resets self.buffer to ArrayBuffer::EMPTY after releasing the
allocation, so the freed pointer cannot be observed through slice() or a
later handoff; this matches the take/replace pattern to_js and
to_node_buffer already use.

The Rust toolchain bump checklist in .github/workflows/CLAUDE.md now lists
rust-test.yml alongside clippy.yml and miri.yml, since it pins the same
RUSTUP_TOOLCHAIN.
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.

[Unsoundness] MarkedArrayBuffer::from_bytes frees caller-owned slices

2 participants