Skip to content

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

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

jsc: replace MarkedArrayBuffer::from_bytes with an owning constructor#32031
alii wants to merge 2 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) is a safe, public constructor that stores a borrowed slice's pointer in a lifetime-less struct with owns_buffer set. destroy() then frees that pointer with the default allocator, and since MarkedArrayBuffer gained a Drop impl (#37075) so does a plain drop. Safe code can free a stack buffer:

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

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. It keeps main's rule that an empty box (no backing allocation) is not marked owned. The separate ArrayBuffer::from_bytes hole ([Unsoundness] ArrayBuffer no-copy constructors trust raw backing pointers #31970) is deliberately not touched.
  • Route from_string through it and migrate the remaining callers off the leak-and-reborrow dance: Terminal.rs, SubprocessPipeReader.rs, and node_fs.rs (x3, via the Buffer alias). The Readable.rs struct literal goes through the constructor too.
  • destroy(), to_js() and to_node_buffer() take the buffer out of self (leaving ArrayBuffer::EMPTY) when they release it, so a later destroy()/Drop or a repeated handoff cannot release the same allocation twice.

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

Test

The removal is enforced by every build: any reintroduced caller of from_bytes fails to compile. The contract itself is pinned by a compile_fail,E0599 doctest on from_owned_bytes containing the snippet above, runnable with cargo test --doc -p bun_jsc (rustdoc checks compile_fail doctests without linking, which is what makes a Rust-level test possible for bun_jsc, whose test targets cannot link without the C++ side). Mutation-tested: re-adding a safe from_bytes(&mut [u8]) makes the doctest fail; on this diff it passes. Per review, the earlier attempts at wiring this into CI (a fixture crate driven from bun:test, then a dedicated workflow) were dropped; the doctest is a source-level guard.

There is no JS-observable behavior to test: the change is to the Rust API surface, and the migrated call sites produce byte-identical results, which the existing suites below cover.

Also in this PR: the x64-asan leak-test flakes

The x64-asan lane failed on inspect-error-leak.test.js and require-cache.test.ts. Both reproduce with this PR's src changes removed, and inspect-error-leak timed out on that lane in 56 of the 300 most recent builds, so this is a pre-existing flake class rather than anything this diff does; fixed here at alii's request (d3ed391). The tests' RSS thresholds were already ASAN-aware but their clock budgets were fixed release-build numbers (the 100k-iteration inspect() loop takes ~13s on the asan lane against a 10s budget; the 100k-iteration import() fixture overruns 30s). The budgets now scale under ASAN or debug builds, the import() fixture (which ran 10x more iterations than its CJS sibling) takes its iteration count from the driving test with a proportionally scaled threshold, and the four standalone fixtures now detect ASAN by asking the runtime (as harness.isASAN does) instead of sniffing the executable name, which missed debug+ASAN builds. Release builds keep the same iteration counts, thresholds and budgets as before.

Verification

On this diff rebuilt against current main, under the ASAN debug build:

  • cargo test --doc -p bun_jsc: the doctest passes, and fails with from_bytes reintroduced.
  • test/js/web/workers/worker-terminate-lifetime.test.ts (the zero-length readFile drop path, which exercises Drop -> destroy() on an unconverted empty buffer): pass.
  • test/js/bun/terminal/terminal.test.ts (98 pass), test/js/bun/spawn/spawnSync.test.ts (14 pass), spawn-empty-arrayBufferOrBlob.test.ts and the spawn.test.ts stdout-read case (PipeReader/Readable handoffs), test/js/node/fs/fs.test.ts -t readFile (22), -t encoding (39, includes readdir with encoding: "buffer"), -t readlink (10): all pass.
  • cargo fmt --all clean.

History: replaces #31986; re-targeted onto current main after main independently picked up the &mut self handoffs, the Drop impl, the empty-box ownership rule, and the doc-comment fences that earlier revisions of this PR carried, so the diff is now just the constructor swap, the caller migration, and the handle neutralization. Rebased again onto main at 07d38c1 (two linear commits); the only conflict was in Terminal.rs, where main had added a landing-frame comment and switched the error arm to dispatch::fold around the same call this PR rewrites, resolved by keeping main's text with the from_owned_bytes call.


no test proof · iteration 18 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/run/require-cache.test.ts

