|
7 | 7 | // this checks both halves of the contract: |
8 | 8 | // |
9 | 9 | // Linux: an anonymous memfd loaded through /proc/self/fd - nothing ever |
10 | | -// reaches the filesystem, and AfterOpen() closes the descriptor |
11 | | -// once the load owns its mapping, so repeated loads must not |
12 | | -// accumulate open descriptors. |
| 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. |
13 | 14 | // other POSIX: a mkdtemp() directory unlinked and rmdir()ed right after the |
14 | 15 | // load, so nothing is left even while the process runs. |
15 | 16 | // Windows: the loader maps the file by path for the DLL's lifetime, so |
@@ -45,27 +46,58 @@ const child = ` |
45 | 46 | // the load can only come from the bytes and their materialized image. |
46 | 47 | const virtualPath = ${JSON.stringify(path.join(addonPath, '..', 'nowhere', 'binding.node'))}; |
47 | 48 |
|
48 | | - // On Linux the image is a descriptor rather than a file, so count them: each |
49 | | - // load must hand its fd to the mapping and close it, leaving no growth. |
| 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. |
50 | 51 | const fdDir = '/proc/self/fd'; |
51 | | - const countFds = () => { |
52 | | - try { return fs.readdirSync(fdDir).length; } catch { return -1; } |
| 52 | + const imageFds = () => { |
| 53 | + let fds; |
| 54 | + try { fds = fs.readdirSync(fdDir); } catch { return null; } |
| 55 | + return fds.filter((fd) => { |
| 56 | + try { |
| 57 | + return fs.readlinkSync(\`\${fdDir}/\${fd}\`).startsWith('/memfd:node-addon'); |
| 58 | + } catch { return false; } |
| 59 | + }); |
53 | 60 | }; |
54 | | - const before = countFds(); |
| 61 | + const before = imageFds(); |
55 | 62 |
|
56 | 63 | // Load repeatedly: each load materializes its own image, so a leak of an |
57 | | - // image, a descriptor or a retained handle shows up as growth. |
58 | | - for (let i = 0; i < 5; i++) { |
| 64 | + // image or a retained handle shows up as growth. |
| 65 | + const loads = 5; |
| 66 | + for (let i = 0; i < loads; i++) { |
59 | 67 | const m = { exports: {} }; |
60 | 68 | // flags undefined: keep the default dlopen(2) mode - 0 is not valid |
61 | 69 | // everywhere (glibc rejects it with EINVAL). |
62 | 70 | dlopenBinary(m, virtualPath, undefined, bytes); |
63 | 71 | if (m.exports.hello() !== 'world') throw new Error('addon did not load'); |
64 | 72 | } |
| 73 | + const loaded = imageFds(); |
65 | 74 |
|
66 | | - const after = countFds(); |
67 | | - if (before !== -1 && after > before) { |
68 | | - throw new Error(\`descriptor leak: \${before} -> \${after} after 5 loads\`); |
| 75 | + for (let i = 0; i < loads; i++) { |
| 76 | + try { |
| 77 | + dlopenBinary({ exports: {} }, virtualPath, undefined, Buffer.from('nope')); |
| 78 | + throw new Error('loading garbage bytes did not fail'); |
| 79 | + } catch (err) { |
| 80 | + if (err.code !== 'ERR_DLOPEN_FAILED') throw err; |
| 81 | + } |
| 82 | + } |
| 83 | + const failed = imageFds(); |
| 84 | +
|
| 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}\`); |
| 90 | + } |
| 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 | + } |
| 100 | + } |
69 | 101 | } |
70 | 102 | process.exit(0); |
71 | 103 | `; |
|
0 commit comments