Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions docs/runtime/ffi.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,8 @@ setTimeout(() => {

When you're done with a JSCallback, you should call `close()` to free the memory.

The same applies to the library itself: call `close()` on the object returned by `dlopen` when you no longer need it. Once a library has been closed, calling any of its symbols throws a `TypeError`.

### Experimental thread-safe callbacks

`JSCallback` has experimental support for thread-safe callbacks. This will be needed if you pass a callback function into a different thread from its instantiation context. You can enable it with the optional `threadsafe` parameter.
Expand Down
5 changes: 4 additions & 1 deletion src/js/bun/ffi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,10 @@ function CFunction(options) {
};

cFunctionRegistry ||= new FinalizationRegistry(onCloseCFunction);
cFunctionRegistry.register(result.symbols[identifier], result.symbols[identifier].close);
// Key collection on the native function rather than the wrapper: `.native`
// (the function that jumps into TinyCC-compiled memory) can outlive the
// wrapper, and the library must not be freed while it is still callable.
cFunctionRegistry.register(result.symbols[identifier].native, result.symbols[identifier].close);
Comment thread
robobun marked this conversation as resolved.

return result.symbols[identifier];
}
Expand Down
28 changes: 19 additions & 9 deletions src/jsc/bindings/JSFFIFunction.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ extern "C" void Bun__FFIFunction_setDataPtr(JSC::EncodedJSValue jsValue, void* p
function->dataPtr = ptr;
}

extern "C" void Bun__JSFFIFunction__invalidate(JSC::EncodedJSValue jsValue)
{
Zig::JSFFIFunction* function = dynamicDowncast<Zig::JSFFIFunction>(JSC::JSValue::decode(jsValue));
if (!function)
return;

function->invalidate();
Comment thread
robobun marked this conversation as resolved.
}

extern "C" JSC::EncodedJSValue Bun__CreateFFIFunctionValue(Zig::GlobalObject* globalObject, const ZigString* symbolName, unsigned argCount, Zig::FFIFunction functionPointer, bool addPtrField, void* symbolFromDynamicLibrary)
{
if (addPtrField) {
Expand Down Expand Up @@ -165,23 +174,24 @@ JSFFIFunction* JSFFIFunction::create(VM& vm, Zig::GlobalObject* globalObject, un
return function;
}

#if OS(WINDOWS)

// All TinyCC-compiled functions are called through this trampoline so that
// `invalidate()` (library close) can make later calls throw instead of jumping
// into freed executable memory. On Windows it is additionally required for ABI
// reasons: JSC host functions use the SYSV ABI while TinyCC emits MS x64 code.
JSC_DEFINE_HOST_FUNCTION(JSFFIFunction::trampoline, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
const auto* function = uncheckedDowncast<JSFFIFunction>(callFrame->jsCallee());
return function->function()(globalObject, callFrame);
const auto ffiFunction = function->function();
if (!ffiFunction) [[unlikely]] {
auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject));
return JSC::throwVMTypeError(globalObject, scope, "Cannot call this FFI function: its library has been closed"_s);
}
return ffiFunction(globalObject, callFrame);
}

#endif

JSFFIFunction* JSFFIFunction::createForFFI(VM& vm, Zig::GlobalObject* globalObject, unsigned length, const String& name, CFFIFunction FFIFunction)
{
#if OS(WINDOWS)
NativeExecutable* executable = vm.getHostFunction(trampoline, ImplementationVisibility::Public, NoIntrinsic, trampoline, nullptr, name);
#else
NativeExecutable* executable = vm.getHostFunction(FFIFunction, ImplementationVisibility::Public, NoIntrinsic, FFIFunction, nullptr, name);
#endif
Structure* structure = globalObject->FFIFunctionStructure();
JSFFIFunction* function = new (NotNull, allocateCell<JSFFIFunction>(vm)) JSFFIFunction(vm, executable, globalObject, structure, reinterpret_cast<CFFIFunction>(WTF::move(FFIFunction)));
function->finishCreation(vm, executable, length, name);
Expand Down
7 changes: 4 additions & 3 deletions src/jsc/bindings/JSFFIFunction.h
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,13 @@ class JSFFIFunction final : public JSC::JSFunction {

const CFFIFunction function() const { return m_function; }

#if OS(WINDOWS)
/// Called when the library owning the TinyCC-compiled wrapper is closed.
/// The trampoline throws instead of jumping into the freed executable
/// memory `m_function` used to point at.
void invalidate() { m_function = nullptr; }

static JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES trampoline(JSGlobalObject* globalObject, CallFrame* callFrame);

#endif

void* dataPtr;
void* symbolFromDynamicLibrary { nullptr };

Expand Down
112 changes: 75 additions & 37 deletions src/runtime/ffi/ffi_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@ unsafe extern "C" {
add_ptr_property: bool,
input_function_ptr: *mut c_void,
) -> JSValue;

/// `JSFFIFunction::invalidate` — nulls the function pointer so the
/// trampoline throws on later calls. No-op for non-`JSFFIFunction` values.
fn Bun__JSFFIFunction__invalidate(value: JSValue);
}

/// `Bun__JSFFIFunction__invalidate` thin wrapper.
#[inline]
fn invalidate_js_function(value: JSValue) {
// SAFETY: thin FFI wrapper; `value` is a by-value tagged i64 and the C++
// side type-checks it with `dynamicDowncast`.
unsafe { Bun__JSFFIFunction__invalidate(value) }
}

/// Raw extern fn pointers fed to
Expand Down Expand Up @@ -177,6 +189,29 @@ fn symbols_value_set_cached(js_object: JSValue, global: &JSGlobalObject, obj: JS
crate::generated_classes::js_FFI::symbols_value_set_cached(js_object, global, obj)
}

/// Create the JS function for a compiled symbol, root it on `function` so
/// teardown can invalidate it (see `Function::js_function`), and define it on
/// the symbols object. The symbols object additionally roots it via the
/// `symbolsValue` cached own-property the callers set.
fn attach_compiled_symbol(
global: &JSGlobalObject,
symbols_obj: JSValue,
function: &mut Function,
name: &ZigString,
compiled_ptr: *const c_void,
) {
let cb = new_runtime_function(
global,
name,
u32::try_from(function.arg_types.len()).expect("int cast"),
compiled_ptr,
true,
function.symbol_from_dynamic_library,
);
function.js_function = Some(jsc::Strong::create(cb, global));
symbols_obj.put(global, name.slice(), cb);
}

impl Offsets {
fn load_once() {
// SAFETY: extern "C" fn populating a static
Expand Down Expand Up @@ -216,19 +251,26 @@ impl Default for FFI {

impl FFI {
pub fn finalize(self: Box<Self>) {
// INTENTIONAL no-op when not closed. Compiled trampolines / dlopen'd
// INTENTIONAL leak when not closed. Compiled trampolines / dlopen'd
// symbols may still be reachable from JS after the wrapper is GC'd
// (e.g. `const { fn } = dlopen(...).symbols`); teardown is owned by
// `close()`. Dropping the Box would run `Function::drop` →
// `tcc_delete()`, freeing the executable pages those JSFunctions still
// jump into.
// jump into. Only the `js_function` Strong roots are released — the
// leaked TCC states keep the executable memory valid, and without
// this the JS functions themselves would leak too.
//
// When `close()` HAS run, the functions map is empty and the dylib /
// shared TCC state are already gone, so the Box only owns the (empty)
// hashmap's retained-capacity buffer. Drop it instead of leaking.
if self.closed.get() {
drop(self);
} else {
self.functions.with_mut(|functions| {
for function in functions.values_mut() {
drop(function.js_function.take());
}
});
let _ = bun_core::heap::release(self);
}
}
Expand Down Expand Up @@ -1226,17 +1268,9 @@ impl FFI {
);
}
Step::Compiled(compiled) => {
let compiled_ptr = compiled.ptr.cast_const();
let str = ZigString::init(function_name.as_bytes());
let cb = new_runtime_function(
global_this,
&str,
u32::try_from(function.arg_types.len()).expect("int cast"),
compiled.ptr.cast_const(),
true,
function.symbol_from_dynamic_library,
);
// `cb` is rooted by the `symbolsValue` cached own-property set below.
obj.put(global_this, str.slice(), cb);
attach_compiled_symbol(global_this, obj, function, &str, compiled_ptr);
}
}
}
Expand Down Expand Up @@ -1331,16 +1365,21 @@ impl FFI {
return Ok(JSValue::UNDEFINED);
}
self.closed.set(true);
if let Some(dylib) = self.dylib.replace(None) {
dylib.close();
}

// Dropping each `Function` invalidates its JS function before freeing
// the per-function TCC state, so do this before the shared state (the
// `cc()` path, where the executable memory of every symbol lives) and
// the dylib go away.
self.functions.with_mut(|f| f.clear_retaining_capacity());

if let Some(state) = self.shared_state.take() {
// SAFETY: state is a valid TCC::State pointer; we have exclusive ownership
unsafe { TCC::State::destroy(state.as_ptr()) };
}

self.functions.with_mut(|f| f.clear_retaining_capacity());
if let Some(dylib) = self.dylib.replace(None) {
dylib.close();
}

Ok(JSValue::UNDEFINED)
}
Expand Down Expand Up @@ -1588,17 +1627,9 @@ impl FFI {
.to_error_instance(global);
}
Step::Compiled(compiled) => {
let compiled_ptr = compiled.ptr.cast_const();
let str = ZigString::init(function_name.as_bytes());
let cb = new_runtime_function(
global,
&str,
u32::try_from(function.arg_types.len()).expect("int cast"),
compiled.ptr.cast_const(),
true,
function.symbol_from_dynamic_library,
);
// `cb` is rooted by the `symbolsValue` cached own-property set below.
obj.put(global, str.slice(), cb);
attach_compiled_symbol(global, obj, function, &str, compiled_ptr);
}
}
}
Expand Down Expand Up @@ -1683,18 +1714,9 @@ impl FFI {
.to_error_instance(global);
}
Step::Compiled(compiled) => {
let compiled_ptr = compiled.ptr.cast_const();
let name = ZigString::init(function_name.as_bytes());

let cb = new_runtime_function(
global,
&name,
u32::try_from(function.arg_types.len()).expect("int cast"),
compiled.ptr.cast_const(),
true,
function.symbol_from_dynamic_library,
);
// `cb` is rooted by the `symbolsValue` cached own-property set below.
obj.put(global, name.slice(), cb);
attach_compiled_symbol(global, obj, function, &name, compiled_ptr);
}
}
}
Expand Down Expand Up @@ -1903,6 +1925,14 @@ pub struct Function {
pub arg_types: Vec<ABIType>,
pub step: Step,
pub threadsafe: bool,
/// The `JSFFIFunction` whose host function is the TinyCC-compiled wrapper.
/// Held so `Drop` can invalidate it before the executable memory is freed
/// (`close()` clears the functions map while the JS function is still
/// reachable — e.g. `const { fn } = dlopen(...).symbols`). The symbols
/// object can't serve that purpose: `src/js/bun/ffi.ts` replaces its
/// entries with plain JS wrappers. `Function` is only created and dropped
/// on the JS thread, which `Strong` requires.
pub js_function: Option<jsc::Strong>,
// allocator field dropped — global mimalloc
}

Expand All @@ -1916,6 +1946,7 @@ impl Default for Function {
arg_types: Vec::new(),
step: Step::Pending,
threadsafe: false,
js_function: None,
}
}
}
Expand All @@ -1927,6 +1958,13 @@ unsafe extern "C" {
impl Drop for Function {
fn drop(&mut self) {
// base_name, arg_types, Step::Failed.msg are owned and freed by drop glue.
// The JS function keeps executing the TinyCC-compiled wrapper whose
// executable memory is freed below (per-function `state` here, the
// shared state in `FFI::close`). Invalidate it first so later calls
// throw instead of jumping into freed memory.
if let Some(js_function) = self.js_function.take() {
invalidate_js_function(js_function.get());
}
if let Some(state) = self.state.take() {
// SAFETY: state is a valid TCC::State pointer; we own it
unsafe { TCC::State::destroy(state.as_ptr()) };
Expand Down
Loading
Loading