@robobun

robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto current main (head 2efb507: the src fix plus the leak-test budget fix alii asked for). Neither change has landed on main independently; MarkedArrayBuffer::from_bytes is still present there. Re-verified under the ASAN debug build after the rebase (doctest, worker-terminate, spawn, fs, and both leak-test files pass). Bot reviews were clean on the previous head; CI is running on the new one. Ready for maintainer sign-off.

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

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
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.
Title check ✅ Passed The title clearly and concisely states the main API change: replacing MarkedArrayBuffer::from_bytes with an owning constructor.
Description check ✅ Passed The description explains the problem, implementation, verification steps, affected tests, and related issue, despite using different headings from the template.

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 Outdated

@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 Outdated
@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 3:08 PM PT - Aug 16th, 2026

@robobun, your commit 2efb50787ecce0f427b69aa6828ab4d7c2e8c03b passed in Build #99536! 🎉


🧪   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 Outdated
@alii

alii commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

@robobun get this mergeable — or close it if the changes are already covered on main / too stale to salvage.

@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

On it. Checked main first: it is not covered there. Main has since picked up the peripheral hardening from this PR (the &mut self handoffs that clear owns_buffer, a Drop impl, the doc fences), but the actual #31969 hole, the safe MarkedArrayBuffer::from_bytes(&mut [u8]) constructor, is still present, and the new Drop impl makes it strictly worse (a borrowed slice now gets freed automatically on drop, no explicit destroy() needed). Re-targeting this onto current main now; the diff gets a lot smaller.

Comment thread .github/workflows/CLAUDE.md Outdated
@robobun
robobun force-pushed the ali/marked-array-buffer-owning-ctor branch from a4b44ed to aecad2d Compare August 11, 2026 00:02
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Re-targeted onto current main and force-pushed as a single commit (aecad2d), replacing the stale stack.

Main had independently picked up the peripheral parts of the old branch (&mut self handoffs, a Drop impl, the empty-box ownership rule, the doc fences), so the diff is now just what is missing there: the from_bytes to from_owned_bytes swap, the six caller migrations (from_string, Terminal, SubprocessPipeReader, Readable, node_fs x3; the old shell/subproc site no longer exists), and neutralizing the handle in destroy/to_js/to_node_buffer. 5 files, net +16 lines. Per the workflow deletion, there is no .github change; the compile_fail doctest stays as a source-level guard (mutation-tested both ways locally) and the removal itself is enforced by every build.

Verified on the rebuilt diff under ASAN: doctest, the zero-length readFile worker-terminate test (exercises Drop on an unconverted empty buffer), terminal (98), spawnSync (14), the spawn buffer paths, and fs readFile/encoding/readlink all pass. Description updated; CI is running on the new head.

@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 re-reviewed after the force-push to aecad2d and found no bugs. Both earlier notes are resolved: destroy() now neutralizes self.buffer after the free, and the stale .github/workflows/CLAUDE.md / rust-test.yml hunk is gone (the diff is now the 5 source files only). Because this rewires allocator/FFI ownership at the JSC boundary — the repo's most-scrutinized category — a maintainer sign-off is still worthwhile.

What was reviewed:

  • from_owned_bytes: empty-box → owns_buffer=false preserved; Buffer alias in node_fs.rs resolves to MarkedArrayBuffer, so all three Buffer::from_owned_bytes sites hit the new constructor.
  • to_js/to_node_buffer/destroy handle-neutralization: grepped every to_node_buffer/to_js caller — none reads self.buffer after the handoff, and the from_js early-return in to_js still fires before the mem::replace.
  • Readable.rs struct-literal → constructor: the old literal set owns_buffer=true unconditionally; the new path sets it to !bytes.is_empty(). Checked the empty case — to_node_buffer runs immediately either way, so no behavior change (and the new path is strictly safer for the dangling empty-box pointer).
  • No remaining MarkedArrayBuffer::from_bytes callers outside the compile_fail doctest.
Extended reasoning...

Overview

