Skip to content

Commit 7e8c874

Browse files
committed
ffi: keep DynamicLibrary alive while callbacks are registered
Callback pointers returned by registerCallback() are plain bigint addresses, so they do not keep the owning DynamicLibrary reachable. If user code dropped the library without calling close(), the next GC ran ~DynamicLibrary(), which freed every ffi_closure. Native code still holding a callback pointer then jumped into freed trampoline memory and crashed the process, even though close() was never called. Make the library a strong reference while it has registered callbacks, and make it weak again when the last callback is unregistered or the library is closed. Callbacks released with unrefCallback() still count, because their trampolines remain live native memory. Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Assisted-by: claude:opus-5.5
1 parent 1a885ca commit 7e8c874

4 files changed

Lines changed: 99 additions & 0 deletions

File tree

‎doc/api/ffi.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,12 @@ When `signature` is omitted, the callback uses a default `void ()` signature.
482482
The return value is the callback pointer address as a `bigint`. It can be
483483
passed to native functions expecting a callback pointer.
484484

485+
While a library has registered callbacks, it is not garbage collected, even if
486+
no JavaScript references to it remain. This keeps callback pointers held by
487+
native code valid until `library.unregisterCallback(pointer)` or
488+
`library.close()` is called. Unregister callbacks or close the library when
489+
they are no longer needed to avoid leaking it.
490+
485491
```cjs
486492
const { DynamicLibrary, suffix } = require('node:ffi');
487493

‎src/node_ffi.cc‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,15 @@ void DynamicLibrary::Close() {
119119
function_wrappers_.clear();
120120
callbacks_.clear();
121121
}
122+
void DynamicLibrary::UpdateCallbackRetention() {
123+
// Must not be called from ~DynamicLibrary(): the persistent handle may
124+
// already have been reset by the weak callback.
125+
if (callbacks_.empty()) {
126+
MakeWeak();
127+
} else {
128+
ClearWeak();
129+
}
130+
}
122131

123132
Maybe<void*> DynamicLibrary::ResolveSymbol(Environment* env,
124133
const std::string& name) {
@@ -585,6 +594,7 @@ void DynamicLibrary::Close(const FunctionCallbackInfo<Value>& args) {
585594
// Closing a library from one of its active callbacks is unsupported and
586595
// dangerous. Callbacks must return before the owning library is closed.
587596
lib->Close();
597+
lib->UpdateCallbackRetention();
588598
}
589599

590600
void DynamicLibrary::InvokeFunction(const FunctionCallbackInfo<Value>& args) {
@@ -1176,6 +1186,7 @@ void DynamicLibrary::RegisterCallback(const FunctionCallbackInfo<Value>& args) {
11761186

11771187
auto ret = static_cast<uint64_t>(reinterpret_cast<uintptr_t>(callback->ptr));
11781188
lib->callbacks_.emplace(callback->ptr, std::move(callback));
1189+
lib->UpdateCallbackRetention();
11791190
args.GetReturnValue().Set(BigInt::NewFromUnsigned(isolate, ret));
11801191
}
11811192

@@ -1214,6 +1225,7 @@ void DynamicLibrary::UnregisterCallback(
12141225
// output, or corrupt memory. Unregistering a callback while it is currently
12151226
// executing is also unsupported and dangerous.
12161227
lib->callbacks_.erase(existing);
1228+
lib->UpdateCallbackRetention();
12171229
}
12181230

12191231
void DynamicLibrary::RefCallback(const FunctionCallbackInfo<Value>& args) {

‎src/node_ffi.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ class DynamicLibrary : public BaseObject {
148148

149149
private:
150150
void Close();
151+
// Keeps the JS object strong while callbacks are registered. Callback
152+
// pointers are plain addresses that cannot root the library, so without
153+
// this a GC could free trampolines that native code still holds.
154+
void UpdateCallbackRetention();
151155
v8::Maybe<void*> ResolveSymbol(Environment* env, const std::string& name);
152156
struct PreparedFunction {
153157
std::shared_ptr<FFIFunction> fn;

‎test/ffi/test-ffi-callback-gc.js‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Flags: --expose-gc
2+
'use strict';
3+
const { skipIfFFIMissing } = require('../common');
4+
skipIfFFIMissing();
5+
6+
const { gcUntil } = require('../common/gc');
7+
const test = require('node:test');
8+
const ffi = require('node:ffi');
9+
const { fixtureSymbols, libraryPath } = require('./ffi-test-common');
10+
11+
const signature = { arguments: ['i32'], return: 'i32' };
12+
13+
test('registered callbacks keep an unreachable library alive', async (t) => {
14+
// A separate handle provides the native function that invokes the callback.
15+
const { lib: keeper, functions: symbols } =
16+
ffi.dlopen(libraryPath, fixtureSymbols);
17+
t.after(() => keeper.close());
18+
19+
let lib = new ffi.DynamicLibrary(libraryPath);
20+
const ref = new WeakRef(lib);
21+
const pointer = lib.registerCallback(signature, (x) => x * 2);
22+
t.assert.strictEqual(symbols.call_int_callback(pointer, 21), 42);
23+
24+
lib = null;
25+
for (let i = 0; i < 5; i++) {
26+
await gcUntil('library with callbacks stays alive', () => true, 1);
27+
t.assert.strictEqual(typeof ref.deref(), 'object');
28+
t.assert.strictEqual(symbols.call_int_callback(pointer, 21), 42);
29+
}
30+
31+
ref.deref().unregisterCallback(pointer);
32+
});
33+
34+
test('library becomes collectable after its callbacks are unregistered', async () => {
35+
let lib = new ffi.DynamicLibrary(libraryPath);
36+
const ref = new WeakRef(lib);
37+
const first = lib.registerCallback(signature, (x) => x);
38+
const second = lib.registerCallback(signature, (x) => x);
39+
lib.unregisterCallback(first);
40+
lib.unregisterCallback(second);
41+
lib = null;
42+
43+
await gcUntil('library is collected after unregisterCallback',
44+
() => ref.deref() === undefined);
45+
});
46+
47+
test('library becomes collectable after close()', async () => {
48+
let lib = new ffi.DynamicLibrary(libraryPath);
49+
const ref = new WeakRef(lib);
50+
lib.registerCallback(signature, (x) => x);
51+
lib.close();
52+
lib = null;
53+
54+
await gcUntil('library is collected after close()',
55+
() => ref.deref() === undefined);
56+
});
57+
58+
test('unrefCallback() does not release the callback trampoline', async (t) => {
59+
const { lib: keeper, functions: symbols } =
60+
ffi.dlopen(libraryPath, fixtureSymbols);
61+
t.after(() => keeper.close());
62+
63+
let lib = new ffi.DynamicLibrary(libraryPath);
64+
const ref = new WeakRef(lib);
65+
const pointer = lib.registerCallback(signature, () => 1);
66+
lib.unrefCallback(pointer);
67+
lib = null;
68+
69+
for (let i = 0; i < 5; i++) {
70+
await gcUntil('library with unref callbacks stays alive', () => true, 1);
71+
t.assert.strictEqual(typeof ref.deref(), 'object');
72+
}
73+
// The JS function may be gone, but the trampoline must still be callable.
74+
symbols.call_int_callback(pointer, 21);
75+
76+
ref.deref().unregisterCallback(pointer);
77+
});

0 commit comments

Comments
 (0)