Skip to content

Commit 6344ae1

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. Hold the owning library through a BaseObjectPtr in each FFICallback, so it stays strong until its last callback is unregistered or the library is closed. Callbacks released with unrefCallback() still count, because their trampolines remain live native memory. Since this forms a cycle, release the callbacks from an Environment cleanup hook so the library is still freed at teardown. Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Assisted-by: claude:opus-5.5
1 parent 1a885ca commit 6344ae1

4 files changed

Lines changed: 114 additions & 2 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: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,24 @@ void FFIFunctionInfo::MemoryInfo(MemoryTracker* tracker) const {
6262
DynamicLibrary::DynamicLibrary(Environment* env, Local<Object> object)
6363
: BaseObject(env, object) {
6464
MakeWeak();
65+
env->AddCleanupHook(CleanupHook, this);
6566
}
6667

6768
DynamicLibrary::~DynamicLibrary() {
69+
env()->RemoveCleanupHook(CleanupHook, this);
6870
this->Close();
6971
}
7072

73+
void DynamicLibrary::CleanupHook(void* arg) {
74+
DynamicLibrary* lib = static_cast<DynamicLibrary*>(arg);
75+
// Registered callbacks hold strong references to the library, which would
76+
// otherwise keep it alive past Environment teardown. Release them only
77+
// after Close() returns, since dropping the last reference may delete lib.
78+
decltype(lib->callbacks_) callbacks;
79+
callbacks.swap(lib->callbacks_);
80+
lib->Close();
81+
}
82+
7183
bool DynamicLibrary::is_closed() const {
7284
return static_cast<void*>(lib_.handle) == nullptr;
7385
}
@@ -1100,7 +1112,7 @@ void DynamicLibrary::RegisterCallback(const FunctionCallbackInfo<Value>& args) {
11001112
}
11011113

11021114
auto callback = std::unique_ptr<FFICallback>(
1103-
new FFICallback{.owner = lib,
1115+
new FFICallback{.owner = BaseObjectPtr<DynamicLibrary>(lib),
11041116
.env = env,
11051117
.thread_id = std::this_thread::get_id(),
11061118
.fn = Global<Function>(isolate, fn),

‎src/node_ffi.h‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ class FFIFunctionInfo final : public BaseObject {
8383
};
8484

8585
struct FFICallback {
86-
DynamicLibrary* owner;
86+
// Callback pointers are plain addresses that cannot root the library, so
87+
// each registered callback keeps its owner alive until it is unregistered
88+
// or the library is closed.
89+
BaseObjectPtr<DynamicLibrary> owner;
8790
Environment* env;
8891
std::thread::id thread_id;
8992
v8::Global<v8::Function> fn;
@@ -161,6 +164,7 @@ class DynamicLibrary : public BaseObject {
161164
Environment* env,
162165
const std::string& name,
163166
const std::shared_ptr<FFIFunction>& fn);
167+
static void CleanupHook(void* arg);
164168
static void CleanupFunctionInfo(
165169
const v8::WeakCallbackInfo<FFIFunctionInfo>& data);
166170
bool is_closed() const;

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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+
});
78+
79+
test('libraries with registered callbacks do not block teardown', async (t) => {
80+
const { Worker } = require('node:worker_threads');
81+
const { once } = require('node:events');
82+
const worker = new Worker(`
83+
const ffi = require('node:ffi');
84+
const lib = new ffi.DynamicLibrary(${JSON.stringify(libraryPath)});
85+
lib.registerCallback(${JSON.stringify(signature)}, (x) => x);
86+
globalThis.lib = lib;
87+
`, { eval: true });
88+
const [code] = await once(worker, 'exit');
89+
t.assert.strictEqual(code, 0);
90+
});

0 commit comments

Comments
 (0)