Skip to content

ffi: load libraries from a mounted VFS - #65909

Open
mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:vfs-ffi-materialize
Open

ffi: load libraries from a mounted VFS#65909
mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:vfs-ffi-materialize

Conversation

@mcollina

@mcollina mcollina commented Sep 8, 2026

Copy link
Copy Markdown
Member

The operating system's dynamic loader cannot open a library that lives in a mounted virtual file system: the reserved mount path has no real inode. Native addons already handle this in require(): the loader hands their bytes to process.dlopen(), which loads them from a private, self-cleaning image — an anonymous in-memory memfd on Linux.

This makes ffi.dlopen() and new ffi.DynamicLibrary() do the same, transparently:

const ffi = require('node:ffi');
const path = require('node:path');

// mountPoint is a mounted VFS (or a SEA useVfs mount).
const { lib, functions } = ffi.dlopen(
  path.join(mountPoint, `mylib.${ffi.suffix}`),
  { add: { arguments: ['i32', 'i32'], return: 'i32' } },
);
  • Mirroring the fs handler integration (setVfsHandlers), the VFS hook installer sets a library reader into node:ffi while at least one VFS is mounted and clears it when the last one unmounts, so the dependency points from the VFS into ffi and ffi never loads any VFS code. The reader hands the library's bytes to the native constructor, which loads them from the same kind of private image (AddonImage, moved from an anonymous namespace in node_binding.cc to node_binding.h so node_ffi.cc can reuse it). library.path keeps reporting the virtual path.
  • Because the load happens inside the constructor, the image never outlives the call: it is unlinked right after uv_dlopen() on POSIX (in-memory memfd on Linux, so nothing touches the file system at all), and there is nothing left for dlclose() to clean up or reference-count. Windows retains the delete-on-close handle for the process lifetime, exactly as for addons.
  • Libraries on the real file system are unaffected and load directly; while no VFS is mounted the cost is a null check.
  • Loading from bytes requires file-system write permission for the temp directory on top of the FFI permission, mirroring dlopenBinary().

Bug fix included

Writing the test exposed a pre-existing bug, fixed in the first commit: the dlopen hook installed while a VFS is mounted always forwarded its flags parameter, so a two-argument process.dlopen() call for a real file-system path reached the original implementation with undefined as the flags. That coerces to 0, which is not a valid dlopen(2) mode, and loading any addon from the real file system failed with EINVAL while a VFS was mounted.

The dlopen hook installed while a VFS is mounted always forwarded its
flags parameter, so a two-argument process.dlopen() call for a real
file system path reached the original implementation with `undefined`
as the flags. That coerces to 0, which is not a valid dlopen(2) mode,
instead of applying the default flags, and loading any addon from the
real file system failed with EINVAL while a VFS was mounted.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Sep 8, 2026
@pipobscure

Copy link
Copy Markdown
Contributor

LETM (Looks Excellent To Me 😃 )

@pipobscure

pipobscure commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

The only question I have is whether we want to hide the detail that the path may need materializing inside ffi.load like we did for process.dlopen. There are pro & cons for both. And maybe someone that is using FFI is the kind of person good with handling this manually. However this is also the kind of thing that's easy to forget to do (especially if you don't think of VFS as an option). So hiding it inside ffi.load would eliminate this fault category.

And since node:ffi has both dlopen and dlclose we even have a good place to put it and do reference counting. Making a library Symbol.disposable can call dlclose which would decrement the counter on the path and on the last close we can delete the file. And then just cleanup leftovers atexit.

@mcollina
mcollina force-pushed the vfs-ffi-materialize branch from fa3e51d to dd7d5f0 Compare September 8, 2026 19:40
@mcollina mcollina changed the title vfs: add materializeSync() for FFI consumers ffi: load libraries from a mounted VFS Sep 8, 2026
@mcollina
mcollina marked this pull request as ready for review September 8, 2026 20:09
@mcollina

mcollina commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

@pipobscure updated, PTAL

The operating system's dynamic loader cannot open a library that lives
in a mounted virtual file system: the reserved mount path has no real
inode. Native addons already handle this in require(): the loader
hands their bytes to process.dlopen(), which loads them from a
private, self-cleaning image - an anonymous in-memory memfd on Linux.

Make ffi.dlopen() and new DynamicLibrary() do the same transparently.
Mirroring the fs handler integration, the VFS hook installer sets a
library reader into node:ffi while at least one VFS is mounted and
clears it when the last one unmounts; DynamicLibrary consults it
before every load, so the dependency points from the VFS into ffi and
ffi never loads any VFS code. The reader hands the library's bytes to
the native constructor, which loads them from the same kind of image,
released right after the load, while library.path keeps reporting the
virtual path. Libraries on the real file system are unaffected and
load directly, and pay only a null check while no VFS is mounted.

Since the load happens inside the constructor, the image never
outlives the call: nothing is left for dlclose() to clean up and no
temporary file lingers on POSIX. The AddonImage materializer moves
from an anonymous namespace in node_binding.cc to node_binding.h so
that node_ffi.cc can reuse it.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina
mcollina force-pushed the vfs-ffi-materialize branch from dd7d5f0 to c994e3d Compare September 8, 2026 20:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants