Skip to content

Commit 310bc02

Browse files
committed
vfs: mount inside a reserved namespace
Move VFS mounts into `${os.devNull}/vfs/layer-<id>/<prefix>`, a reserved location that cannot exist on the real file system because `os.devNull` is a character device on POSIX and a device-namespace path on Windows - neither can have child filesystem entries. Virtual paths never conflate with real paths, and the owning layer of a path is decidable by inspecting the path itself. The prefix passed to `vfs.mount(prefix)` becomes a purely logical name inside the namespace - it is never resolved against `process.cwd()`. `mount()` returns the resulting absolute mount point. Two instances using the same prefix cannot collide, so overlap detection is gone. Because the namespace makes ownership self-describing, this removes: - the per-VFS `ownedFilenames` / `ownedPathCacheKeys` sets; - the `Module._pathCache` write recorder; - the `?vfs-layer=<id>` search-param tagging in the ESM resolver; - the Windows drive-less-URL fixup in finalizeResolution. Dispatch collapses to one normalization, one prefix check against the namespace root, and a map lookup on the layer id - constant time in the number of mounted layers. Unmount purges loader caches by a prefix scan of the mount point, which fixes the two correctness gaps flagged in review: symlink realpaths inside a VFS always resolve under the mount point, so cached entries under any symlinked realpath are purged; and resolved URLs are plain file URLs under the mount point, so `import(import.meta.resolve(x))` hits the same module job on repeat. `os.devNull` was chosen over `process.execPath` because it is ASCII-clean by definition. This keeps mount points safe to embed in dynamic-`import()` specifiers even when Node.js itself is installed under a directory whose name contains URL-reserved characters (a case exercised by the "linux-arm" CI job that runs from a path containing `?` and other unusual characters). `fs.statSync` / `fs.lstatSync` now catch ENOENT from the VFS handler when `throwIfNoEntry` is false, matching the native binding contract. The VFS handler surfaces ENOENT unconditionally for missing entries; the fall-through to the real file system would otherwise traverse `os.devNull` and produce ENOTDIR. Docs, tests and benchmark adapted. `benchmark/vfs/bench-fs-dispatch.js` now reports flat per-call latency across 1..10 mounted layers, versus ~80 ns/layer for stat/exists/access and ~200 ns/layer for readFile before this change. Refs: https://github.com/nodejs/single-executable/blob/main/docs/virtual-file-system-requirements.md#no-interference-with-valid-paths-in-the-file-system Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 133a8a1 commit 310bc02

61 files changed

Lines changed: 860 additions & 1099 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

benchmark/vfs/bench-fs-dispatch.js

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99
// binding.op(getValidatedPath(path));
1010
//
1111
// With layers=0 the VFS module is never required and `h === null` is
12-
// the first thing fs sees. With layers>=1 the handler walks
13-
// activeVFSList calling vfs.shouldHandle(path) on each. The benchmark
14-
// mounts N VFSes at distinct, unrelated mount points and probes a real
15-
// file under __dirname, so every call falls through after the VFS list
16-
// declines the path. That isolates per-layer dispatch cost.
12+
// the first thing fs sees. With layers>=1 the handler normalizes the
13+
// path once and rejects it with a single prefix comparison against the
14+
// reserved VFS namespace - the number of mounted layers should not
15+
// matter. The benchmark mounts N VFSes and probes a real file under
16+
// __dirname, so every call falls through after the namespace check
17+
// declines the path.
1718

