Skip to content

Commit ea97424

Browse files
committed
vfs: close memfd images right after loading
Keeping a loaded memfd open until exit gave each image a path of its own, but held one file descriptor per load for the life of the process: ffi's close() did not release it, nor did a worker's exit, nor a load that failed after dlopen(), so a process opening libraries in a loop would grow without bound. The fd is only needed until dlopen() returns, as the load's mapping keeps the memory alive from then on. What has to last is the path, and that depends only on the number. So move each image's fd to a number no earlier image has had, using F_DUPFD_CLOEXEC with a process-wide counter that only goes up, and close it right after the load again. Unlike closing it after dlclose(), this also holds for an object that dlclose() leaves loaded, and for addons on the main thread, which are never unloaded. Should the numbers run out, the image goes to a temp file. Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
1 parent a839799 commit ea97424

5 files changed

Lines changed: 89 additions & 43 deletions

File tree

‎src/node_binding.cc‎

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,24 @@ bool WriteAllToFd(int fd, const char* data, size_t len) {
476476
int NodeMemfdCreate(const char* name, unsigned int flags) {
477477
return static_cast<int>(syscall(SYS_memfd_create, name, flags));
478478
}
479+
480+
// The dynamic loader knows a loaded object by the path it was opened with, and
481+
// hands back the object already loaded under a path instead of opening the
482+
// file again. An image is loaded through /proc/self/fd/N, and its fd is closed
483+
// right after, so left to the kernel, which hands out the lowest free number,
484+
// the next image would get the same path and silently resolve to this one.
485+
// dlclose() does not settle it either: it can leave an object loaded. So each
486+
// image moves its fd to a number that no earlier image has had. Workers share
487+
// the fd table, so this is process-wide.
488+
int MoveFdToUnusedNumber(int fd) {
489+
static Mutex mutex;
490+
static int next_number = 0;
491+
Mutex::ScopedLock lock(mutex);
492+
const int moved = fcntl(fd, F_DUPFD_CLOEXEC, next_number);
493+
close(fd);
494+
if (moved != -1) next_number = moved + 1;
495+
return moved;
496+
}
479497
#endif // __linux__
480498
#else // _WIN32
481499

@@ -636,7 +654,8 @@ bool AddonImage::Materialize(const char* data, size_t len) {
636654
// reaches the filesystem, so there is no temp file to secure, unlink, or
637655
// leak, and no noexec-mount problem. Request an executable memfd (MFD_EXEC,
638656
// Linux 6.3+); retry without it on older kernels that reject the flag, then
639-
// fall back to a temp file if memfd is unavailable entirely.
657+
// fall back to a temp file if memfd is unavailable entirely, or if the fd
658+
// numbers no image has had yet have run out.
640659
#ifndef MFD_CLOEXEC
641660
#define MFD_CLOEXEC 0x0001U
642661
#endif
@@ -647,6 +666,7 @@ bool AddonImage::Materialize(const char* data, size_t len) {
647666
if (fd_ == -1 && errno == EINVAL) {
648667
fd_ = NodeMemfdCreate("node-addon", MFD_CLOEXEC);
649668
}
669+
if (fd_ != -1) fd_ = MoveFdToUnusedNumber(fd_);
650670
if (fd_ != -1) {
651671
if (!WriteAllToFd(fd_, data, len)) {
652672
errmsg_ = "could not write the native addon to an in-memory file";
@@ -709,25 +729,17 @@ bool AddonImage::MaterializeTempFile(const char* data, size_t len) {
709729

710730
void AddonImage::AfterOpen(bool opened, void* module) {
711731
consumed_ = true;
712-
// The module never has to be unloaded here: Node keeps addons loaded for
713-
// the life of the process, and an FFI library is closed by its owner.
732+
// The right cleanup is the same whether or not the load worked, and the
733+
// module never has to be unloaded: the name is already gone by now.
734+
(void)opened;
714735
(void)module;
736+
// memfd: the load's mapping (or nothing, on failure) owns it from here.
715737
if (fd_ != -1) {
716-
// The dynamic loader knows a loaded object by the path it was opened
717-
// with, and hands back the object already loaded under a path instead of
718-
// opening the file again. Closing a loaded memfd would free its number
719-
// for the next image, which would then be loaded through the very same
720-
// /proc/self/fd/N and silently resolve to this one. So a memfd that was
721-
// loaded stays open, keeping its path to itself, for the life of the
722-
// process; MFD_CLOEXEC keeps it out of child processes. A failed load
723-
// left nothing under that path, so its number can go back.
724-
if (!opened) close(fd_);
738+
close(fd_);
725739
fd_ = -1;
726740
return;
727741
}
728742
if (!temp_dir_.empty()) {
729-
// A temp file can go at once: it lives in a fresh mkdtemp() directory,
730-
// so its path is not handed out again the way an fd number is.
731743
// On success the mapping keeps the inode alive, so unlinking now leaves no
732744
// on-disk trace; on failure this just cleans up.
733745
unlink(path_.c_str());

‎src/node_binding.h‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,9 @@ void DLOpenBinary(const v8::FunctionCallbackInfo<v8::Value>& args);
177177
// dlopen()/LoadLibrary() can load, with the smallest, most private on-disk
178178
// footprint each platform allows:
179179
// Linux: an anonymous in-memory memfd, loaded via /proc/self/fd/N -
180-
// the bytes never touch the filesystem. A loaded memfd is kept
181-
// open until exit, so no later image reuses its path (the
182-
// loader would return the earlier object by that name).
180+
// the bytes never touch the filesystem. The fd is closed right
181+
// after the load, and its number is not used for another image
182+
// (the loader would return the earlier object by that path).
183183
// other POSIX: a 0700 mkdtemp() directory plus an O_EXCL|O_NOFOLLOW file,
184184
// unlink()ed right after the load (the mapping keeps it alive).
185185
// Windows: a temp file, written and closed before the load because the
@@ -209,9 +209,8 @@ class AddonImage {
209209

210210
// Call exactly once, right after the load; `opened` says whether the load
211211
// succeeded and `module` is the module handle it produced. Releases what is
212-
// no longer needed: on POSIX unlinks the temp file, which a successful load
213-
// keeps alive through its own mapping, and closes the memfd only when the
214-
// load failed, keeping a loaded one's path unique until exit. Windows cannot
212+
// no longer needed: on POSIX closes the memfd or unlinks the temp file, which
213+
// a successful load keeps alive through its own mapping. Windows cannot
215214
// unlink a mapped image, so there the file is removed at once only when the
216215
// load failed; otherwise it is kept, with `module`, until process exit, where
217216
// the module is unloaded and the file finally deleted.

‎test/ffi/test-ffi-vfs.js‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,27 @@ test('ffi.dlopen() loads a library from a mounted VFS', () => {
4646
}
4747
});
4848

49+
test('library images leave no file descriptor behind', { skip: !common.isLinux }, () => {
50+
const images = () => fs.readdirSync('/proc/self/fd').filter((fd) => {
51+
try {
52+
return fs.readlinkSync(`/proc/self/fd/${fd}`).startsWith('/memfd:node-addon');
53+
} catch {
54+
return false;
55+
}
56+
});
57+
58+
const { lib, functions } = ffi.dlopen(virtualPath, {
59+
add_i32: fixtureSymbols.add_i32,
60+
});
61+
try {
62+
assert.deepStrictEqual(images(), []);
63+
assert.strictEqual(functions.add_i32(40, 2), 42);
64+
} finally {
65+
lib.close();
66+
}
67+
assert.deepStrictEqual(images(), []);
68+
});
69+
4970
test('new ffi.DynamicLibrary() loads from a mounted VFS', () => {
5071
const lib = new ffi.DynamicLibrary(virtualPath);
5172

‎test/parallel/test-dlopen-binary-image-cleanup.js‎

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,8 @@
77
// this checks both halves of the contract:
88
//
99
// Linux: an anonymous memfd loaded through /proc/self/fd - nothing ever
10-
// reaches the filesystem. A loaded memfd stays open until exit,
11-
// close-on-exec, so that no later image is loaded through the
12-
// same /proc/self/fd/N path (the loader would return this one);
13-
// a failed load closes its memfd again.
10+
// reaches the filesystem, and the fd is closed right after the
11+
// load, whether or not it worked.
1412
// other POSIX: a mkdtemp() directory unlinked and rmdir()ed right after the
1513
// load, so nothing is left even while the process runs.
1614
// Windows: the loader maps the file by path for the DLL's lifetime, so
@@ -46,8 +44,8 @@ const child = `
4644
// the load can only come from the bytes and their materialized image.
4745
const virtualPath = ${JSON.stringify(path.join(addonPath, '..', 'nowhere', 'binding.node'))};
4846
49-
// On Linux the image is a memfd rather than a file, so count those: each
50-
// successful load keeps exactly one, close-on-exec, and a failed load none.
47+
// On Linux the image is a memfd rather than a file, so count those: no load
48+
// may keep one.
5149
const fdDir = '/proc/self/fd';
5250
const imageFds = () => {
5351
let fds;
@@ -58,7 +56,6 @@ const child = `
5856
} catch { return false; }
5957
});
6058
};
61-
const before = imageFds();
6259
6360
// Load repeatedly: each load materializes its own image, so a leak of an
6461
// image or a retained handle shows up as growth.
@@ -70,7 +67,7 @@ const child = `
7067
dlopenBinary(m, virtualPath, undefined, bytes);
7168
if (m.exports.hello() !== 'world') throw new Error('addon did not load');
7269
}
73-
const loaded = imageFds();
70+
const afterLoads = imageFds();
7471
7572
for (let i = 0; i < loads; i++) {
7673
try {
@@ -80,23 +77,14 @@ const child = `
8077
if (err.code !== 'ERR_DLOPEN_FAILED') throw err;
8178
}
8279
}
83-
const failed = imageFds();
80+
const afterFailures = imageFds();
8481
85-
if (before !== null) {
86-
const kept = loaded.filter((fd) => !before.includes(fd));
87-
// Zero when memfd is unavailable and the temp-file fallback was used.
88-
if (kept.length !== 0 && kept.length !== loads) {
89-
throw new Error(\`expected \${loads} retained images, got \${kept.length}\`);
82+
if (afterLoads !== null) {
83+
if (afterLoads.length !== 0) {
84+
throw new Error(\`loads kept images: \${afterLoads}\`);
9085
}
91-
if (failed.length !== loaded.length) {
92-
throw new Error(\`failed loads kept images: \${loaded} -> \${failed}\`);
93-
}
94-
for (const fd of kept) {
95-
const info = fs.readFileSync(\`/proc/self/fdinfo/\${fd}\`, 'utf8');
96-
const flags = parseInt(/^flags:\\s*([0-7]+)/m.exec(info)[1], 8);
97-
if ((flags & 0o2000000) === 0) { // O_CLOEXEC
98-
throw new Error(\`retained image fd \${fd} is not close-on-exec\`);
99-
}
86+
if (afterFailures.length !== 0) {
87+
throw new Error(\`failed loads kept images: \${afterFailures}\`);
10088
}
10189
}
10290
process.exit(0);

‎test/parallel/test-vfs-addon-sequence.js‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
'use strict';
33
const common = require('../common');
44
const assert = require('node:assert');
5+
const { spawnSync } = require('node:child_process');
56
const fs = require('node:fs');
67
const path = require('node:path');
78
const vfs = require('node:vfs');
@@ -31,3 +32,28 @@ assert.strictEqual(fn(), 'world');
3132
assert.notStrictEqual(fn, hello);
3233

3334
myVfs.unmount();
35+
36+
// Every image takes an fd number no earlier image had, so a low limit runs out
37+
// of numbers long before it runs out of fds. Loading has to carry on regardless.
38+
if (common.isLinux) {
39+
const limit = 128;
40+
const child = `
41+
const assert = require('node:assert');
42+
const fs = require('node:fs');
43+
const path = require('node:path');
44+
const vfs = require('node:vfs');
45+
const myVfs = vfs.create();
46+
myVfs.writeFileSync('/function.node', fs.readFileSync(${JSON.stringify(functionPath)}));
47+
const mountPoint = myVfs.mount();
48+
for (let i = 0; i < ${limit * 2}; i++) {
49+
const addonPath = path.join(mountPoint, 'function.node');
50+
assert.strictEqual(require(addonPath)(), 'world');
51+
delete require.cache[addonPath];
52+
}
53+
`;
54+
const { status, stderr } = spawnSync('/bin/sh', [
55+
'-c', `ulimit -n ${limit} && exec "$0" "$@"`,
56+
process.execPath, '--disable-warning=ExperimentalWarning', '-e', child,
57+
], { encoding: 'utf8' });
58+
assert.strictEqual(status, 0, stderr);
59+
}

0 commit comments

Comments
 (0)