Skip to content

Commit 21cb9f5

Browse files
committed
vfs: let unloaded memfd images give their fd number back
Moving each image's fd to a number no earlier image had used up one number per load, even when the image was closed again, so a process opening libraries in a loop hit the limit on open files and then needed the temp-file fallback, which a noexec or read-only temporary directory rules out. The number only has to differ from those of objects still loaded, and the loader can say which those are. So ask it with RTLD_NOLOAD, and move the fd to a higher number only while the path is taken. Holding the fd from the check through the load keeps anyone else from taking the path in between, so this needs neither a counter nor a lock. An object that dlclose() leaves loaded keeps its number, and only images loaded at the same time compete for numbers. Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
1 parent ea97424 commit 21cb9f5

4 files changed

Lines changed: 59 additions & 21 deletions

File tree

‎src/node_binding.cc‎

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -479,20 +479,23 @@ int NodeMemfdCreate(const char* name, unsigned int flags) {
479479

480480
// The dynamic loader knows a loaded object by the path it was opened with, and
481481
// 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;
482+
// file again. An image is loaded through /proc/self/fd/N and its fd is closed
483+
// right after, so the kernel can hand N to the next image while the first is
484+
// still loaded. So ask the loader, and move to a higher number while the path
485+
// is taken. Holding the fd from here through the load keeps anyone else from
486+
// taking the path in between, and an object that has really been unloaded gives
487+
// its number back, so only images loaded at the same time compete for numbers.
488+
int MoveFdToFreeImagePath(int fd) {
489+
while (true) {
490+
const std::string path = "/proc/self/fd/" + std::to_string(fd);
491+
void* loaded = dlopen(path.c_str(), RTLD_LAZY | RTLD_NOLOAD);
492+
if (loaded == nullptr) return fd;
493+
dlclose(loaded);
494+
const int moved = fcntl(fd, F_DUPFD_CLOEXEC, fd + 1);
495+
close(fd);
496+
if (moved == -1) return -1;
497+
fd = moved;
498+
}
496499
}
497500
#endif // __linux__
498501
#else // _WIN32
@@ -654,8 +657,8 @@ bool AddonImage::Materialize(const char* data, size_t len) {
654657
// reaches the filesystem, so there is no temp file to secure, unlink, or
655658
// leak, and no noexec-mount problem. Request an executable memfd (MFD_EXEC,
656659
// Linux 6.3+); retry without it on older kernels that reject the flag, then
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.
660+
// fall back to a temp file if memfd is unavailable entirely, or if so many
661+
// images are loaded that no fd number is left to load another through.
659662
#ifndef MFD_CLOEXEC
660663
#define MFD_CLOEXEC 0x0001U
661664
#endif
@@ -666,14 +669,16 @@ bool AddonImage::Materialize(const char* data, size_t len) {
666669
if (fd_ == -1 && errno == EINVAL) {
667670
fd_ = NodeMemfdCreate("node-addon", MFD_CLOEXEC);
668671
}
669-
if (fd_ != -1) fd_ = MoveFdToUnusedNumber(fd_);
670672
if (fd_ != -1) {
671673
if (!WriteAllToFd(fd_, data, len)) {
672674
errmsg_ = "could not write the native addon to an in-memory file";
673675
close(fd_);
674676
fd_ = -1;
675677
return false;
676678
}
679+
fd_ = MoveFdToFreeImagePath(fd_);
680+
}
681+
if (fd_ != -1) {
677682
path_ = "/proc/self/fd/" + std::to_string(fd_);
678683
return true;
679684
}

‎src/node_binding.h‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,8 @@ void DLOpenBinary(const v8::FunctionCallbackInfo<v8::Value>& args);
178178
// footprint each platform allows:
179179
// Linux: an anonymous in-memory memfd, loaded via /proc/self/fd/N -
180180
// 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).
181+
// after the load, and a number whose path still names a loaded
182+
// object is skipped (the loader would return that object).
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

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
const common = require('../common');
44
common.skipIfFFIMissing();
55
const assert = require('node:assert');
6+
const { spawnSync } = require('node:child_process');
67
const fs = require('node:fs');
78
const os = require('node:os');
89
const path = require('node:path');
@@ -67,6 +68,37 @@ test('library images leave no file descriptor behind', { skip: !common.isLinux }
6768
assert.deepStrictEqual(images(), []);
6869
});
6970

71+
test('closed library images give their fd numbers back', { skip: !common.isLinux }, () => {
72+
// More loads than the limit allows fds, one at a time. There is no usable
73+
// temporary directory, so this only works if each stays an in-memory image.
74+
const limit = 128;
75+
const child = `
76+
const assert = require('node:assert');
77+
const fs = require('node:fs');
78+
const path = require('node:path');
79+
const ffi = require('node:ffi');
80+
const vfs = require('node:vfs');
81+
const myVfs = vfs.create();
82+
myVfs.writeFileSync('/lib.so', fs.readFileSync(${JSON.stringify(libraryPath)}));
83+
const libPath = path.join(myVfs.mount(), 'lib.so');
84+
for (let i = 0; i < ${limit * 4}; i++) {
85+
const { lib, functions } = ffi.dlopen(libPath, {
86+
add_i32: ${JSON.stringify(fixtureSymbols.add_i32)},
87+
});
88+
assert.strictEqual(functions.add_i32(40, 2), 42);
89+
lib.close();
90+
}
91+
`;
92+
const { status, stderr } = spawnSync('/bin/sh', [
93+
'-c', `ulimit -n ${limit} && exec "$0" "$@"`,
94+
process.execPath, '--disable-warning=ExperimentalWarning', '-e', child,
95+
], {
96+
encoding: 'utf8',
97+
env: { ...process.env, TMPDIR: path.join(os.tmpdir(), 'no-such-directory') },
98+
});
99+
assert.strictEqual(status, 0, stderr);
100+
});
101+
70102
test('new ffi.DynamicLibrary() loads from a mounted VFS', () => {
71103
const lib = new ffi.DynamicLibrary(virtualPath);
72104

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ assert.notStrictEqual(fn, hello);
3333

3434
myVfs.unmount();
3535

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.
36+
// An addon on the main thread stays loaded and keeps the path it was loaded
37+
// through, so enough of them leave no fd number to load another through. Loading
38+
// has to carry on regardless, through the temp-file fallback.
3839
if (common.isLinux) {
3940
const limit = 128;
4041
const child = `

0 commit comments

Comments
 (0)