Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 18 additions & 26 deletions src/runtime/ffi/FFIObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ unsafe fn deallocator_from_addr(addr: usize) -> jsc::JSTypedArrayBytesDeallocato
unsafe { core::mem::transmute::<usize, jsc::JSTypedArrayBytesDeallocator>(addr) }
}

/// Frees nothing. `JSBuffer__bufferFromPointerAndLengthAndDeinit` asserts a non-null
/// deallocator for `len > 0`, so a borrowed view supplies this instead of `None`.
Comment thread
robobun marked this conversation as resolved.
unsafe extern "C" fn noop_bytes_deallocator(_ptr: *mut c_void, _ctx: *mut c_void) {}

/// Unlike `JSValue::create_buffer` (which hard-codes `MarkedArrayBuffer_deallocator`),
/// this variant passes the caller's (possibly null) deallocator through, so FFI-owned
/// memory is only freed by the user-supplied callback.
/// this passes the caller's deallocator through so FFI bytes are only freed when asked.
#[allow(non_snake_case)]
#[inline]
fn create_buffer_with_ctx(
Expand All @@ -43,8 +46,8 @@ fn create_buffer_with_ctx(
deallocator: jsc::JSTypedArrayBytesDeallocator,
) -> JSValue;
}
// SAFETY: `global` is live; slice describes FFI-owned memory whose
// ownership transfers to JSC (freed via `callback`, or never if None).
// SAFETY: `global` is live; `slice` stays valid for the Buffer's lifetime.
// `callback` controls disposal (a no-op when the storage stays caller-owned).
unsafe {
JSBuffer__bufferFromPointerAndLengthAndDeinit(
global,
Expand Down Expand Up @@ -502,15 +505,8 @@ fn ptr_(global_this: &JSGlobalObject, value: JSValue, byte_offset: Option<JSValu
JSValue::from_ptr_address(addr)
}

/// `union(enum)` → Rust enum.
/// `Slice` carries a raw (ptr, len) because it points at caller-owned FFI memory
/// of unknown lifetime.
// Consumer audit: `new_cstring` copies the bytes into a JS string;
// `to_array_buffer` wraps the pointer with the caller's optional finalizer and
// never frees it from Rust; `to_buffer` does the same when a finalizer is
// given, but WITHOUT one it falls back to `JSValue::create_buffer`, which
// installs `MarkedArrayBuffer_deallocator` and `mi_free`s the caller-owned
// slice on GC — free-foreign-memory footgun, see PR #31753.
/// `Slice` carries a raw (ptr, len) pointing at caller-owned FFI memory; consumers
/// borrow it (or delegate disposal to a supplied finalizer), never free it from Rust.
Comment thread
robobun marked this conversation as resolved.
enum ValueOrError {
Err(JSValue),
Slice(*mut u8, usize),
Expand Down Expand Up @@ -740,20 +736,16 @@ fn to_buffer(
}
}

// SAFETY: ptr/len came from get_ptr_slice; FFI-owned memory.
// SAFETY: ptr/len came from get_ptr_slice; caller-owned FFI memory.
let slice = unsafe { core::slice::from_raw_parts_mut(ptr, len) };
if callback.is_some() || ctx.is_some() {
return Ok(create_buffer_with_ctx(
global_this,
slice,
ctx.unwrap_or(core::ptr::null_mut()),
callback,
));
}

// `JSValue::create_buffer` installs `MarkedArrayBuffer_deallocator` so
// the slice is `mi_free`d on GC (including the free-foreign-memory footgun).
Ok(JSValue::create_buffer(global_this, slice))
// No finalizer means borrow: the noop deallocator keeps GC from freeing
// caller-owned storage (oven-sh/bun#35405).
Comment thread
robobun marked this conversation as resolved.
Ok(create_buffer_with_ctx(
global_this,
slice,
ctx.unwrap_or(core::ptr::null_mut()),
callback.or(Some(noop_bytes_deallocator)),
))
Comment thread
robobun marked this conversation as resolved.
}
}
}
Expand Down
4 changes: 0 additions & 4 deletions test/js/bun/ffi/ffi-test.c
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,10 @@ uint32_t add_uint32_t(uint32_t a, uint32_t b) { return a + b; }
uint64_t add_uint64_t(uint64_t a, uint64_t b) { return a + b; }

FFI_EXPORT void *ptr_should_point_to_42_as_int32_t();
FFI_EXPORT void *getNoopDeallocatorCallback();

static int32_t ffi_static_42 = 42;
void *ptr_should_point_to_42_as_int32_t() { return &ffi_static_42; }

static void noop_deallocator(void *ptr, void *ctx) { (void)ptr; (void)ctx; }
void *getNoopDeallocatorCallback() { return &noop_deallocator; }

