Skip to content
Open
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
58 changes: 58 additions & 0 deletions doc/api/ffi.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,64 @@ console.log(add(20, 22));
console.log(add.pointer);
```

### `library.toFunction(pointer, signature)`

* `pointer` {bigint}
* `signature` {Object}
* Returns: {Function}

Creates a callable JavaScript wrapper for a native function address. The pointer
must be a nonzero, non-negative `bigint` that fits the platform's pointer width.
It can come from a resolved symbol, a native function's return value, or a
function pointer stored in native memory, such as a vtable slot.

```cjs
const { DynamicLibrary, suffix } = require('node:ffi');

const lib = new DynamicLibrary(`./mylib.${suffix}`);
try {
const address = lib.getSymbol('add_i32');
const add = lib.toFunction(address, {
arguments: ['int32', 'int32'],
return: 'int32',
});
console.log(add(20, 22));
console.log(add.pointer === address);
} finally {
lib.close();
}
```

Argument and return conversions follow the same rules as `getFunction()`.
This method supports fixed signatures using the platform's default calling
convention. Explicit calling convention selection and structures passed or
returned by value are not supported.

Each call creates a distinct wrapper. Multiple signatures can be associated
with the same address, but the caller is responsible for their correctness.
These wrappers do not appear in `library.functions`, `library.getFunctions()`,
`library.symbols`, or `library.getSymbols()` unless a symbol was separately
resolved by name. They use the generic libffi call path rather than Fast API
or SharedBuffer invokers.

The wrapper keeps the associated library alive. Calling it after
`library.close()` throws `ERR_FFI_LIBRARY_CLOSED`. FFI permission is required
when creating the wrapper, including after permission has been revoked on an
already-open library.

**The associated library does not establish ownership or validity of the
address.** Node.js cannot determine whether the pointer refers to executable
code, matches the signature, or is still valid. Passing a data pointer, using
an incorrect signature, or calling code that has been unloaded can crash the
process or corrupt memory.

The caller must keep the actual code and any native object used by the call
alive. For example, associating a COM vtable method with `ole32.dll` does not
retain the COM object or the module implementing that method. The caller must
manage its native references and lifetime. A wrapper for a callback pointer
also becomes unsafe if that callback is unregistered, even if the associated
library remains open.

### `library.getFunctions([definitions])`

* `definitions` {Object}
Expand Down
6 changes: 6 additions & 0 deletions lib/ffi.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ ObjectDefineProperty(DynamicLibrary.prototype, 'constructor', {
});

const rawGetFunction = DynamicLibrary.prototype.getFunction;
const rawToFunction = DynamicLibrary.prototype.toFunction;
const rawGetFunctions = DynamicLibrary.prototype.getFunctions;
const rawClose = DynamicLibrary.prototype.close;

Expand Down Expand Up @@ -180,6 +181,11 @@ DynamicLibrary.prototype.getFunction = function getFunction(name, signature) {
return wrapFFIFunction(raw, this);
};

DynamicLibrary.prototype.toFunction = function toFunction(pointer, signature) {
const raw = FunctionPrototypeCall(rawToFunction, this, pointer, signature);
return wrapFFIFunction(raw, this);
};

DynamicLibrary.prototype.getFunctions = function getFunctions(definitions) {
const raw = definitions === undefined ?
FunctionPrototypeCall(rawGetFunctions, this) :
Expand Down
135 changes: 114 additions & 21 deletions src/node_ffi.cc
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ void DynamicLibrary::MemoryInfo(MemoryTracker* tracker) const {
sizeof(decltype(function_wrappers_)::value_type),
"std::unordered_map<std::string, v8::Global<v8::Function>>");

tracker->TrackFieldWithSize(
"pointer_functions",
pointer_functions_ == nullptr
? 0
: sizeof(std::unordered_set<FFIFunction*>) +
pointer_functions_->size() * sizeof(FFIFunction*),
"std::unordered_set<FFIFunction*>");

// FFIFunctionInfo instances and their sb_backing ArrayBuffers are
// owned by V8 function wrappers and reachable only via weak references,
// so they are deliberately not counted here.
Expand All @@ -101,6 +109,14 @@ void DynamicLibrary::Close() {
fn->ptr = nullptr;
}

if (pointer_functions_ != nullptr) {
for (FFIFunction* fn : *pointer_functions_) {
fn->closed = true;
fn->ptr = nullptr;
}
pointer_functions_->clear();
}

// Closing the library invalidates all registered callbacks. Node.js does not
// track or revoke callback pointers that have already been handed to native
// code. If native code calls a callback pointer after `close()` or
Expand Down Expand Up @@ -142,7 +158,10 @@ Maybe<void*> DynamicLibrary::ResolveSymbol(Environment* env,
}

Maybe<DynamicLibrary::PreparedFunction> DynamicLibrary::PrepareFunction(
Environment* env, const std::string& name, Local<Object> signature) {
Environment* env,
const std::string& name,
Local<Object> signature,
void* ptr) {
std::shared_ptr<FFIFunction> fn;
FunctionSignature parsed;

Expand All @@ -151,22 +170,22 @@ Maybe<DynamicLibrary::PreparedFunction> DynamicLibrary::PrepareFunction(
}
// Look up the cache only after parsing: the signature's getters run user
// code that may close the library, which clears `functions_`.
auto existing = functions_.find(name);
const bool from_pointer = ptr != nullptr;
auto existing = from_pointer ? functions_.end() : functions_.find(name);
auto [return_type, args, return_type_name, arg_type_names] =
std::move(parsed);

bool should_cache_symbol = false;
bool should_cache_function = false;

if (existing == functions_.end()) {
void* ptr;

if (!ResolveSymbol(env, name).To(&ptr)) {
return {};
if (!from_pointer) {
if (!ResolveSymbol(env, name).To(&ptr)) {
return {};
}
should_cache_symbol = symbols_.find(name) == symbols_.end();
}

should_cache_symbol = symbols_.find(name) == symbols_.end();

fn = std::make_shared<FFIFunction>();
fn->ptr = ptr;
fn->args = std::move(args);
Expand Down Expand Up @@ -205,7 +224,7 @@ Maybe<DynamicLibrary::PreparedFunction> DynamicLibrary::PrepareFunction(
}
#endif

should_cache_function = true;
should_cache_function = !from_pointer;
} else {
fn = existing->second;

Expand Down Expand Up @@ -267,7 +286,6 @@ MaybeLocal<Function> DynamicLibrary::CreateFunction(
const std::string& name,
const std::shared_ptr<FFIFunction>& fn) {
Isolate* isolate = env->isolate();
Local<Context> context = env->context();

// Creating a callable emits a trampoline, allocates an FFIFunctionInfo, and
// on the SharedBuffer path allocates an ArrayBuffer, so reuse the one already
Expand All @@ -282,17 +300,38 @@ MaybeLocal<Function> DynamicLibrary::CreateFunction(
function_wrappers_.erase(cached);
}

Local<Function> ret;
if (!BuildFunction(env, name, fn, true).ToLocal(&ret)) {
return {};
}
function_wrappers_.emplace(name, Global<Function>(isolate, ret))
.first->second.SetWeak();
return ret;
}

MaybeLocal<Function> DynamicLibrary::BuildFunction(
Environment* env,
const std::string& name,
const std::shared_ptr<FFIFunction>& fn,
bool optimize) {
Isolate* isolate = env->isolate();
Local<Context> context = env->context();
auto info = FFIFunctionInfo::Create(env, fn, this);
if (!info) {
return {};
}

DCHECK_EQ(fn->args.size(), fn->arg_type_names.size());

// Try the generated Fast API path first. If metadata creation rejects the
// signature, fall back to SharedBuffer for supported scalar shapes, then to
// the generic libffi invoker.
std::shared_ptr<FFIFunction> fast_fn = CloneWithRawPointerArgNames(fn);
info->fast_metadata = CreateFastFFIMetadata(*fast_fn, &fn->closed, isolate);
if (optimize) {
std::shared_ptr<FFIFunction> fast_fn = CloneWithRawPointerArgNames(fn);
info->fast_metadata = CreateFastFFIMetadata(*fast_fn, &fn->closed, isolate);
}
bool use_fast_api = info->fast_metadata != nullptr;
bool use_sb = !use_fast_api && IsSBEligibleSignature(*fn);
bool use_sb = optimize && !use_fast_api && IsSBEligibleSignature(*fn);
bool has_ptr_args = use_sb && SignatureHasPointerArgs(*fn);
// Signatures that need JS-side conversion or validation use a wrapper, as
// do all fast signatures on platforms without a native library guard.
Expand Down Expand Up @@ -485,14 +524,6 @@ MaybeLocal<Function> DynamicLibrary::CreateFunction(
}
}

// A strong handle would root the callable, which holds the library object
// through FFIFunctionInfo, so neither could ever be collected. Weaken the
// stored handle instead, so the cache lasts exactly as long as user code
// keeps a reference. SetWeak() runs after the move into the map because
// moving a handle relocates the underlying slot.
function_wrappers_.emplace(name, Global<Function>(isolate, ret))
.first->second.SetWeak();

return ret;
}

Expand Down Expand Up @@ -607,6 +638,7 @@ void DynamicLibrary::InvokeFunction(const FunctionCallbackInfo<Value>& args) {
std::vector<uint64_t> values(expected_args, 0);
std::vector<void*> ffi_args(expected_args, nullptr);
std::vector<std::string> strings;
strings.reserve(expected_args);

for (unsigned int i = 0; i < expected_args; i++) {
FFIArgumentCategory res;
Expand Down Expand Up @@ -861,6 +893,66 @@ void DynamicLibrary::GetFunction(const FunctionCallbackInfo<Value>& args) {
args.GetReturnValue().Set(ret);
}

void DynamicLibrary::ToFunction(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
THROW_IF_INSUFFICIENT_PERMISSIONS(env, permission::PermissionScope::kFFI, "");

if (args.Length() < 1 || !args[0]->IsBigInt()) {
THROW_ERR_INVALID_ARG_TYPE(env, "Function pointer must be a bigint");
return;
}
bool lossless;
uint64_t address = args[0].As<BigInt>()->Uint64Value(&lossless);
if (!lossless || address == 0 ||
address > static_cast<uint64_t>(std::numeric_limits<uintptr_t>::max())) {
THROW_ERR_INVALID_ARG_VALUE(env, "Invalid function pointer");
return;
}
if (args.Length() < 2 || !args[1]->IsObject() || args[1]->IsArray()) {
THROW_ERR_INVALID_ARG_TYPE(env, "Function signature must be an object");
return;
}

DynamicLibrary* lib = Unwrap<DynamicLibrary>(args.This());
if (lib->is_closed()) {
THROW_ERR_FFI_LIBRARY_CLOSED(env);
return;
}
Local<Object> signature = args[1].As<Object>();
PreparedFunction prepared;
void* ptr = reinterpret_cast<void*>(static_cast<uintptr_t>(address));
if (!lib->PrepareFunction(env, "<pointer>", signature, ptr).To(&prepared)) {
return;
}
THROW_IF_INSUFFICIENT_PERMISSIONS(env, permission::PermissionScope::kFFI, "");
if (lib->is_closed()) {
THROW_ERR_FFI_LIBRARY_CLOSED(env);
return;
}

auto fn = std::move(prepared.fn);
if (lib->pointer_functions_ == nullptr) {
lib->pointer_functions_ =
std::make_shared<std::unordered_set<FFIFunction*>>();
}
fn->pointer_registry = lib->pointer_functions_;
fn->closed = true;
lib->pointer_functions_->insert(fn.get());
Local<Function> ret;
if (!lib->BuildFunction(env, "<pointer>", fn, false).ToLocal(&ret)) {
lib->pointer_functions_->erase(fn.get());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't we also want to call this whenever the JS function gets garbage-collected?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is already handled by FFIFunction's destructor: it locks pointer_registry and calls erase(this) if the registry still exists.
GC of the callable's FFIFunctionInfo releases the FFIFunction.
The explicit erase here handles construction failure.

fn->ptr = nullptr;
return;
}
THROW_IF_INSUFFICIENT_PERMISSIONS(env, permission::PermissionScope::kFFI, "");
if (lib->is_closed()) {
THROW_ERR_FFI_LIBRARY_CLOSED(env);
return;
}
fn->closed = false;
args.GetReturnValue().Set(ret);
}

void DynamicLibrary::GetFunctions(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
Isolate* isolate = env->isolate();
Expand Down Expand Up @@ -1324,6 +1416,7 @@ Local<FunctionTemplate> DynamicLibrary::GetConstructorTemplate(
SetProtoMethod(isolate, tmpl, "close", DynamicLibrary::Close);
SetProtoDispose(isolate, tmpl, DynamicLibrary::Close);
SetProtoMethod(isolate, tmpl, "getFunction", DynamicLibrary::GetFunction);
SetProtoMethod(isolate, tmpl, "toFunction", DynamicLibrary::ToFunction);
SetProtoMethod(isolate, tmpl, "getFunctions", DynamicLibrary::GetFunctions);
SetProtoMethod(isolate, tmpl, "getSymbol", DynamicLibrary::GetSymbol);
SetProtoMethod(isolate, tmpl, "getSymbols", DynamicLibrary::GetSymbols);
Expand Down
17 changes: 16 additions & 1 deletion src/node_ffi.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <string>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <vector>

// libffi only accelerates reusable call plans on x86-64 System V. Other
Expand All @@ -29,12 +30,18 @@ struct FFIFunction;

struct FFIFunction {
FFIFunction() = default;
~FFIFunction() {
if (auto registry = pointer_registry.lock()) {
registry->erase(this);
}
}
FFIFunction(const FFIFunction&) = delete;
FFIFunction& operator=(const FFIFunction&) = delete;
FFIFunction(FFIFunction&&) = delete;
FFIFunction& operator=(FFIFunction&&) = delete;

bool closed = false;
std::weak_ptr<std::unordered_set<FFIFunction*>> pointer_registry;

void* ptr = nullptr;
ffi_cif cif = {};
Expand Down Expand Up @@ -133,6 +140,7 @@ class DynamicLibrary : public BaseObject {

static void GetPath(const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetFunction(const v8::FunctionCallbackInfo<v8::Value>& args);
static void ToFunction(const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetFunctions(const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetSymbol(const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetSymbols(const v8::FunctionCallbackInfo<v8::Value>& args);
Expand All @@ -156,11 +164,17 @@ class DynamicLibrary : public BaseObject {
};
v8::Maybe<PreparedFunction> PrepareFunction(Environment* env,
const std::string& name,
v8::Local<v8::Object> signature);
v8::Local<v8::Object> signature,
void* ptr = nullptr);
v8::MaybeLocal<v8::Function> CreateFunction(
Environment* env,
const std::string& name,
const std::shared_ptr<FFIFunction>& fn);
v8::MaybeLocal<v8::Function> BuildFunction(
Environment* env,
const std::string& name,
const std::shared_ptr<FFIFunction>& fn,
bool optimize);
static void CleanupFunctionInfo(
const v8::WeakCallbackInfo<FFIFunctionInfo>& data);
bool is_closed() const;
Expand All @@ -175,6 +189,7 @@ class DynamicLibrary : public BaseObject {
// which keeps the map from rooting the library through the wrapper's
// FFIFunctionInfo.
std::unordered_map<std::string, v8::Global<v8::Function>> function_wrappers_;
std::shared_ptr<std::unordered_set<FFIFunction*>> pointer_functions_;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a particular reason for having this behind a std::shared_ptr?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The library owns the registry, while each FFIFunction holds a weak_ptr to it.
This lets its destructor unregister itself if the registry still exists, without dereferencing the library.

std::unordered_map<void*, std::unique_ptr<FFICallback>> callbacks_;
};

Expand Down
Loading
Loading