mirror of
https://github.com/nodejs/node.git
synced 2026-10-28 10:15:07 +08:00
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> PR-URL: https://github.com/nodejs/node/pull/65909 Reviewed-By: Paolo Insogna <paolo@cowtech.it> Reviewed-By: James M Snell <jasnell@gmail.com>
91 lines
4.1 KiB
JavaScript
91 lines
4.1 KiB
JavaScript
// Flags: --expose-internals
|
|
'use strict';
|
|
|
|
// Loading an addon from bytes materializes them into a private image so the
|
|
// dynamic loader has a real path to open. That image is transient and must not
|
|
// outlive the process that loaded it. How it is held differs by platform, so
|
|
// this checks both halves of the contract:
|
|
//
|
|
// Linux: an anonymous memfd loaded through /proc/self/fd - nothing ever
|
|
// reaches the filesystem, and AfterOpen() closes the descriptor
|
|
// once the load owns its mapping, so repeated loads must not
|
|
// accumulate open descriptors.
|
|
// other POSIX: a mkdtemp() directory unlinked and rmdir()ed right after the
|
|
// load, so nothing is left even while the process runs.
|
|
// Windows: the loader maps the file by path for the DLL's lifetime, so
|
|
// the image has to stay put; it is retained with the module it
|
|
// loaded as, and both are released at process exit.
|
|
|
|
const common = require('../common');
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
const tmpdir = require('../common/tmpdir');
|
|
|
|
const addonPath = path.join(
|
|
__dirname, '..', 'addons', 'hello-world', 'build', 'Release', 'binding.node');
|
|
if (!fs.existsSync(addonPath)) common.skip('the hello-world addon is not built');
|
|
|
|
tmpdir.refresh();
|
|
|
|
// Where a temp-file image would land: GetTempPathW() reads TMP/TEMP and
|
|
// TempDir() reads TMPDIR, so pointing all three at a directory this test owns
|
|
// keeps any image the child writes somewhere it can inspect afterwards. Linux
|
|
// normally uses a memfd and never writes here at all.
|
|
const imageDir = tmpdir.resolve('addon-images');
|
|
fs.mkdirSync(imageDir, { recursive: true });
|
|
|
|
const child = `
|
|
const fs = require('fs');
|
|
const { internalBinding } = require('internal/test/binding');
|
|
const { dlopenBinary } = internalBinding('process_methods');
|
|
const bytes = fs.readFileSync(${JSON.stringify(addonPath)});
|
|
// A path that does not exist on disk, as a VFS-resident addon would be, so
|
|
// the load can only come from the bytes and their materialized image.
|
|
const virtualPath = ${JSON.stringify(path.join(addonPath, '..', 'nowhere', 'binding.node'))};
|
|
|
|
// On Linux the image is a descriptor rather than a file, so count them: each
|
|
// load must hand its fd to the mapping and close it, leaving no growth.
|
|
const fdDir = '/proc/self/fd';
|
|
const countFds = () => {
|
|
try { return fs.readdirSync(fdDir).length; } catch { return -1; }
|
|
};
|
|
const before = countFds();
|
|
|
|
// Load repeatedly: each load materializes its own image, so a leak of an
|
|
// image, a descriptor or a retained handle shows up as growth.
|
|
for (let i = 0; i < 5; i++) {
|
|
const m = { exports: {} };
|
|
// flags undefined: keep the default dlopen(2) mode - 0 is not valid
|
|
// everywhere (glibc rejects it with EINVAL).
|
|
dlopenBinary(m, virtualPath, undefined, bytes);
|
|
if (m.exports.hello() !== 'world') throw new Error('addon did not load');
|
|
}
|
|
|
|
const after = countFds();
|
|
if (before !== -1 && after > before) {
|
|
throw new Error(\`descriptor leak: \${before} -> \${after} after 5 loads\`);
|
|
}
|
|
process.exit(0);
|
|
`;
|
|
|
|
const res = spawnSync(process.execPath, ['--expose-internals', '-e', child], {
|
|
env: { ...process.env, TMPDIR: imageDir, TMP: imageDir, TEMP: imageDir },
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
// The load itself must succeed. On Windows a retained writable handle makes the
|
|
// loader fail with ERROR_SHARING_VIOLATION ("The process cannot access the file
|
|
// because it is being used by another process").
|
|
assert.strictEqual(res.status, 0, `child failed:\n${res.stderr}`);
|
|
|
|
// Nothing an image left behind may outlive the process that created it. Match
|
|
// the shapes the two on-disk paths produce rather than requiring the directory
|
|
// to be empty, so an unrelated temp file cannot fail this.
|
|
const leftovers = fs.readdirSync(imageDir).filter(
|
|
(name) => /^nod.*\.tmp$/i.test(name) || name.startsWith('node-addon-'));
|
|
assert.deepStrictEqual(
|
|
leftovers, [],
|
|
`materialized addon image outlived the process that loaded it: ${leftovers}`);
|