The PR replaces the unsound MarkedArrayBuffer::from_bytes(&mut [u8], JSType) constructor in src/jsc/array_buffer.rs with from_owned_bytes(Box<[u8]>, JSType), migrates the six in-tree callers (from_string, Terminal.rs, SubprocessPipeReader.rs, Readable.rs, and three node_fs.rs sites via the Buffer alias), and neutralizes self.buffer via mem::replace(.., ArrayBuffer::EMPTY) in the three release paths (destroy, to_js, to_node_buffer). Net diff is 5 files, ~+16 lines. Since my last review the branch was force-pushed to a single commit that drops all .github/ changes, resolving the stale rust-test.yml reference I'd flagged.

Security risks

None identified. This is an internal Rust-API soundness fix; no user-controlled input flows through the changed code paths differently than before, and the pointers/deallocators handed to JSC are byte-identical to what the leak-and-reborrow callers produced.

Level of scrutiny

High. src/jsc/array_buffer.rs is FFI glue that hands raw allocations to JavaScriptCore with a GC-time deallocator, and MarkedArrayBuffer now has a Drop impl — exactly the "every allocation has one owner, released once, with the right allocator" territory REVIEW.md calls out as the most-blocked category. The change is mechanically simple, but the domain warrants a maintainer's eyes.

Other factors

  • Both of my prior inline notes (the destroy() dangling-handle nit and the orphaned rust-test.yml doc reference) are addressed in the current head.
  • CodeRabbit's earlier "neutralize on handoff" concern is addressed (mem::replace in all three release paths).
  • alii is actively engaged and asked for the PR to be made mergeable, so a human review is already expected; this note is to record what the automated pass covered.
  • Verified Buffer in node_fs.rs aliases jsc::MarkedArrayBuffer (src/runtime/node/types.rs:17), and grepped for any remaining MarkedArrayBuffer::from_bytes callers (only the intentional compile_fail doctest remains).

@alii

alii commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

@robobun fix the debian-x64-asan test failure in build #91768.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

On it. The debian-x64-asan lane in #91768 had two test files red: test/js/bun/util/inspect-error-leak.test.js and test/cli/run/require-cache.test.ts (the import() leak case). Both reproduce locally on the ASAN debug build and also reproduce with this PR's diff removed, so they are main breakages rather than something this diff introduced; root-causing them now and will report back here.

@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 the re-targeted diff and found no issues. All prior automated-review feedback (the destroy() handle-neutralization nit and the stale .github/workflows/CLAUDE.md reference) is addressed on the current head — the diff is now just the five .rs files. Because this is a memory-ownership change at the Rust↔JSC FFI boundary, a maintainer sign-off is still worthwhile.

What was reviewed:

  • from_owned_bytes computes owns_buffer = !bytes.is_empty() before the move into ArrayBuffer::from_owned_bytes, matching main's empty-box rule; the Readable.rs struct-literal that hard-coded owns_buffer: true now goes through it, so the empty case is handled.
  • destroy()/to_js()/to_node_buffer() all take/null the buffer (mem::replaceArrayBuffer::EMPTY), so a repeated handoff or post-destroy accessor cannot reach the freed pointer; the leak-on-throw direction in to_js is unchanged (owns_buffer was already cleared pre-call on main).
  • Grepped for remaining MarkedArrayBuffer::from_bytes callers and owns_buffer: true struct literals — none survive outside the compile_fail doctest; the remaining ArrayBuffer::from_bytes sites are the separate #31970 hole the PR scopes out.
  • Confirmed node_fs.rs's Buffer alias resolves to MarkedArrayBuffer (src/runtime/node/types.rs:17), so the three Buffer::from_owned_bytes migrations bind to the new constructor.
Extended reasoning...

Overview

The PR replaces the unsound MarkedArrayBuffer::from_bytes(&mut [u8], JSType) constructor in src/jsc/array_buffer.rs with from_owned_bytes(Box<[u8]>, JSType), routes from_string through it, migrates the six in-tree callers (Terminal, SubprocessPipeReader, Readable, node_fs ×3), and hardens the three release paths (destroy, to_js, to_node_buffer) to take the buffer out of self via mem::replace(.., ArrayBuffer::EMPTY) so a later Drop or repeated handoff cannot double-release. Net +16 lines across 5 .rs files; the earlier .github changes were dropped.

Security risks

This is a soundness hardening at the Rust↔JSC ownership boundary — the class of bug it closes is double-free / free-of-borrowed-storage. The change strictly narrows the API surface (Box-only) and neutralizes handles on transfer, so it reduces risk rather than adding it. No new user-facing surface, no auth/crypto/permissions.

