Repository navigation
Conversation
|
Rebased onto current main (head 0324264: the src fix, the leak-test budget fix alii asked for, plus a small review follow-up that limits the reduced leak-fixture loop to ASAN so non-ASAN builds keep full leak detection). CI on 0324264 (build 112436): the only red lane is |
alii
left a comment
There was a problem hiding this comment.
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
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughRefactors ChangesMarkedArrayBuffer ownership safety and migration
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
Comment |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
test/regression/marked-array-buffer-ownership-soundness-fixture/Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (10)
src/jsc/array_buffer.rssrc/runtime/api/bun/Terminal.rssrc/runtime/api/bun/subprocess/Readable.rssrc/runtime/api/bun/subprocess/SubprocessPipeReader.rssrc/runtime/node/node_fs.rssrc/runtime/shell/subproc.rstest/regression/marked-array-buffer-ownership-soundness-fixture/.gitignoretest/regression/marked-array-buffer-ownership-soundness-fixture/Cargo.tomltest/regression/marked-array-buffer-ownership-soundness-fixture/src/lib.rstest/regression/marked-array-buffer-ownership-soundness.test.ts
There was a problem hiding this comment.
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 winAdd missing imports from harness.
The test uses
bunExe()(lines 82, 115) andbunEnv(lines 99, 132) but never imports them. This will fail at runtime withReferenceError.🐛 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
📒 Files selected for processing (1)
test/regression/marked-array-buffer-ownership-soundness.test.ts
|
@robobun? adopt plz |
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
.github/workflows/rust-test.ymlsrc/jsc/JSGlobalObject.rssrc/jsc/array_buffer.rssrc/jsc/host_object.rs
20e2596 to
299c27b
Compare
|
Updated 8:05 PM PT - Sep 7th, 2026
❌ @robobun, your commit 0324264 has 1 failures in 🧪 To try this PR locally: bunx bun-pr 32031That installs a local version of the PR into your bun-32031 --bun |
|
Rebased onto main and force-pushed (299c27b). One conflict, in the Re-verified on the rebased tree: |
|
@robobun get this mergeable — or close it if the changes are already covered on main / too stale to salvage. |
|
On it. Checked main first: it is not covered there. Main has since picked up the peripheral hardening from this PR (the |
a4b44ed to
aecad2d
Compare
|
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 ( 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. |
There was a problem hiding this comment.
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=falsepreserved;Bufferalias innode_fs.rsresolves toMarkedArrayBuffer, so all threeBuffer::from_owned_bytessites hit the new constructor.to_js/to_node_buffer/destroyhandle-neutralization: grepped everyto_node_buffer/to_jscaller — none readsself.bufferafter the handoff, and thefrom_jsearly-return into_jsstill fires before themem::replace.Readable.rsstruct-literal → constructor: the old literal setowns_buffer=trueunconditionally; the new path sets it to!bytes.is_empty(). Checked the empty case —to_node_bufferruns immediately either way, so no behavior change (and the new path is strictly safer for the dangling empty-box pointer).- No remaining
MarkedArrayBuffer::from_bytescallers outside thecompile_faildoctest.
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 orphanedrust-test.ymldoc reference) are addressed in the current head. - CodeRabbit's earlier "neutralize on handoff" concern is addressed (
mem::replacein 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
Bufferinnode_fs.rsaliasesjsc::MarkedArrayBuffer(src/runtime/node/types.rs:17), and grepped for any remainingMarkedArrayBuffer::from_bytescallers (only the intentionalcompile_faildoctest remains).
|
@robobun fix the debian-x64-asan test failure in build #91768. |
|
On it. The debian-x64-asan lane in #91768 had two test files red: |
There was a problem hiding this comment.
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_bytescomputesowns_buffer = !bytes.is_empty()before the move intoArrayBuffer::from_owned_bytes, matching main's empty-box rule; theReadable.rsstruct-literal that hard-codedowns_buffer: truenow goes through it, so the empty case is handled.destroy()/to_js()/to_node_buffer()all take/null the buffer (mem::replace→ArrayBuffer::EMPTY), so a repeated handoff or post-destroy accessor cannot reach the freed pointer; the leak-on-throw direction into_jsis unchanged (owns_buffer was already cleared pre-call on main).- Grepped for remaining
MarkedArrayBuffer::from_bytescallers andowns_buffer: truestruct literals — none survive outside thecompile_faildoctest; the remainingArrayBuffer::from_bytessites are the separate #31970 hole the PR scopes out. - Confirmed
node_fs.rs'sBufferalias resolves toMarkedArrayBuffer(src/runtime/node/types.rs:17), so the threeBuffer::from_owned_bytesmigrations 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.
|
Fixed in d3ed391 (rebased onto your latest merge of main). What was actually failing on the x64-asan lane in #91768: The fix keeps leak detection as it was and fixes the clocks: the budgets scale under ASAN or debug builds (same |
There was a problem hiding this comment.
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_bytesempty-box handling and themem::replaceneutralization indestroy/to_js/to_node_buffer— allocator/ownership pairing is unchanged from the pre-PR call sites.- Fixture
isASANdetection matchesharness.ts'sdetectASAN();isASANEnabledexists inbun:internal-for-testing. esm-fixture-leak-small.mjsthreshold 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.
d3ed391 to
2efb507
Compare
|
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: One conflict, in 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. |
There was a problem hiding this comment.
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.
2efb507 to
62afaf7
Compare
|
Rebased onto current main (c09b847) and force-pushed; still the same two commits, e59b75c (src) and 62afaf7 (leak-test budgets). Both fixes are still absent from main. The one conflict was a useful one: main removed the unused Re-verified on the rebased tree under the ASAN debug build: compile_fail doctest, worker-terminate zero-length readFile, terminal (98/98), spawnSync and the spawn buffer tests, fs readFile/encoding/readlink, and both leak-test files all pass. |
There was a problem hiding this comment.
I re-reviewed after the latest rebase and the leak-test budget commit; my earlier nits (destroy() handle neutralization, the stale CLAUDE.md/rust-test.yml reference) are all addressed and the bug hunt found nothing new. Since this touches allocator-ownership across the JSC FFI boundary, a maintainer sign-off is still the right call.
What was reviewed:
from_owned_bytesvs the callers: confirmedBufferin node_fs is theMarkedArrayBufferalias, no remainingMarkedArrayBuffer::from_bytescallers outside the compile_fail doctest, and the empty-box path (dangling ptr,owns_buffer=false) round-trips throughto_node_buffer→byte_slice_mut()as an empty slice.Readable.rsstruct-literal → constructor swap for theowns_bufferdifference on empty input —to_node_bufferclears it first either way, so no behavior change.- Leak-test budgets: at 100k iterations the new
esm-fixture-leak-small.mjsformula reproduces the old 100/500 MB thresholds exactly;isASANEnabledis exported frombun:internal-for-testingso the fixture-side detection resolves.
Extended reasoning...
Overview
Two commits: (1) replace the safe-but-unsound MarkedArrayBuffer::from_bytes(&mut [u8]) with from_owned_bytes(Box<[u8]>) in src/jsc/array_buffer.rs, migrate the six call sites (from_string, Terminal, SubprocessPipeReader, Readable, node_fs ×3), and neutralize the handle in destroy()/to_node_buffer() via mem::replace(.., ArrayBuffer::EMPTY); (2) give six leak tests instrumentation-aware clock budgets and runtime-based ASAN detection, with release-lane thresholds/iteration counts unchanged.
Security risks
None user-facing. The src change is a strict tightening of an internal Rust API — it removes a way for safe in-tree code to free a borrowed pointer. All callers were already passing Box::leak'd/heap::into_raw'd default-allocator boxes, so the bytes handed to JSC and the deallocators installed are identical. The handle-neutralization in destroy()/to_node_buffer() is defense-in-depth against double-release; no in-tree caller relied on the old post-handoff state.
Level of scrutiny
High. src/jsc/array_buffer.rs is exactly the allocator-ownership-across-FFI category REVIEW.md flags as most-blocked, and destroy()/Drop semantics changed. That said, the change is small, mechanical, and strictly narrows what safe code can do. I traced each migrated call site and the empty-box edge case; behavior is byte-identical.
Other factors
All three of my earlier inline comments are resolved in the current diff (destroy() now writes ArrayBuffer::EMPTY, .github/workflows/CLAUDE.md is no longer in the changed-files set, PR description reflects the doctest as source-level only). The leak-test commit was added at alii's explicit request for the x64-asan flakes; I checked that release lanes keep identical thresholds, budgets and iteration counts, and that isASANEnabled exists in bun:internal-for-testing (used by harness.isASAN). Given a maintainer is already driving this to merge, deferring for their final sign-off rather than auto-approving memory-safety FFI changes.
62afaf7 to
59b9e4c
Compare
|
Rebased onto current main (1336918) and force-pushed as 59b9e4c; still the two commits. The conflicts were in the two subprocess readers: main has since moved both to One caveat on verification: main does not build at this commit ( |
There was a problem hiding this comment.
LGTM — the constructor swap is type-system-enforced and the migrated callers produce identical allocations; the leak-test changes only widen clock budgets under instrumentation while keeping release thresholds and iteration counts unchanged.
What was reviewed:
from_owned_bytesreadsis_empty()before consuming the box, so the empty-sliceowns_buffer = falserule is preserved;destroy()andto_node_buffer()now neutralizeself.buffertoEMPTY(byte_len 0 → dangling-ptr slice is sound).- Grepped for remaining
MarkedArrayBuffer::from_bytes/Buffer::from_bytescallers: none outside the compile_fail doctest; the survivingArrayBuffer::from_bytessites are the separate #31970 hole this PR intentionally leaves. esm-fixture-leak-small.mjsthreshold arithmetic at 100k iterations resolves to the pre-PR 100 MB / 500 MB bounds;bunEnvis correctly spread when addingLEAK_ITERATIONS.isASANEnabledexists inbun:internal-for-testingand the fixture probe matchesharness.isASAN's shape with the exec-path fallback.
Extended reasoning...
Overview
Two logically independent pieces: (1) src/jsc/array_buffer.rs replaces the unsound safe constructor MarkedArrayBuffer::from_bytes(&mut [u8]) with from_owned_bytes(Box<[u8]>), routes from_string through it, neutralizes the source handle in destroy()/to_node_buffer(), and migrates the four remaining callers in Terminal.rs and node_fs.rs. (2) Six test files get instrumentation-aware clock budgets and runtime-based ASAN detection, requested by alii after the x64-asan lane flaked on pre-existing timeouts.
Security risks
None. This is a hardening: an unsound safe API that could free a caller-owned pointer is removed and replaced with one that takes ownership by type. No new attack surface, no user-facing behavior change; the migrated callers were already handing over Box<[u8]> allocations via heap::into_raw + reborrow.
Level of scrutiny
Memory-ownership code in bun_jsc is normally the highest-scrutiny category, but this diff removes an unsafe-in-practice safe fn rather than adding one. The new constructor delegates to the pre-existing ArrayBuffer::from_owned_bytes; the caller migrations are mechanical (drop the into_raw/leak + unsafe { &mut *raw } dance, pass the box directly) and produce byte-identical pointers/deallocators. The handle-neutralization in destroy() and to_node_buffer() was my own prior nit and is a one-line defense-in-depth. The test half only widens timeouts under isDebug || isASAN and switches ASAN detection from exec-path sniffing to the bun:internal-for-testing probe that harness.isASAN already uses; release builds keep identical iteration counts, thresholds, and budgets.
Other factors
All prior review threads (alii's early workflow comments, CodeRabbit's handle-neutralization and duplicate-test findings, my three nits about destroy() neutralization and the rust-test.yml/CLAUDE.md drift) are resolved in the current diff — the workflow and CLAUDE.md hunk are gone, destroy() sets self.buffer = ArrayBuffer::EMPTY, and the PR description accurately reflects that the doctest is a source-level guard. alii has been actively driving this toward merge and explicitly asked for the leak-test flake fix that became the second commit. The bug-hunting system found nothing this run. Existing runtime coverage (terminal, fs readFile/encoding/readlink, worker-terminate zero-length readFile) exercises every migrated call site under ASAN.
59b9e4c to
31863fa
Compare
|
The main-side build fix (the missing |
There was a problem hiding this comment.
LGTM — the earlier review notes (destroy() neutralization, the stale rust-test.yml doc reference) are addressed, and the remaining diff is a small type-system-enforced soundness fix plus flake-budget scaling.
What was reviewed:
from_owned_bytespreserves the empty-box rule (owns_buffer = !bytes.is_empty()evaluated before the move) anddestroy()/to_node_buffer()now both neutralizeself.buffer.- Caller migrations in
Terminal.rs/node_fs.rs(via theBuffer = MarkedArrayBufferalias) hand over the sameinto_boxed_slice()allocations as before, minus the leak-and-reborrowunsafe. - No remaining
MarkedArrayBuffer::from_bytescallers in-tree (the survivingArrayBuffer::from_bytessites are #31970, out of scope here); the doctest reference is the intendedcompile_fail. esm-fixture-leak-small.mjsthreshold formula reduces to the original 100/500 MB at 100k iterations, so release lanes are unchanged; the fixture ASAN probe matchesharness.detectASAN.
Extended reasoning...
Overview
The PR replaces the unsound MarkedArrayBuffer::from_bytes(&mut [u8], JSType) constructor with from_owned_bytes(Box<[u8]>, JSType), so the ownership transfer that every caller was already upholding by convention becomes a type-system invariant. It routes from_string through the new constructor, migrates the four remaining call sites (Terminal::on_read_chunk and three node_fs readFile paths via the Buffer alias), and neutralizes the source handle in the two release paths (destroy(), to_node_buffer()) via mem::replace(.., ArrayBuffer::EMPTY). A second commit gives six leak tests instrumentation-aware clock budgets and swaps the fixtures' executable-name ASAN sniff for the same runtime probe harness.isASAN uses.
Security risks
None introduced. This is a memory-soundness hardening: a safe constructor that could free a stack address is removed, and every migrated call site drops an unsafe block. No new unsafe, no new FFI surface, no user-input parsing.
Level of scrutiny
Moderate. src/jsc/array_buffer.rs is allocator/ownership code — the most-blocked category per REVIEW.md — but the change is subtractive: it deletes the borrowed-slice constructor and the leak-and-reborrow dance at call sites, producing byte-identical pointers/deallocators. I checked that owns_buffer is computed before bytes moves (empty-box case), that ArrayBuffer::from_owned_bytes (which this delegates to) does the same heap::into_raw the old callers did, that Buffer in node_fs.rs resolves to MarkedArrayBuffer, and that no in-tree caller of the removed method remains outside the compile_fail doctest. The leak-test changes preserve release-lane iteration counts and thresholds (the esm-fixture-leak-small.mjs formula collapses to the previous 100 MB / 500 MB at 100k) and only widen budgets under isDebug || isASAN.
Other factors
This PR has been through several review rounds with a maintainer actively directing the rebases and the flake fix; every prior inline comment (mine, alii's, CodeRabbit's) is marked resolved and reflected in the current diff. The workflow/CLAUDE.md artifacts I flagged on 2026-08-10 are gone from the changed-files list. The bug-hunting pass on the current head found nothing. Existing suites (terminal, fs readFile/encoding/readlink, worker-terminate) cover the migrated sites, and any reintroduced from_bytes caller fails every build.
31863fa to
08e77db
Compare
|
Rebased onto current main (0e395c2) and force-pushed as 08e77db; same two commits, same 9-file diff. One small conflict: main removed the Re-verified on the rebased tree under the ASAN debug build: compile_fail doctest, terminal (98/98), worker-terminate zero-length readFile, fs readFile/encoding/readlink, and both leak-test files all pass. |
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, node_fs x3) off the leak-and-reborrow pattern. The subprocess readers already hand their bytes to JSValue::create_buffer_from_box on main and need no change. destroy() 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.
08e77db to
850d94e
Compare
|
Rebased onto current main (a3e0ab6) and force-pushed as 850d94e; same two commits, same 9-file diff. The src commit applied with no conflicts this time. The one conflict was a single line in Re-verified on the rebased tree under the ASAN debug build: compile_fail doctest, terminal (98/98), worker-terminate zero-length readFile, fs readFile/encoding/readlink, and both leak-test files all pass. |
The reduced LEAK_ITERATIONS loop was applied to every instrumented build, but only ASAN needs it: its free quarantine and redzones put a floor of several hundred MB under RSS that swamps the ~1 KB per iteration signal this fixture looks for, at any iteration count that fits the lane's clock, so the ASAN run can only be a reduced pass that exercises the path under the sanitizer. Plain debug builds have no such floor, so they keep the full 100k loop and the 100 MB bound, and with them the leak detection, as release builds do. The fixture comment now says which lanes detect the leak instead of implying the ASAN bound does.
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 withowns_bufferset.destroy()then frees that pointer with the default allocator, and sinceMarkedArrayBuffergained aDropimpl (#37075) so does a plain drop. Safe code can free a stack buffer:All in-tree callers upheld the contract manually (
Box::leak/heap::into_rawa default-allocatorBox<[u8]>, then reborrow), so there is no user-reachable corruption today; this closes the API-boundary soundness hole.Fixes #31969
Fix
MarkedArrayBuffer::from_bytes(&mut [u8])withMarkedArrayBuffer::from_owned_bytes(Box<[u8]>, JSType), built on the existingArrayBuffer::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 separateArrayBuffer::from_byteshole ([Unsoundness] ArrayBuffer no-copy constructors trust raw backing pointers #31970) is deliberately not touched.from_stringthrough it and migrate the remaining callers off the leak-and-reborrow dance:Terminal.rsandnode_fs.rs(x3, via theBufferalias). The subprocess readers already hand their bytes toJSValue::create_buffer_from_boxon main and need no change.destroy()andto_node_buffer()take the buffer out ofself(leavingArrayBuffer::EMPTY) when they release it, so a laterdestroy()/Dropor 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_bytesfails to compile. The contract itself is pinned by acompile_fail,E0599doctest onfrom_owned_bytescontaining the snippet above, runnable withcargo test --doc -p bun_jsc(rustdoc checkscompile_faildoctests 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 safefrom_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.jsandrequire-cache.test.ts. Both reproduce with this PR's src changes removed, andinspect-error-leaktimed 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-iterationinspect()loop takes ~13s on the asan lane against a 10s budget; the 100k-iterationimport()fixture overruns 30s). The budgets now scale under ASAN or debug builds, theimport()fixture (which ran 10x more iterations than its CJS sibling) runs a reduced loop under ASAN only, where the quarantine floor swamps its RSS signal anyway, so non-ASAN builds keep the full loop and the leak detection while the ASAN run exercises the path under the sanitizer with a gross-blowup ceiling, and the four standalone fixtures now detect ASAN by asking the runtime (asharness.isASANdoes) instead of sniffing the executable name, which missed debug+ASAN builds. Release builds keep the same iteration counts, thresholds and budgets as before; leak detection lives on the non-ASAN lanes, the same splitinspect-error-leakalready used.Verification
On this diff rebuilt against current main, under the ASAN debug build:
cargo test --doc -p bun_jsc: the doctest passes, and fails withfrom_bytesreintroduced.test/js/web/workers/worker-terminate-lifetime.test.ts(the zero-length readFile drop path, which exercisesDrop->destroy()on an unconverted empty buffer): pass.test/js/bun/terminal/terminal.test.ts(98 pass),test/js/node/fs/fs.test.ts-t readFile(22),-t encoding(39, includesreaddirwithencoding: "buffer"),-t readlink(10): all pass.cargo fmt --allclean.History: replaces #31986; re-targeted onto current main after main independently picked up the
&mut selfhandoffs, theDropimpl, 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 inTerminal.rs, where main had added a landing-frame comment and switched the error arm todispatch::foldaround the same call this PR rewrites, resolved by keeping main's text with thefrom_owned_bytescall. Rebased once more onto c09b847: main removed the unusedMarkedArrayBuffer::to_js(#39420), so this PR's rewrite of it dropped out and the handle neutralization now covers the two remaining release paths; no other conflicts. Rebased again onto 1336918: main had moved the two subprocess reader sites toJSValue::create_buffer_from_box, so those hunks dropped out (main's version kept at both conflicts); the src side is nowarray_buffer.rs,Terminal.rsandnode_fs.rs. Main was briefly unbuildable at that commit (a missingbun_cssdependency edge, since fixed); the branch has been rebased onto the fixed main and the verification below was re-run against it with no workaround. Rebased once more onto 0e395c2: main removedMarkedArrayBuffer'spinnedfield, so the constructor no longer sets it; the only conflict, re-verified as below. Rebased again onto a3e0ab6: the src commit applied cleanly; the one conflict was inrequire-cache.test.ts, where main had independently bumped theimport()file-paths leak test to 60s under ASAN (#41186), which this PR'sleakTimeouthelper subsumes (wider instrumented budget plus the scaled fixture loop), so the helper was kept.no test proof · iteration 23 · 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