Skip to content

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

Closed
EffortlessSteven wants to merge 3 commits into
oven-sh:mainfrom
EffortlessSteven:claude/ffi-tobuffer-owned-pointer
Closed

ffi: don't free caller-owned memory in toBuffer without a finalizer#31753
EffortlessSteven wants to merge 3 commits into
oven-sh:mainfrom
EffortlessSteven:claude/ffi-tobuffer-owned-pointer

Conversation

@EffortlessSteven

@EffortlessSteven EffortlessSteven commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

What this does

bun:ffi's toBuffer(ptr, offset, len) without a finalizer installed Bun's allocator deallocator on a pointer Bun does not own. Collecting the Buffer then frees storage owned by someone else, a malloc from a dlopen'd library or another Buffer's live backing store, and segfaults.

let original = Buffer.alloc(64, 0x41);
let adopted = toBuffer(ptr(original), 0, 64); // zero-copy view
adopted = null;
Bun.gc(true); // panic: Segmentation fault, original's storage was freed

Fix: the no-finalizer path installs a no-op 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 once.

Fixes #35405

Verification

Red on main, green after: unpatched segfaults during GC, patched exits 0 with the caller's memory intact. #35405's DLL-allocation repro is platform-sensitive, but the same ownership bug reproduces deterministically on Linux with Buffer-backed and static storage once GC is forced, so the tests are not platform-gated.

Check Result
ptr(Buffer), interior offset, ptr(Uint8Array) unpatched SIGSEGV, patched reads/writes the original after GC
explicit finalizer, self-compiled via cc() called exactly once, with the buffer's own pointer
compiled FFI fixture (primitives) default no-finalizer path survives GC, static native storage still readable
cargo clippy -p bun_runtime, prettier clean
bun bd test test/js/bun/ffi/ffi.test.js ownership suite 4/4; remaining failures reproduce on a current-main baseline

Review map

  • src/runtime/ffi/FFIObject.rs: no-finalizer to_buffer uses an internal no-op deallocator instead of the JSValue::create_buffer fallback, which hard-codes MarkedArrayBuffer_deallocator.
  • test/js/bun/ffi/ffi.test.js: subprocess regressions for the three borrowed-pointer shapes, a finalizer-runs-once guard, and the compiled primitives fixture now exercising toBuffer/toArrayBuffer on the default path instead of passing a workaround no-op.
  • test/js/bun/ffi/ffi-test.c: drops the exported no-op deallocator that workaround needed; it has no other users.

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR fixes a memory safety bug in Bun's FFI layer where toBuffer would incorrectly adopt caller-owned memory, causing ASAN bad-free crashes on garbage collection. A new noop_bytes_deallocator prevents JSC from freeing foreign memory when no explicit finalizer is provided, while preserving existing behavior when a deallocator callback is supplied. Comprehensive regression tests validate the fix across multiple scenarios.

Changes

FFI toBuffer Borrowed Pointer Ownership

Layer / File(s) Summary
Noop deallocator and toBuffer ownership logic
src/runtime/ffi/FFIObject.rs
Introduces noop_bytes_deallocator extern function and updates to_buffer to always use create_buffer_with_ctx, supplying either the caller's callback or the noop deallocator. When no explicit finalizer is provided, the buffer becomes a non-owning view that prevents JSC from freeing foreign memory.
Regression tests for ownership behavior
test/js/bun/ffi/ffi.test.js
Consolidates harness imports and adds a subprocess-based regression test suite validating that caller memory survives GC cycles when toBuffer is used without an explicit finalizer (plain pointers, interior pointers, and typed arrays). Includes a separate test confirming explicit finalizers are called exactly once on GC.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title concisely states the main fix: toBuffer no longer frees caller-owned memory when no finalizer is provided.
Description check ✅ Passed The description covers the change and verification clearly, though it uses different headings than the template.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Confirming this also fixes an intermittent segfault on Windows (both x64 and arm64) when toBuffer() wraps a pointer that was malloc()ed by a foreign DLL and a GC runs.

On Linux, mimalloc is built with MI_MALLOC_OVERRIDE=true, so a shared library's malloc() is mimalloc and MarkedArrayBuffer_deallocator (which calls mi_free) happens to work on a single adopted pointer. On Windows and macOS the override is disabled, so the DLL's malloc() is the CRT / libc allocator and mi_free walks garbage page metadata.

import { dlopen, toBuffer } from "bun:ffi";
// any DLL that returns a malloc()'d pointer; test/js/bun/ffi/ffi-test.c built with clang -shared
const { symbols } = dlopen("bun-ffi-test.dll", {
  ptr_should_point_to_42_as_int32_t: { returns: "ptr", args: [] },
});
toBuffer(symbols.ptr_should_point_to_42_as_int32_t(), 0, 4);
Bun.gc(true);   // segfault ~50% of runs on Windows arm64/x64 canary 50bb3bd8e

Measured on canary 50bb3bd8e:

variant windows-aarch64 windows-x64
toBuffer(ptr, 0, 4) (no finalizer) 18/30 crash 13/30 crash
toBuffer(ptr, 0, 4, noopDeallocator) 0/30 n/a
toArrayBuffer(ptr, 0, 4) 0/30 n/a
toBuffer(ptr(Buffer.alloc(64)), 0, 64) then drop + GC twice 30/30 crash n/a

The toBuffer(..., noopDeallocator) row is exactly what this PR does internally for the no-finalizer path, so this PR fixes the Windows crash as well.

This is also why test/js/bun/ffi/ffi.test.js "run ffi > primitives" (which calls toBuffer(cptr, 0, 4) then Bun.gc(true)) would flake on Windows when the test DLL is present; it's invisible in CI only because that describe is skipped when /tmp/bun-ffi-test.${suffix} doesn't exist.

The src/runtime/ffi/FFIObject.rs hunk still applies cleanly with a 3-way merge against current main; only the test-file imports conflict.

Jarred-Sumner added a commit that referenced this pull request Jul 25, 2026
…test.js

Review fixes:

- Engine failures were silently swallowed: take_exception() returned the
  raw JSC::Exception wrapper cell, which the glue's Error.isError() does
  not recognize, so a thrown TypeError (invalid signature) was treated as a
  successful symbol / a JSCallback with ptr === undefined. Use take_error()
  (unwraps to the ErrorInstance) at all three sites so the engine's error
  propagates, matching the TinyCC failure paths.
- No fallback when the JIT is unavailable: with BUN_JSC_useJIT=0 or in a
  JIT-less environment the engine's create() throws "bun:ffi requires the
  JIT" and every dlopen failed. jsc_ffi_enabled() now queries the engine's
  own FFI::isAvailable() (via Bun__JSCFFIIsAvailable, cached per process)
  and routes to TinyCC when the engine machinery can't run.
- test/harness.ts: add compileFixture(sourcePath), which builds a C fixture
  into a shared library with the host `cc` at test time (present on every
  CI test host), and gracefully skips fixture-dependent tests on a
  compiler-less machine instead of failing the file.
- ffi.test.js was permanently skipped: its `make compile-ffi-test`
  prerequisite no longer exists and it hardcoded a .dylib path. Compile the
  fixture in-test so the suite runs on every platform. Neutralize the one
  pre-existing crash (toBuffer(cptr,0,4) frees a malloc'd pointer with
  mi_free on GC; #35405, real fix in #31753) by keeping that
  Buffer reachable, and mark the threadsafe i64/u64 callback cases todo
  (unrooted BigInt in the deferred task; #35406) -- both pre-existing on
  main, both reproduce on the stock canary, neither related to the backend.
  Result: 101 pass / 0 fail (previously never ran); the TinyCC kill-switch
  path still fails uint32_t identity at 2^32-1 (#7007 / #35407), which the
  engine path fixes.
- Fix a stale doc comment on create_jsc_ffi_function and the .native
  comment in ffi.ts.
`bun:ffi.toBuffer(ptr, offset, len)` without an explicit finalizer fell into the
owned-memory path (`JSValue::create_buffer`), which hard-codes
`MarkedArrayBuffer_deallocator`. For a borrowed pointer (e.g. from `ptr(buffer)`)
that `mi_free`s memory Bun never allocated when the Buffer is collected: an ASAN
bad-free, and a SIGSEGV on release builds.

Install a no-op deallocator on the no-finalizer path so the Buffer borrows the
pointer instead of freeing it on GC, mirroring `toArrayBuffer` (which already
passes its possibly-absent finalizer through). The zero-copy view is preserved;
an explicit finalizer still takes ownership and runs exactly once.

Tests cover the bad-free (offset 0, interior offset, typed-array source), red on
system Bun / green patched and asserting the original caller memory stays valid,
plus a regression that an explicit finalizer is still called exactly once on GC
(self-compiled via cc() so it runs without an external fixture dylib).
@EffortlessSteven
EffortlessSteven force-pushed the claude/ffi-tobuffer-owned-pointer branch from d08f557 to 79f8000 Compare July 29, 2026 23:24
The FFI runner's `primitives` test passed `getNoopDeallocatorCallback()` to
`toBuffer`/`toArrayBuffer` so it would not hit the no-finalizer path, which
adopted the caller's pointer as owned and freed it on GC. That path is fixed, so
the workaround only hides the coverage: drop the callback and let the compiled
fixture exercise the real default, wrapping static native storage, dropping the
Buffer, forcing GC, then reading the pointer again.

`getNoopDeallocatorCallback` has no other users, so remove it from the symbol
descriptor, the destructuring, and the fixture. The real deallocator-counter
helpers stay.

Without the toBuffer fix this is red: the fixture segfaults the test runner
(`panic(main thread): Segmentation fault`), which is what oven-sh#35405
reports and why `run ffi > primitives` flaked on Windows.
Comment-only. The comments around the `toBuffer` fix still described the
no-finalizer path as transferring ownership to JSC and as freeing "memory Bun
never allocated". Neither is accurate: `ptr(Buffer.alloc(...))` points at storage
Bun did allocate but this Buffer does not own, and a caller-supplied finalizer
controls disposal rather than taking ownership (it may free, return to an arena,
drop a refcount, or do nothing). `create_buffer_with_ctx` also no longer receives
a "possibly null" deallocator from `to_buffer`: it gets either the caller's
finalizer or the internal no-op.

In the tests, use "caller-owned" instead of "foreign" (a Buffer's backing store
is not foreign to Bun), and correct the claim that dropping `original` makes an
unpatched build hit "the same pointer a second time" — for the interior-offset
case the invalidly adopted pointer is `base + 8` while the owner later disposes
`base`; it is the same ownership path, not the same pointer.
@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Carried forward as #36521 (same diff, rebased on current main, fail-before/pass-after verified under ASAN). Co-author credit preserved in the commit. Thanks for the fix.

Jarred-Sumner pushed a commit that referenced this pull request Jul 31, 2026
…36521)

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

## Repro

```js
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.

<!-- robobun:evidence:begin -->

---

**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

<!-- robobun:evidence:end -->

---------

Co-authored-by: Steven Zimmerman <15812269+EffortlessSteven@users.noreply.github.com>
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.

bun:ffi toBuffer() on a dlopen-returned pointer segfaults during GC on Windows x64

2 participants