1819
const common = require('../common.js');
1920
const fs = require('fs');
@@ -23,7 +24,7 @@ const bench = common.createBenchmark(main, {
2324
n: [3e5],
2425
op: ['statSync', 'existsSync', 'accessSync', 'readFileSync'],
2526
// 0 = VFS module never loaded (true baseline)
26-
// >=1 = that many VFS instances mounted at unrelated paths
27+
// >=1 = that many VFS instances mounted
2728
layers: [0, 1, 2, 5, 10],
2829
}, {
2930
flags: ['--experimental-vfs', '--no-warnings'],
@@ -34,7 +35,7 @@ function mountLayers(count) {
3435
const handles = [];
3536
for (let i = 0; i < count; i++) {
3637
const v = vfs.create();
37-
v.mount(`/vfs-bench-${i}`);
38+
v.mount('/bench');
3839
handles.push(v);
3940
}
4041
return handles;

doc/api/vfs.md

Lines changed: 53 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -112,39 +112,50 @@ added: v26.4.0
112112
* `emitExperimentalWarning` {boolean} Whether to emit the experimental
113113
warning. **Default:** `true`.
114114

115-
### `vfs.mount(prefix)`
115+
### `vfs.mount([prefix])`
116116

117117
<!-- YAML
118118
added: REPLACEME
119119
-->
120120

121-
* `prefix` {string} The path prefix where the VFS will be mounted.
122-
* Returns: {VirtualFileSystem} The VFS instance, for chaining or `using`.
123-
124-
Mounts the virtual file system at the specified path prefix. After
125-
mounting, files in the VFS can be accessed through the `node:fs`
126-
module — and resolved through `require()` and `import` — using paths
127-
that start with the prefix.
128-
129-
If a real file-system path already exists at the mount prefix, the
130-
VFS **shadows** that path: every operation against a path under the
131-
mount point is directed to the VFS until the VFS is unmounted.
121+
* `prefix` {string} A logical name for the mount inside the reserved
122+
VFS namespace. Interpreted as a relative path; leading separators
123+
are ignored. **Default:** `'/'`.
124+
* Returns: {string} The absolute mount point.
125+
126+
Mounts the virtual file system and returns the resulting mount point.
127+
After mounting, files in the VFS can be accessed through the
128+
`node:fs` module — and resolved through `require()` and `import`
129+
using paths under the returned mount point.
130+
131+
Mount points always live inside a reserved namespace,
132+
`${os.devNull}/vfs/layer-<layerId>/`. Because [`os.devNull`][] is a
133+
character device (POSIX) or a device-namespace path (Windows) that
134+
cannot have child filesystem entries, no real file-system path can
135+
exist under this namespace: virtual paths never conflate with (or
136+
shadow) real paths, and the layer that owns a path is visible in the
137+
path itself. The `prefix` argument is a purely logical name inside
138+
the namespace — it is never resolved against the working directory,
139+
and a prefix that attempts to escape the namespace (for example with
140+
`..` segments) throws `ERR_INVALID_ARG_VALUE`.
132141

133142
```cjs
134143
const vfs = require('node:vfs');
135144
const fs = require('node:fs');
136145

137146
const myVfs = vfs.create();
138147
myVfs.writeFileSync('/data.txt', 'Hello');
139-
myVfs.mount('/virtual');
148+
const mountPoint = myVfs.mount('/virtual');
149+
// e.g. '/dev/null/vfs/layer-0/virtual'
140150

141-
fs.readFileSync('/virtual/data.txt', 'utf8'); // 'Hello'
151+
fs.readFileSync(`${mountPoint}/data.txt`, 'utf8'); // 'Hello'
142152
```
143153

144154
Each `VirtualFileSystem` instance may be mounted at most once at a
145155
time. Attempting to mount an already-mounted instance throws
146-
`ERR_INVALID_STATE`. Mounting two instances at overlapping prefixes
147-
(e.g., `/virtual` and `/virtual/sub`) also throws `ERR_INVALID_STATE`.
156+
`ERR_INVALID_STATE`. Because each instance mounts inside its own
157+
`layer-<layerId>` namespace, mounts from different instances can
158+
never overlap, even when they use the same `prefix`.
148159

149160
The VFS supports the [Explicit Resource Management][] proposal. Use
150161
a `using` declaration to unmount automatically when leaving scope:
@@ -153,15 +164,16 @@ a `using` declaration to unmount automatically when leaving scope:
153164
const vfs = require('node:vfs');
154165
const fs = require('node:fs');
155166

167+
let mountPoint;
156168
{
157169
using myVfs = vfs.create();
158170
myVfs.writeFileSync('/data.txt', 'Hello');
159-
myVfs.mount('/virtual');
171+
mountPoint = myVfs.mount('/virtual');
160172

161-
fs.readFileSync('/virtual/data.txt', 'utf8'); // 'Hello'
173+
fs.readFileSync(`${mountPoint}/data.txt`, 'utf8'); // 'Hello'
162174
} // VFS is automatically unmounted here
163175

164-
fs.existsSync('/virtual/data.txt'); // false
176+
fs.existsSync(`${mountPoint}/data.txt`); // false
165177
```
166178

167179
### `vfs.unmount()`
@@ -196,8 +208,9 @@ added: REPLACEME
196208

197209
* {string | null}
198210

199-
The current mount-point path as an absolute string, or `null` when
200-
the VFS is not mounted.
211+
The current mount point as an absolute string (the value returned by
212+
the last [`vfs.mount(prefix)`][] call), or `null` when the VFS is not
213+
mounted.
201214

202215
### `vfs.layerId`
203216

@@ -212,16 +225,10 @@ construction. The id is stable across `mount()` / `unmount()` cycles
212225
for the lifetime of the instance, and is independent of the order in
213226
which VFS layers are mounted.
214227

215-
The layer id is the building block for cache scoping (see
216-
[Module loader integration][]):
217-
218-
* it surfaces in `import.meta.url` for ES modules loaded from this
219-
VFS, as a `?vfs-layer=<id>` search parameter, so that the cascaded
220-
loader's caches can be scoped per VFS;
221-
* it appears in the `NODE_DEBUG=vfs` output for `register` and
222-
`deregister` events;
223-
* it appears in the `ERR_INVALID_STATE` error message thrown when two
224-
VFS instances try to mount at overlapping prefixes.
228+
The layer id forms the `layer-<id>` segment of the reserved mount
229+
namespace, so every path served by this instance carries the id, and
230+
it appears in the `NODE_DEBUG=vfs` output for `register` and
231+
`deregister` events.
225232

226233
```cjs
227234
const vfs = require('node:vfs');
@@ -322,7 +329,7 @@ The promise namespace mirrors `fs.promises` and includes `readFile`,
322329

323330
## Module loader integration
324331

325-
Once a `VirtualFileSystem` is mounted, paths under the mount prefix
332+
Once a `VirtualFileSystem` is mounted, paths under the mount point
326333
participate in module resolution and loading. Both
327334
`require()` / `require.resolve()` (CommonJS) and `import` /
328335
`import.meta.resolve()` (ECMAScript modules) consult the VFS through
@@ -339,44 +346,26 @@ myVfs.mkdirSync('/lib');
339346
myVfs.writeFileSync('/lib/greet.js', 'module.exports = () => "hi";');
340347
myVfs.writeFileSync(
341348
'/lib/package.json', '{"main": "./greet.js"}');
342-
myVfs.mount('/virtual');
349+
const mountPoint = myVfs.mount('/virtual');
343350

344-
const greet = require('/virtual/lib');
351+
const greet = require(`${mountPoint}/lib`);
345352
console.log(greet()); // 'hi'
346353

347354
myVfs.unmount();
348355
```
349356

350-
### Cache scoping and `import.meta.url`
351-
352-
Module loaders maintain caches that survive the lifetime of any
353-
single VFS. To keep entries from leaking once a VFS is unmounted
354-
without invalidating unrelated real-fs imports, two mechanisms are
355-
combined:
356-
357-
* **CommonJS caches** (`require.cache`, the internal stat and
358-
realpath caches, and the `package.json` caches) are filtered on
359-
`unmount()`: entries whose absolute filename would be claimed by
360-
the VFS going away are deleted. `__filename` and `module.filename`
361-
are unchanged - they remain plain absolute paths.
362-
363-
* **ECMAScript module URLs** are tagged at resolve time. When the
364-
resolver determines that a path belongs to a mounted VFS, it
365-
appends `?vfs-layer=<id>` (where `<id>` is the owning instance's
366-
[`vfs.layerId`][]) to the resolved URL. The tag therefore appears
367-
in `import.meta.url` and in cache keys, and on `unmount()` the
368-
cascaded loader's caches drop just the entries that carry the tag
369-
for the unmounting layer.
370-
371-
```mjs
372-
// inside /virtual/lib/greet.mjs after the VFS above is mounted
373-
console.log(import.meta.url);
374-
// e.g. 'file:///virtual/lib/greet.mjs?vfs-layer=0'
375-
```
357+
Module identity follows the path: `__filename`, `module.filename`,
358+
and `import.meta.url` are the plain absolute path (or `file:` URL) of
359+
the module under the mount point, with no synthetic decorations.
360+
Importing the same virtual path repeatedly — including through
361+
`import.meta.resolve()` — yields the same module instance, exactly as
362+
for real files.
376363

377-
User code that compares `import.meta.url` literally should account
378-
for the search parameter; use `new URL(import.meta.url).pathname` or
379-
`fileURLToPath()` to obtain the underlying path.
364+
Calling [`vfs.unmount()`][] invalidates the modules that were loaded
365+
from the mount point: a subsequent `require()` or `import` of a path
366+
under a re-created mount re-reads the file from the newly mounted
367+
VFS rather than returning a stale module. Modules loaded from other
368+
VFS instances or from the real file system are unaffected.
380369

381370
Mounting and unmounting do not invalidate ESM modules that are
382371
already executing. As with any other module-system teardown,
@@ -507,14 +496,13 @@ fields use synthetic but stable values:
507496
* Times default to the moment the entry was created/last modified.
508497

509498
[Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management
510-
[Module loader integration]: #module-loader-integration
511499
[`MemoryProvider`]: #class-memoryprovider
512500
[`RealFSProvider`]: #class-realfsprovider
513501
[`VirtualFileSystem`]: #class-virtualfilesystem
514502
[`VirtualProvider`]: #class-virtualprovider
515503
[`fs.BigIntStats`]: fs.md#class-fsbigintstats
516504
[`fs.Stats`]: fs.md#class-fsstats
517505
[`node:fs`]: fs.md
518-
[`vfs.layerId`]: #vfslayerid
506+
[`os.devNull`]: os.md#osdevnull
519507
[`vfs.mount(prefix)`]: #vfsmountprefix
520508
[`vfs.unmount()`]: #vfsunmount

lib/fs.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2044,8 +2044,13 @@ function fstatSync(fd, options = { __proto__: null, bigint: false }) {
20442044
function lstatSync(path, options = { __proto__: null, bigint: false, throwIfNoEntry: true }) {
20452045
const h = vfsState.handlers;
20462046
if (h !== null) {
2047-
const result = h.lstatSync(path, options);
2048-
if (result !== undefined) return result;
2047+
try {
2048+
const result = h.lstatSync(path, options);
2049+
if (result !== undefined) return result;
2050+
} catch (err) {
2051+
if (err?.code === 'ENOENT' && options?.throwIfNoEntry === false) return;
2052+
throw err;
2053+
}
20492054
}
20502055
path = getValidatedPath(path);
20512056
if (permission.isEnabled() && !permission.has('fs.read', path)) {
@@ -2078,8 +2083,13 @@ function lstatSync(path, options = { __proto__: null, bigint: false, throwIfNoEn
20782083
function statSync(path, options = { __proto__: null, bigint: false, throwIfNoEntry: true }) {
20792084
const h = vfsState.handlers;
20802085
if (h !== null) {
2081-
const result = h.statSync(path, options);
2082-
if (result !== undefined) return result;
2086+
try {
2087+
const result = h.statSync(path, options);
2088+
if (result !== undefined) return result;
2089+
} catch (err) {
2090+
if (err?.code === 'ENOENT' && options?.throwIfNoEntry === false) return undefined;
2091+
throw err;
2092+
}
20832093
}
20842094
const stats = binding.stat(
20852095
getValidatedPath(path),

0 commit comments

Comments
 (0)