From a49e8e41e1c32220f761473cae6141971ff01592 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 24 Apr 2026 16:12:45 +0200 Subject: [PATCH 1/2] ffi: support Symbol.dispose on DynamicLibrary Install [Symbol.dispose]() on DynamicLibrary.prototype (calling close()) and on the object returned from ffi.dlopen(), so both can be used with the `using` declaration for automatic cleanup. --- doc/api/ffi.md | 41 +++++++++++++++++++ lib/ffi.js | 17 +++++++- test/ffi/test-ffi-dynamic-library.js | 59 ++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/doc/api/ffi.md b/doc/api/ffi.md index 28ca2f9dd1427d..930ad70afc0155 100644 --- a/doc/api/ffi.md +++ b/doc/api/ffi.md @@ -182,6 +182,21 @@ The returned object contains: * `lib` {DynamicLibrary} The loaded library handle. * `functions` {Object} Callable wrappers for the requested symbols. +The returned object also implements the explicit resource management protocol, +so it can be used with the [`using`][] declaration. Disposing the returned +object closes the library handle. + +```mjs +import { dlopen } from 'node:ffi'; + +{ + using handle = dlopen('./mylib.so', { + add_i32: { parameters: ['i32', 'i32'], result: 'i32' }, + }); + console.log(handle.functions.add_i32(20, 22)); +} // handle.lib.close() is invoked automatically here. +``` + ```mjs import { dlopen } from 'node:ffi'; @@ -275,6 +290,21 @@ An object containing previously resolved symbol addresses as `bigint` values. Closes the library handle. +`DynamicLibrary` implements the explicit resource management protocol, so a +library instance can be managed with the [`using`][] declaration. Leaving the +enclosing scope invokes `library.close()` automatically. + +```mjs +import { DynamicLibrary } from 'node:ffi'; + +{ + using lib = new DynamicLibrary('./mylib.so'); + // Use `lib` here; `lib.close()` is called when the block exits. +} +``` + +Calling `library.close()` (or disposing the library) more than once is a no-op. + After a library has been closed: * Resolved function wrappers become invalid. @@ -295,6 +325,16 @@ Calling `library.close()` from one of the library's active callbacks is unsupported and dangerous. The callback must return before the library is closed. +### `library[Symbol.dispose]()` + + + +Calls `library.close()`. This allows `DynamicLibrary` instances to be used with +the [`using`][] declaration for automatic cleanup when the enclosing scope +exits. It is a no-op on a library that has already been closed. + ### `library.getFunction(name, signature)` * `name` {string} @@ -684,3 +724,4 @@ and keep callback and pointer lifetimes explicit on the native side. [Permission Model]: permissions.md#permission-model [`--allow-ffi`]: cli.md#--allow-ffi [`ffi.toBuffer(pointer, length, copy)`]: #ffitobufferpointer-length-copy +[`using`]: https://tc39.es/proposal-explicit-resource-management/#sec-using-declarations diff --git a/lib/ffi.js b/lib/ffi.js index b276f4b29dfcdc..d069ec4ca970bb 100644 --- a/lib/ffi.js +++ b/lib/ffi.js @@ -1,8 +1,10 @@ 'use strict'; const { + ObjectDefineProperty, ObjectFreeze, ObjectPrototypeToString, + SymbolDispose, } = primordials; const { Buffer } = require('buffer'); const { emitExperimentalWarning } = require('internal/util'); @@ -53,6 +55,15 @@ const { toArrayBuffer, } = internalBinding('ffi'); +ObjectDefineProperty(DynamicLibrary.prototype, SymbolDispose, { + __proto__: null, + configurable: true, + writable: true, + value: function dispose() { + this.close(); + }, +}); + function checkFFIPermission() { if (!permission.isEnabled() || permission.has('ffi')) { return; @@ -69,7 +80,11 @@ function dlopen(path, definitions) { const lib = new DynamicLibrary(path); try { const functions = definitions === undefined ? ObjectFreeze({ __proto__: null }) : lib.getFunctions(definitions); - return { lib, functions }; + return { + lib, + functions, + [SymbolDispose]() { lib.close(); }, + }; } catch (error) { lib.close(); throw error; diff --git a/test/ffi/test-ffi-dynamic-library.js b/test/ffi/test-ffi-dynamic-library.js index 8ff2e9de816317..31c2397f26ffa2 100644 --- a/test/ffi/test-ffi-dynamic-library.js +++ b/test/ffi/test-ffi-dynamic-library.js @@ -125,6 +125,65 @@ test('closed libraries reject subsequent operations', () => { assert.throws(() => lib.getSymbol('add_i32'), /Library is closed/); }); +test('DynamicLibrary supports Symbol.dispose', () => { + const lib = new ffi.DynamicLibrary(libraryPath); + const addI32 = lib.getFunction('add_i32', fixtureSymbols.add_i32); + + assert.strictEqual(typeof lib[Symbol.dispose], 'function'); + assert.strictEqual(addI32(20, 22), 42); + + lib[Symbol.dispose](); + + assert.throws(() => addI32(1, 2), /Library is closed/); + assert.throws(() => lib.getSymbol('add_i32'), /Library is closed/); + + // Disposing twice is a no-op. + lib[Symbol.dispose](); + lib.close(); +}); + +test('using declaration closes DynamicLibrary on scope exit', () => { + let captured; + { + using lib = new ffi.DynamicLibrary(libraryPath); + captured = lib.getFunction('add_i32', fixtureSymbols.add_i32); + assert.strictEqual(captured(20, 22), 42); + } + + assert.throws(() => captured(1, 2), /Library is closed/); +}); + +test('dlopen return value is disposable', () => { + let capturedLib; + let capturedFn; + { + using handle = ffi.dlopen(libraryPath, { + add_i32: fixtureSymbols.add_i32, + }); + assert.strictEqual(typeof handle[Symbol.dispose], 'function'); + capturedLib = handle.lib; + capturedFn = handle.functions.add_i32; + assert.strictEqual(capturedFn(20, 22), 42); + } + + assert.throws(() => capturedFn(1, 2), /Library is closed/); + assert.throws(() => capturedLib.getSymbol('add_i32'), /Library is closed/); +}); + +test('using still disposes DynamicLibrary when block throws', () => { + const sentinel = new Error('boom'); + let captured; + + assert.throws(() => { + using lib = new ffi.DynamicLibrary(libraryPath); + captured = lib.getFunction('add_i32', fixtureSymbols.add_i32); + assert.strictEqual(captured(20, 22), 42); + throw sentinel; + }, sentinel); + + assert.throws(() => captured(1, 2), /Library is closed/); +}); + test('dynamic library APIs validate failures and bad signatures', () => { assert.throws(() => { ffi.dlopen('missing-library-for-ffi-tests.so'); From 6da442440df71b8c066a01063111c13adb1a52bd Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 24 Apr 2026 16:51:11 +0200 Subject: [PATCH 2/2] ffi: simplify Symbol.dispose assignment Use a direct prototype assignment for DynamicLibrary.prototype[Symbol.dispose], matching the style used elsewhere in the codebase (e.g. Timers). --- lib/ffi.js | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/lib/ffi.js b/lib/ffi.js index d069ec4ca970bb..8478ba108611fc 100644 --- a/lib/ffi.js +++ b/lib/ffi.js @@ -1,7 +1,6 @@ 'use strict'; const { - ObjectDefineProperty, ObjectFreeze, ObjectPrototypeToString, SymbolDispose, @@ -55,14 +54,9 @@ const { toArrayBuffer, } = internalBinding('ffi'); -ObjectDefineProperty(DynamicLibrary.prototype, SymbolDispose, { - __proto__: null, - configurable: true, - writable: true, - value: function dispose() { - this.close(); - }, -}); +DynamicLibrary.prototype[SymbolDispose] = function() { + this.close(); +}; function checkFFIPermission() { if (!permission.isEnabled() || permission.has('ffi')) {