Skip to content

ffi: don't free caller-owned memory in toBuffer without a finalizer - #36521

Merged
Jarred-Sumner merged 5 commits into
mainfrom
farm/3c3869d3/ffi-tobuffer-no-finalizer-noop-dealloc
Jul 31, 2026
Merged

ffi: don't free caller-owned memory in toBuffer without a finalizer#36521
Jarred-Sumner merged 5 commits into
mainfrom
farm/3c3869d3/ffi-tobuffer-no-finalizer-noop-dealloc

Conversation

@robobun

@robobun robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Adopts #31753 by @EffortlessSteven. Fixes #35405. Fixes #24160. Closes #31753.

Repro

import { ptr, toBuffer } from "bun:ffi";
let original = Buffer.alloc(64, 0x41);
let adopted = toBuffer(ptr(original), 0, 64); // zero-copy view
adopted = null;
Bun.gc(true); // SIGSEGV / ASAN bad-free: original's storage was mi_free'd
panic(main thread): Segmentation fault at address 0x87D8

On Windows/macOS this reproduces with any dlopen'd symbol returning a malloc'd pointer (#35405), because mimalloc override is off there so mi_free walks a CRT/libc allocation. On Linux it reproduces via double-free/UAF when the pointer comes from ptr(Buffer).

Cause

to_buffer in src/runtime/ffi/FFIObject.rs falls back to JSValue::create_buffer(global_this, slice) when no finalizer is supplied. create_buffer hard-codes MarkedArrayBuffer_deallocator (i.e. mi_free), so collecting the returned Buffer frees storage it never owned.

toArrayBuffer already gets this right: it passes the caller's optional finalizer through and never frees on its own.

Fix

The no-finalizer path installs a no-op bytes deallocator, so the Buffer borrows the pointer and collecting it frees nothing. The zero-copy view is unchanged; an explicit finalizer still controls disposal and runs exactly once. JSBuffer__bufferFromPointerAndLengthAndDeinit asserts a non-null deallocator for non-empty storage, so a real no-op function is required rather than None.

Verification

New describe("toBuffer borrowed-pointer ownership ...") block in test/js/bun/ffi/ffi.test.js:

  • three subprocess tests (ptr(Buffer) at offset 0, interior offset, ptr(Uint8Array)): unpatched child crashes (empty stdout), patched prints survived-gc and the caller's bytes remain readable/writable after GC
  • regression guard: explicit finalizer via cc() is still called exactly once on GC with the buffer's own pointer

The primitives fixture test drops its getNoopDeallocatorCallback() workaround and now exercises the real no-finalizer path on static native storage (also red on unpatched builds).

Fail-before (3 fail under both release and ASAN), pass-after (4/4 under ASAN). cargo clippy -p bun_runtime clean.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/ffi/ffi.test.js

`bun:ffi.toBuffer(ptr, offset, len)` without an explicit finalizer fell into
`JSValue::create_buffer`, which hard-codes `MarkedArrayBuffer_deallocator`.
For a borrowed pointer (from `ptr(buffer)` or a dlopen'd symbol) that
`mi_free`s storage this Buffer does not own when it is collected: an ASAN
bad-free, and a SIGSEGV on release builds (reliably on Windows/macOS where
mimalloc override is off).

Install a no-op deallocator on the no-finalizer path so the Buffer borrows the
pointer instead of freeing it on GC, matching `toArrayBuffer`'s existing
behavior. The zero-copy view is preserved; an explicit finalizer still controls
disposal and runs exactly once.

Tests cover the bad-free (offset 0, interior offset, typed-array source) via
subprocess, red on the unpatched build and asserting the caller's memory stays
valid, plus a regression that an explicit finalizer is still called exactly
once on GC. The `primitives` fixture test drops its no-op deallocator
workaround and now exercises the real default path on static native storage.

Fixes #35405
Closes #31753

Co-authored-by: Steven Zimmerman <15812269+EffortlessSteven@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cfebee4e-930d-4e89-8611-3a84d38dc056

📥 Commits

Reviewing files that changed from the base of the PR and between aa056c9 and 032d4b9.

📒 Files selected for processing (1)
  • test/js/bun/ffi/ffi.test.js

Walkthrough

Changes

FFI borrowed-buffer ownership

Layer / File(s) Summary
Borrowed buffer ownership
src/runtime/ffi/FFIObject.rs
to_buffer now uses a no-op deallocator when no finalizer is supplied. Explicit finalizers remain supported. Documentation describes caller-owned memory and disposal behavior.
Ownership regression coverage
test/js/bun/ffi/ffi-test.c, test/js/bun/ffi/ffi.test.js
Tests remove the native no-op workaround and verify borrowed pointer views survive GC. Explicit finalizers are verified to run exactly once.

Possibly related PRs

  • oven-sh/bun#36090: Both changes cover external buffer finalization and deallocator timing, but use different APIs.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation and tests address the linked issues by preventing borrowed-memory frees while preserving explicit finalizer behavior.
Out of Scope Changes check ✅ Passed The changes are limited to the FFI deallocator fix and its focused regression tests and fixture cleanup.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing toBuffer from freeing caller-owned memory without a finalizer.
Description check ✅ Passed The description explains the problem, cause, fix, linked issues, and verification results, although it uses custom headings instead of the template headings.

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

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced via test/js/bun/ffi/ffi.test.js -t 'borrowed-pointer ownership': 3 subprocess tests fail (child SIGSEGV/ASAN bad-free) on the unfixed build, 4/4 pass with the fix. Adopts #31753.

All review threads addressed. ffi.test.js is green on CI (build 86110). The only non-flaky CI failure is test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts hitting ASSERTION FAILED: !exception() in JSC::ExceptionScope on debian x64-asan, which this diff does not touch (FFI only) and which passes locally with this build. Ready for a maintainer.

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. [Unsoundness] MarkedArrayBuffer::from_bytes frees caller-owned slices #31969 - Directly describes the same root cause: MarkedArrayBuffer::from_bytes frees caller-owned slices via its destructor, which this PR fixes by installing a no-op deallocator
  2. Crash with call fullscreen on GLFW. #24160 - User calls toBuffer on GLFW-owned FFI pointer; crash trace shows GCIncomingRefCountedSet<ArrayBuffer>::sweep freeing via mimalloc (mi_slice_first), exactly the pattern this PR fixes

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

Fixes #31969
Fixes #24160

🤖 Generated with Claude Code

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Re the issue-finder suggestions:

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. ffi: don't free caller-owned memory in toBuffer without a finalizer #31753 - Same fix: toBuffer without a finalizer installs a no-op deallocator instead of mi_free to avoid freeing caller-owned memory. ffi: don't free caller-owned memory in toBuffer without a finalizer #36521 explicitly supersedes this PR.
  2. fix(ffi): multiple long-standing FFI correctness bugs #31449 - Broader FFI correctness PR that includes the same toBuffer deallocator fix among other changes (see "Crash / DoS fixes" section: toBuffer(ptr(typedArray)) double-free/allocator mismatch).

🤖 Generated with Claude Code

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Re the duplicate-finder suggestions:

Comment thread test/js/bun/ffi/ffi.test.js Outdated
Comment thread test/js/bun/ffi/ffi.test.js Outdated
…r the finalizer guard

The subprocess tests now assert the full {stdout, stderr, exitCode} object so a
failure diff carries the child's crash output instead of a bare empty-string
mismatch, matching the rest of the file.

The explicit-finalizer regression guard switches from cc() (TinyCC) to the
compiled ffi-test fixture's getDeallocatorCallback/getDeallocatorBuffer/
getDeallocatorCalledCount helpers. Every in-process cc() invocation in the
repo is gated on isASAN, and the debian x64-asan lane reported a
generate_symbols leak from this one. The fixture-based test runs under ASAN
and the helpers already reset the counter, so no isolation is lost.

Co-authored-by: Steven Zimmerman <15812269+EffortlessSteven@users.noreply.github.com>
Comment thread src/runtime/ffi/FFIObject.rs Outdated
Comment thread src/runtime/ffi/FFIObject.rs Outdated
Comment thread src/runtime/ffi/FFIObject.rs Outdated
Comment thread src/runtime/ffi/FFIObject.rs Outdated
Co-authored-by: Steven Zimmerman <15812269+EffortlessSteven@users.noreply.github.com>
Comment thread src/runtime/ffi/FFIObject.rs
Comment thread src/runtime/ffi/FFIObject.rs
Comment thread src/runtime/ffi/FFIObject.rs
- make the three independent subprocess tests `it.concurrent`
- the explicit-finalizer GC poll is now async with a yield between collections
  and a 100-iteration ceiling, matching the repo's gcUntil shape
- drop the incorrect 'wedges in the crash handler' parenthetical; the unpatched
  child exits non-zero promptly (139 release / ASAN abort), so the toEqual
  mismatch is the red signal

Co-authored-by: Steven Zimmerman <15812269+EffortlessSteven@users.noreply.github.com>
@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:05 AM PT - Jul 31st, 2026

@robobun, your commit 032d4b9 is building: #86110

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

🤖 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/runtime/ffi/FFIObject.rs`:
- Around line 739-748: Update the toBuffer API documentation associated with
JSBuffer__bufferFromPointerAndLengthAndDeinit to specify that omitting the
finalizer borrows caller-owned memory while a supplied finalizer releases it,
and explicitly document that zero-length inputs invoke the non-null finalizer
immediately. Also document that toArrayBuffer without a callback does not free
the supplied bytes.

In `@test/js/bun/ffi/ffi.test.js`:
- Around line 1375-1377: Measure the four GC subprocess tests under the debug
build, following test/CLAUDE.md. Remove GC_TIMEOUT and use the default test
timeout if the measured runtime fits; otherwise reduce the 20-iteration gcLoop
work until it does, without retaining a per-test timeout.
- Around line 1379-1385: Trim the paragraph-length comments in the affected test
blocks, including the block near the post-condition checks and the corresponding
comments near lines 1357 and 1452. Retain only the durable, non-obvious fact and
the issue URL; remove narrative explanations already conveyed by the assertion
messages.
- Around line 1436-1450: Update the test case around toBuffer and
originalSurvives to assert that adopted aliases the caller-owned typed array
before the GC survival check. Add assertions that verify the shared memory and
expected byte values, while preserving the existing “survived-gc” assertion and
cleanup behavior.
🪄 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: 2e3f9873-c0d4-46b8-a2e3-58bcc9795575

📥 Commits

Reviewing files that changed from the base of the PR and between 529adec and aa056c9.

📒 Files selected for processing (3)
  • src/runtime/ffi/FFIObject.rs
  • test/js/bun/ffi/ffi-test.c
  • test/js/bun/ffi/ffi.test.js
💤 Files with no reviewable changes (1)
  • test/js/bun/ffi/ffi-test.c

Comment thread src/runtime/ffi/FFIObject.rs
Comment thread test/js/bun/ffi/ffi.test.js Outdated
Comment thread test/js/bun/ffi/ffi.test.js Outdated
Comment thread test/js/bun/ffi/ffi.test.js Outdated
… in the typed-array case

Measured debug+ASAN runtime is ~1s per subprocess test (concurrent), well under
the default budget.

Co-authored-by: Steven Zimmerman <15812269+EffortlessSteven@users.noreply.github.com>

@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 didn't find issues with the latest revision — the earlier review nits (ASAN/TinyCC guard, combined-object assertions, comment length, aliasing check, per-test timeout) are all addressed at 032d4b9. Deferring to a human because this changes memory-ownership semantics on a public bun:ffi path; the fix looks right, but that's the repo's most-scrutinized category.

What was reviewed:

  • callback.or(Some(noop_bytes_deallocator)) preserves the explicit-finalizer path and only substitutes on None; ctx handling is unchanged.
  • Confirmed JSBuffer__bufferFromPointerAndLengthAndDeinit asserts a non-null deallocator for len > 0 (JSBuffer.cpp:392), so a real no-op fn is required — None would trip the debug assert.
  • Behavior now matches the sibling to_array_buffer, which already borrows without freeing when no finalizer is given.
  • Removing getNoopDeallocatorCallback from the fixture is safe — its only consumer was the primitives test, which now exercises the real no-finalizer path.
Extended reasoning...

Overview

The PR changes to_buffer in src/runtime/ffi/FFIObject.rs so that when toBuffer(ptr, off, len) is called without a finalizer, the returned Buffer installs a no-op bytes deallocator instead of falling through to JSValue::create_buffer (which hard-codes MarkedArrayBuffer_deallocatormi_free). The old path freed caller-owned FFI memory on GC, causing SIGSEGV/ASAN bad-free (#35405, #24160). The change is ~10 effective Rust lines plus a new no-op extern "C" fn; the rest is test coverage (3 subprocess crash-repro tests, 1 explicit-finalizer regression guard) and removing the fixture's now-unneeded getNoopDeallocatorCallback workaround.

Security risks

None introduced. FFI is inherently trust-the-caller; this change removes a case where Bun freed memory it didn't own. The only conceivable regression is a leak for a caller who was (undocumented, Linux-only) relying on the auto-mi_free — but that same call crashes on Windows/macOS today, and toArrayBuffer already borrows, so borrow is the correct contract.

Level of scrutiny

High. This is native memory-ownership code in a public API surface. Per the repo's review guide, memory safety is the most-blocked category and "every allocation has exactly one named owner, released exactly once" is the governing rule. The fix is small and I believe correct — .or(Some(noop)) is the minimal delta, the explicit-finalizer path is provably unchanged (Some(cb).or(...) == Some(cb)), and the C++ side's ASSERT(bytesDeallocator) at JSBuffer.cpp:392 confirms a real fn pointer is required rather than None. But an ownership-semantics change on bun:ffi should get a maintainer's eyes.

Other factors

All prior review feedback has been addressed at 032d4b9: my two inline nits (TinyCC/ASAN, combined-object subprocess assertions), the comment-cop paragraph-comment flags, and CodeRabbit's aliasing-assertion / comment-trim / GC_TIMEOUT remarks. The finalizer regression guard now uses the compiled fixture via dlopen (no in-process TinyCC), and the three subprocess tests are it.concurrent. This PR also overlaps with the larger omnibus #31449, which the author has already noted; a maintainer may want to coordinate landing order.

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

My earlier feedback (TinyCC/ASAN gate, combined-object assertions) has been addressed, and I found nothing new to flag. Deferring to a human since this changes memory-ownership semantics of a public FFI API in native code — small and well-tested, but the kind of change that should get a maintainer sign-off.

What was reviewed:

  • Confirmed JSBuffer__bufferFromPointerAndLengthAndDeinit does ASSERT(bytesDeallocator) for length > 0 (JSBuffer.cpp:392), so the real no-op fn is required rather than None.
  • Verified getNoopDeallocatorCallback has no remaining references after removal from the fixture.
  • Traced arg parsing in to_buffer: ctx is only ever set when callback is also set, so the removed || ctx.is_some() branch was dead and collapsing to a single create_buffer_with_ctx call is behavior-preserving for the with-finalizer path.
  • The one CI failure (worker-transfer-terminate-stress.test.ts SIGABRT on x64-asan) is unrelated to FFI.
Extended reasoning...

Overview

Three files touched: src/runtime/ffi/FFIObject.rs (the fix — ~10 production lines), test/js/bun/ffi/ffi-test.c (removes the now-unnecessary getNoopDeallocatorCallback workaround), and test/js/bun/ffi/ffi.test.js (four new tests plus dropping the workaround from the existing primitives test). The production change replaces the no-finalizer fallback JSValue::create_buffer(global_this, slice) — which hard-codes MarkedArrayBuffer_deallocator / mi_free — with create_buffer_with_ctx(..., callback.or(Some(noop_bytes_deallocator))), so a toBuffer view without a finalizer borrows caller-owned memory instead of freeing it on GC.

Security risks

None in the traditional sense. bun:ffi is an inherently trusted API surface (raw pointers from JS). This fixes a use-after-free / bad-free that crashes user programs; it does not add any new attack surface. The only theoretical downside is that a caller who was (incorrectly) relying on Linux's mimalloc-override to free their pointer via the old bug now leaks — but that behavior was never documented, never worked on Windows/macOS, and doesn't match the sibling toArrayBuffer.

Level of scrutiny

Medium-high. The diff is tiny and mechanical, but it changes ownership semantics of a public API (toBuffer without a finalizer: was "free on GC", now "borrow"). It touches native memory-lifetime code, which REVIEW.md flags as the most-blocked category. I verified the C++ assertion the PR description cites, confirmed the removed ctx.is_some() branch was unreachable, and checked that the explicit-finalizer path is unchanged (callback.or(Some(noop)) preserves any user-supplied callback). The fix mirrors what to_array_buffer already does in the same file.

Other factors

All prior review feedback is resolved: my two inline comments (TinyCC-under-ASAN, combined-object subprocess assertions) were addressed in a475c24; the comment-cop paragraph-comment flags and CodeRabbit's three nits (per-test timeout, comment length, typed-array aliasing assertion) were addressed in aa056c9/032d4b9. Test coverage is solid — three subprocess crash-repro variants plus an explicit-finalizer regression guard using the compiled fixture. The one CI failure so far is in an unrelated worker_threads stress test. Given the memory-ownership semantics change and REVIEW.md's emphasis on native lifetime code, I'm deferring rather than approving so a maintainer can confirm the intended contract.

@Jarred-Sumner
Jarred-Sumner merged commit 45ccba4 into main Jul 31, 2026
52 of 54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/3c3869d3/ffi-tobuffer-no-finalizer-noop-dealloc branch July 31, 2026 08:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bun:ffi toBuffer() on a dlopen-returned pointer segfaults during GC on Windows x64 Crash with call fullscreen on GLFW.

2 participants