Level of scrutiny

High. src/jsc/array_buffer.rs is the layer that hands raw allocations to JavaScriptCore with a deallocator; a mistake here is a UAF or double-free reachable from every readFile/spawn/terminal data path. That said, the diff itself is small and mechanical: a constructor rename that lets the type system enforce what callers were already doing manually (Box::leak + reborrow), plus the take/null pattern applied uniformly to all three release paths. I traced each migrated call site and confirmed the allocation being handed over was already a default-allocator Box<[u8]> in every case, so the pointers/deallocators reaching JSC are byte-identical to before.

Other factors

All prior automated feedback on this PR is resolved on the current head: CodeRabbit's "consume the backing buffer on handoff" and my destroy() neutralization nit are both applied; the stale rust-test.yml/CLAUDE.md references are gone (no .github changes remain). The bug-hunting pass on this revision found nothing. The maintainer (alii) is actively shepherding and today asked about a debian-x64-asan lane failure — robobun's analysis that inspect-error-leak/require-cache reproduce on main without this diff is consistent with the change (nothing here touches those paths), but that thread is still open. Given the FFI-memory-ownership subject matter and the active maintainer engagement, deferring rather than auto-approving.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixed in d3ed391 (rebased onto your latest merge of main).

What was actually failing on the x64-asan lane in #91768: inspect-error-leak.test.js and the import() file-paths case in require-cache.test.ts, both on the clock, not on their RSS assertions (345 MB against a 400 MB bound, and the fixture under its 500 MB bound). The 100k-iteration inspect() loop takes ~13s on that lane against a 10s budget, and the 100k-iteration import() fixture overruns its 30s budget. Scanning the last 300 builds, inspect-error-leak timed out on the asan lane in 56 of them and require-cache hard-failed there on unrelated PRs too, and both reproduce with this PR's src changes removed, so it is a pre-existing flake class; this build just drew both at once.

The fix keeps leak detection as it was and fixes the clocks: the budgets scale under ASAN or debug builds (same isDebug || isASAN pattern as serve-body-leak / req-url-leak), the import() fixture, which ran 10x more iterations than its CJS sibling, now takes its iteration count from the test with a proportionally scaled threshold, and the four standalone fixtures detect ASAN by asking the runtime (as harness.isASAN does) rather than sniffing for bun-asan in the executable name, which missed debug+ASAN builds and made them apply release thresholds. Release lanes keep identical iteration counts, thresholds and budgets. Verified on the ASAN debug build here: both previously-failing tests pass, the AST fixtures still pass (and the detection fix is what makes require-cache-bug-leak-fixture.js pass on a debug+ASAN build), and the fixtures report no-leak RSS well inside their bounds.

@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 re-reviewed after the retarget and the test-budget commit; no bugs found. Both earlier nits are resolved (destroy() now neutralizes self.buffer, and the .github/workflows/CLAUDE.md hunk is gone). The Rust side looks correct — the constructor swap, the six caller migrations, and the take-on-handoff pattern all check out, and the Readable.rs struct-literal → from_owned_bytes change also fixes the empty-box owns_buffer=true edge case that was there before.

Given this touches FFI ownership transfer plus the new leak-test budget tuning (the esm-fixture-leak-small iteration/threshold scaling under instrumentation in particular), a maintainer sign-off on the final shape would still be worthwhile.

What was reviewed:

  • from_owned_bytes empty-box handling and the mem::replace neutralization in destroy/to_js/to_node_buffer — allocator/ownership pairing is unchanged from the pre-PR call sites.
  • Fixture isASAN detection matches harness.ts's detectASAN(); isASANEnabled exists in bun:internal-for-testing.
  • esm-fixture-leak-small.mjs threshold formula at 100k iterations reduces to the previous 100 MB / 500 MB bounds, so release-lane sensitivity is unchanged.
Extended reasoning...

Overview

