Skip to content

ffi: load libraries from a mounted VFS - #65909

Open
mcollina wants to merge 1 commit into
nodejs:mainfrom
mcollina:vfs-ffi-materialize
Open

ffi: load libraries from a mounted VFS#65909
mcollina wants to merge 1 commit 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.

@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

@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.03419% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.16%. Comparing base (2987a59) to head (17f29b3).
⚠️ Report is 94 commits behind head on main.

Files with missing lines Patch % Lines
src/node_ffi.cc 50.00% 6 Missing and 3 partials ⚠️
lib/internal/vfs/setup.js 87.50% 2 Missing and 2 partials ⚠️
src/node_binding.h 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65909      +/-   ##
==========================================
- Coverage   90.16%   90.16%   -0.01%     
==========================================
  Files         771      773       +2     
  Lines      265097   265578     +481     
  Branches    50358    50488     +130     
==========================================
+ Hits       239026   239453     +427     
- Misses      17011    17070      +59     
+ Partials     9060     9055       -5     
Files with missing lines Coverage Δ
lib/ffi.js 96.37% <100.00%> (+0.30%) ⬆️
lib/internal/ffi/vfs.js 100.00% <100.00%> (ø)
src/node_binding.cc 70.63% <100.00%> (-0.13%) ⬇️
src/node_binding.h 80.00% <80.00%> (ø)
lib/internal/vfs/setup.js 87.88% <87.50%> (+0.08%) ⬆️
src/node_ffi.cc 71.47% <50.00%> (-0.39%) ⬇️

... and 69 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mcollina
mcollina force-pushed the vfs-ffi-materialize branch from c994e3d to 672232a Compare September 9, 2026 04:31
@pipobscure

pipobscure commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Awesome stuff! => LGTM

@mcollina mcollina added the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Sep 10, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Sep 10, 2026
@nodejs-github-bot

This comment has been minimized.

@nodejs-github-bot

This comment has been minimized.

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.

The AddonImage materializer moves from an anonymous namespace in
node_binding.cc to node_binding.h so that node_ffi.cc can reuse it.
On Windows the image is now written and closed before the load,
because the loader shares read alone and a retained writable
delete-on-close handle failed the load with ERROR_SHARING_VIOLATION;
since a mapped image cannot be unlinked there, it is kept with the
module it loaded as and both are released at process exit. On POSIX
the image still never outlives the constructor call, so nothing is
left for dlclose() to clean up.

Also fix the VFS dlopen hook forwarding a missing flags argument as
undefined, which process.dlopen() coerces to 0 - not a valid
dlopen(2) mode - so loading any addon from the real file system
failed with EINVAL while a VFS was mounted.

Co-authored-by: Philipp Dunkel <pipobscure@users.noreply.github.com>
Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina mcollina added the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Sep 11, 2026
@github-actions github-actions Bot added request-ci-failed Starting CI with the request-ci label failed and requires manual intervention. and removed request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. labels Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor
Failed to start CI
- Validating Jenkins credentials
✔  Jenkins credentials valid
- Querying data for job/node-test-pull-request/77322/
SyntaxError: Unexpected token '<', ..."    
  https://github.com/nodejs/node/actions/runs/34634996295

@mcollina mcollina added request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. and removed request-ci-failed Starting CI with the request-ci label failed and requires manual intervention. labels Sep 11, 2026
@github-actions github-actions Bot added request-ci-failed Starting CI with the request-ci label failed and requires manual intervention. and removed request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. labels Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor
Failed to start CI
- Validating Jenkins credentials
✔  Jenkins credentials valid
- Querying data for job/node-test-pull-request/77322/
SyntaxError: Unexpected token '<', ..."    
  https://github.com/nodejs/node/actions/runs/34647251003

@mcollina mcollina added request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. and removed request-ci-failed Starting CI with the request-ci label failed and requires manual intervention. labels Sep 11, 2026
@github-actions github-actions Bot added request-ci-failed Starting CI with the request-ci label failed and requires manual intervention. and removed request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. labels Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor
Failed to start CI
- Validating Jenkins credentials
✔  Jenkins credentials valid
- Querying data for job/node-test-pull-request/77322/
SyntaxError: Unexpected token '<', ..."    
  https://github.com/nodejs/node/actions/runs/34648712529

@mcollina mcollina added request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. and removed request-ci-failed Starting CI with the request-ci label failed and requires manual intervention. labels Sep 12, 2026
@github-actions github-actions Bot added request-ci-failed Starting CI with the request-ci label failed and requires manual intervention. and removed request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. labels Sep 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor
Failed to start CI
- Validating Jenkins credentials
✔  Jenkins credentials valid
- Querying data for job/node-test-pull-request/77322/
SyntaxError: Unexpected token '<', ..."    
  https://github.com/nodejs/node/actions/runs/34683737619

@aduh95 aduh95 added author ready PRs with CI started, the required approvals, and no outstanding review comments. and removed request-ci-failed Starting CI with the request-ci label failed and requires manual intervention. labels Sep 12, 2026
@nodejs-github-bot

This comment has been minimized.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@saitanallensantiago26-beep

Copy link
Copy Markdown

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.

@saitanallensantiago26-beep

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author ready PRs with CI started, the required approvals, and no outstanding review comments. 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.

7 participants