static uint8_t buffer_with_deallocator[128];
static int deallocatorCalled;
FFI_EXPORT void deallocator(void *ptr, void *userData) { deallocatorCalled++; }
Expand Down
116 changes: 108 additions & 8 deletions test/js/bun/ffi/ffi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,6 @@ function getTypes(fast) {
returns: "ptr",
args: [],
},
getNoopDeallocatorCallback: {
returns: "ptr",
args: [],
},
getDeallocatorBuffer: {
returns: "ptr",
args: [],
Expand Down Expand Up @@ -382,7 +378,6 @@ function ffiRunner(fast) {
is_null,
does_pointer_equal_42_as_int32_t,
ptr_should_point_to_42_as_int32_t,
getNoopDeallocatorCallback,
cb_identity_true,
cb_identity_false,
cb_identity_42_char,
Expand Down Expand Up @@ -493,11 +488,12 @@ function ffiRunner(fast) {
expect(cptr != 0).toBe(true);
expect(typeof cptr === "number").toBe(true);
expect(does_pointer_equal_42_as_int32_t(cptr)).toBe(true);
const noopDeallocator = getNoopDeallocatorCallback();
{
const buffer = toBuffer(cptr, 0, 4, noopDeallocator);
// No finalizer: both views borrow `cptr` (static storage in the fixture),
// so the GC below must not free it. See oven-sh/bun#35405.
const buffer = toBuffer(cptr, 0, 4);
expect(buffer.readInt32(0)).toBe(42);
expect(new DataView(toArrayBuffer(cptr, 0, 4, noopDeallocator), 0, 4).getInt32(0, true)).toBe(42);
expect(new DataView(toArrayBuffer(cptr, 0, 4), 0, 4).getInt32(0, true)).toBe(42);
expect(ptr(buffer)).toBe(cptr);
}
Bun.gc(true);
Expand Down Expand Up @@ -1358,6 +1354,110 @@ describe.if(!!libPath)("can open more than 63 symbols via", () => {
}
});

// oven-sh/bun#35405: toBuffer without a finalizer used to mi_free caller-owned
// memory on GC. Subprocess because unpatched builds crash.
describe("toBuffer borrowed-pointer ownership (no bad-free on GC)", () => {
async function runsClean(script) {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}

const gcLoop = `for (let i = 0; i < 20; i++) { Bun.gc(true); Buffer.alloc(1024 * 1024); }`;

// Drops the borrowed view first, then the owner, so an invalid free shows up as
// corrupted caller memory rather than only as a crash.
const originalSurvives = (index, expected) => `
adopted = null;
${gcLoop}
if (original[${index}] !== ${expected}) throw new Error("caller memory corrupted after adopted GC: " + original[${index}]);
original[${index}] = 0x55;
if (original[${index}] !== 0x55) throw new Error("caller memory not writable after adopted GC");
original = null;
${gcLoop}
`;

it.concurrent("toBuffer(ptr(buffer)) does not free caller-owned memory on GC", async () => {
expect(
await runsClean(`
import { ptr, toBuffer } from "bun:ffi";
let original = Buffer.alloc(64, 0x41);
let adopted = toBuffer(ptr(original), 0, 64);
if (adopted[0] !== 0x41) throw new Error("expected a zero-copy view");
adopted[0] = 0x42;
if (original[0] !== 0x42) throw new Error("expected an aliasing view");
${originalSurvives(0, "0x42")}
console.log("survived-gc");
`),
).toEqual({ stdout: "survived-gc\n", stderr: "", exitCode: 0 });
});

it.concurrent("toBuffer(ptr(buffer), offset) does not free an interior pointer on GC", async () => {
expect(
await runsClean(`
import { ptr, toBuffer } from "bun:ffi";
let original = Buffer.alloc(64, 0x41);
let adopted = toBuffer(ptr(original), 8, 48);
if (adopted[0] !== 0x41) throw new Error("expected a zero-copy view");
adopted[0] = 0x42;
if (original[8] !== 0x42) throw new Error("expected a view aliasing original[8]");
${originalSurvives(8, "0x42")}
console.log("survived-gc");
`),
).toEqual({ stdout: "survived-gc\n", stderr: "", exitCode: 0 });
});

it.concurrent("toBuffer(ptr(typedArray)) does not free caller-owned memory on GC", async () => {
expect(
await runsClean(`
import { ptr, toBuffer } from "bun:ffi";
let original = new Uint8Array(64).fill(0x41);
let adopted = toBuffer(ptr(original), 0, 64);
if (adopted[0] !== 0x41) throw new Error("expected a zero-copy view");
adopted[0] = 0x42;
if (original[0] !== 0x42) throw new Error("expected an aliasing view");
${originalSurvives(0, "0x42")}
console.log("survived-gc");
`),
).toEqual({ stdout: "survived-gc\n", stderr: "", exitCode: 0 });
});

// Regression guard: an explicit finalizer still controls disposal and runs exactly
// once on GC. getDeallocatorBuffer/getDeallocatorCallback each reset the counter.
it.skipIf(!FFI_FIXTURE_PATH)(
"toBuffer with an explicit finalizer calls the deallocator exactly once on GC",
async () => {
const {
symbols: { getDeallocatorCallback, getDeallocatorBuffer, getDeallocatorCalledCount },
} = dlopen(FFI_FIXTURE_PATH, {
getDeallocatorCallback: { args: [], returns: "ptr" },
getDeallocatorBuffer: { args: [], returns: "ptr" },
getDeallocatorCalledCount: { args: [], returns: "int" },
});
(() => {
const bufPtr = getDeallocatorBuffer();
let buf = toBuffer(bufPtr, 0, 128, getDeallocatorCallback());
expect(buf.length).toBe(128);
expect(getDeallocatorCalledCount()).toBe(0);
buf = null;
})();
for (let i = 0; i < 100 && getDeallocatorCalledCount() === 0; i++) {
Bun.gc(true);
Buffer.alloc(1024 * 1024);
await Bun.sleep(0);
}
expect(getDeallocatorCalledCount()).toBe(1);
Bun.gc(true);
expect(getDeallocatorCalledCount()).toBe(1);
},
);
});

describe.skipIf(!FFI_FIXTURE_PATH)("engine-native FFI (single implementation)", () => {
const lib = FFI_FIXTURE_PATH;
it("linkSymbols() binds and calls symbols from raw pointers", () => {
Expand Down
Loading