Eleven files. Five Rust files close the #31969 soundness hole by replacing MarkedArrayBuffer::from_bytes(&mut [u8]) with from_owned_bytes(Box<[u8]>), migrating six call sites (from_string, Terminal, SubprocessPipeReader, Readable, node_fs ×3 via the Buffer alias), and neutralizing self.buffer in destroy/to_js/to_node_buffer so a repeated handoff or post-destroy access cannot observe the freed pointer. Six test files (new since my 2026-08-10 review) address pre-existing debian-x64-asan failures alii asked robobun to fix: the four leak fixtures now detect ASAN via bun:internal-for-testing (matching harness.ts) instead of the executable name, require-cache.test.ts adds a leakTimeout() helper and scales esm-fixture-leak-small to 10k iterations under instrumentation via LEAK_ITERATIONS, and inspect-error-leak.test.js widens its clock budget under debug/ASAN.

Security risks

None introduced. The Rust change strictly tightens an API boundary — the old constructor let safe code hand a stack pointer to default_alloc::free; the new one requires Box<[u8]>. The deallocators installed (MarkedArrayBuffer_deallocator / JSValue::create_buffer's deallocator) and the allocator that produced every migrated buffer (.into_boxed_slice() on the global allocator) are unchanged from main. No untrusted-input parsing, auth, or crypto is touched.

Level of scrutiny

High. This is FFI ownership transfer in bun_jsc — exactly the "every allocation has one named owner, released once, with the allocator that allocated it" territory REVIEW.md calls out as most-blocked. Separately, the test changes tune RSS thresholds and iteration counts on leak regression tests, which per REVIEW.md needs care that the property the original assertion protected is still asserted. I verified the release-lane bounds are byte-identical (100 MB / 500 MB at 100k iterations) and only the instrumented lanes are relaxed, which follows the repo convention that ASAN's job is UAF detection while release lanes are the RSS canary — but a maintainer should confirm that trade-off is acceptable.

Other factors

All three of my prior inline comments are resolved in the current diff. alii has been actively shepherding ("get this mergeable", "fix the debian-x64-asan test failure") and is the natural sign-off. CI on the new head (#92381) was still building at review time. The Readable.rs migration incidentally corrects a latent owns_buffer=true on an empty boxed slice (dangling ptr) that the old struct literal set unconditionally — not user-reachable, but worth noting the direction is strictly safer.

MarkedArrayBuffer::from_bytes(&mut [u8], JSType) was a safe constructor
that stored a borrowed slice's pointer with owns_buffer set, so destroy()
(and, since the Drop impl landed, plain drop) freed memory the caller
still owned. Safe code could free a stack buffer with it (#31969).

Replace it with from_owned_bytes(Box<[u8]>, JSType), built on
ArrayBuffer::from_owned_bytes, so the ownership transfer is enforced by
the type system. Empty boxes have no backing allocation, so the
constructor keeps the existing rule of not marking them owned. Route
from_string through it and migrate the remaining callers (Terminal,
SubprocessPipeReader, node_fs x3) off the leak-and-reborrow pattern;
the Readable struct literal goes through the constructor too.

destroy(), to_js() and to_node_buffer() now take the buffer out of self
when they release it, so a later destroy()/Drop or a repeated handoff
cannot release the same allocation twice.

The contract is pinned by a compile_fail doctest on from_owned_bytes,
runnable with cargo test --doc -p bun_jsc (rustdoc checks compile_fail
doctests without linking, which is what makes a Rust-level test possible
for bun_jsc). The removal itself is enforced by every build: any
reintroduced caller of from_bytes fails to compile.
inspect-error-leak.test.js and the require-cache leak tests already use
ASAN-aware RSS thresholds (the RSS bound is what detects a leak), but
their clock budgets were fixed release-build numbers. On the x64-asan
lane the 100k-iteration inspect() loop takes ~13s against a 10s budget
and the 100k-iteration import() fixture overruns its 30s budget, so
both time out regularly there (inspect-error-leak timed out on the asan
lane in 56 of the last 300 builds) and debug builds time out locally.

Scale the clock budgets when running under ASAN or a debug build, and
have the import() fixture, which ran 10x more iterations than its CJS
sibling, take its iteration count from the driving test so instrumented
builds run a proportionally scaled loop with a proportionally scaled
threshold. Release builds keep the same iteration counts, thresholds
and budgets as before.

The standalone fixtures detected ASAN by sniffing the executable name
for bun-asan, which misses debug builds (bun-debug) that also enable
ASAN, so they applied release thresholds to ASAN processes and failed
locally. They now ask the runtime, the same way harness.isASAN does,
and fall back to the name check.
@robobun
robobun force-pushed the ali/marked-array-buffer-owning-ctor branch from d3ed391 to 2efb507 Compare August 16, 2026 20:05
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto current main (07d38c1) and force-pushed; the branch is now the two commits, 0547fed (src) and 2efb507 (leak-test budgets), with the earlier merge commits folded away. Neither change has landed on main in the meantime: MarkedArrayBuffer::from_bytes is still there and the leak tests still have the fixed budgets.

One conflict, in Terminal.rs: main added a landing-frame comment and switched the error arm to dispatch::fold around the same call this PR rewrites. Kept main's text with the from_owned_bytes call; the other three overlapping files auto-merged (main's array_buffer.rs change is an unrelated pinned field on ArrayBuffer).

Re-verified on the rebased tree under the ASAN debug build: the compile_fail doctest, the worker-terminate zero-length readFile test, spawnSync and the spawn buffer tests, fs readFile/encoding/readlink, and both leak-test files all pass. The terminal suite was flaky on this box today (PTY timing tests), but it fails the same way with this PR's src removed, so that is environmental; the Terminal.rs resolution is the same call that passed the suite on CI before the rebase.

@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. Both of my earlier nits are addressed (destroy() now neutralizes self.buffer to ArrayBuffer::EMPTY after the free, and the stale .github/workflows/CLAUDE.md hunk is gone). Because the change rewires ownership/deallocation in MarkedArrayBuffer across the FFI boundary and the second commit adjusts leak-test thresholds and iteration counts, a maintainer sign-off would still be worthwhile.

Checked: no remaining MarkedArrayBuffer::from_bytes / Buffer::from_bytes callers outside the compile_fail doctest; the surviving ArrayBuffer::from_bytes sites are #31970 and out of scope. The Readable.rs struct-literal → from_owned_bytes swap changes owns_buffer from true to !bytes.is_empty(), which is correct for the empty-box dangling-ptr case and irrelevant on the immediate to_node_buffer path. The fixture isASAN detection matches harness.ts's detectASAN() verbatim, and release lanes keep their pre-existing iteration counts / thresholds / budgets (esm-fixture-leak-small at 100k → 100/500 MB as before).

Extended reasoning...

Overview

Two commits on top of current main. The first (0547fedf) replaces the unsound MarkedArrayBuffer::from_bytes(&mut [u8]) constructor in src/jsc/array_buffer.rs with from_owned_bytes(Box<[u8]>), migrates the six callers (from_string, Terminal.rs, SubprocessPipeReader.rs, Readable.rs, node_fs.rs ×3 via the Buffer alias), and neutralizes self.buffer to ArrayBuffer::EMPTY in destroy()/to_js()/to_node_buffer() so a repeated release cannot observe the freed pointer. The second (2efb5078) gives the module-load leak tests instrumentation-aware clock budgets and switches four standalone fixtures from execPath-sniffing to bun:internal-for-testing's isASANEnabled().

Security risks

The change is a strict soundness improvement: it removes a safe constructor that could free a stack address. The migrated callers hand the same default-allocator Box<[u8]> allocations to the same deallocator (default_alloc::free / MarkedArrayBuffer_deallocator), so runtime behavior is unchanged. The new handle-neutralization is defense-in-depth (no in-tree caller reuses a MarkedArrayBuffer after handoff). No new attack surface.

Level of scrutiny

High — this is allocator/deallocator wiring across the Rust↔JSC FFI boundary, which REVIEW.md calls out as the most-blocked category. The change is small and well-argued, but ownership transfer across FFI is exactly where a maintainer's eyes add the most value. The second commit adjusts leak-test thresholds and iteration counts; release lanes are provably unchanged, but the instrumented-lane scaling (10k iterations, 446 MB ASAN threshold in esm-fixture-leak-small.mjs) is a judgment call alii should confirm.

Other factors

alii has been actively driving this PR (adopted it, requested the rebase, requested the x64-asan flake fix). All prior review threads (mine, CodeRabbit's) are resolved and reflected in the current diff. The bug-hunting pass found nothing. Verification per the PR description covers the affected paths under ASAN. Given the memory-safety surface plus the leak-threshold adjustments, deferring for a final maintainer sign-off rather than auto-approving.

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