Skip to content

Commit 99a5a5c

Browse files
committed
vfs: scope-purge loader caches on deregister
Replace the global loader-cache flush in deregisterVFS with a scope-purge that only drops entries owned by the unmounting VFS. Per-layer ownership is determined two ways: - For CJS-style filename-keyed caches (Module._cache, Module._pathCache, the CJS stat cache, the helpers.js realpath cache, and the package.json caches) entries are filtered with `vfs.shouldHandle(filename)`. __filename stays a clean absolute path so user code that does `path.dirname(__filename)` or similar is unaffected. - For the ESM cascaded loader's loadCache, entries are tagged at resolve time: when finalizeResolution() detects the resolved path is VFS-owned (via the new loaderGetLayerForPath hook), it appends `?vfs-layer=<id>` to the URL. The tag surfaces in `import.meta.url`, matching the cache-busting pattern used by HMR tooling. On deregister, entries whose URL carries the tag for the unmounting layer are deleted. Multi-mount setups no longer pay the cross-VFS cache-warmup penalty when a single VFS unmounts, and ESM modules loaded from a VFS become reachable for purge instead of leaking forever in the cascaded loader. New helpers exposed for VFS: - cjs/loader.js: clearStatCacheForVFS - helpers.js: purgeRealpathCacheForVFS, loaderGetLayerForPath - package_json_reader.js: purgePackageJSONCacheForVFS Adds test-vfs-scoped-cache-purge covering both the multi-mount isolation and the import.meta.url tag visibility. Refs: #63653 Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent e01d83a commit 99a5a5c

7 files changed

Lines changed: 338 additions & 20 deletions

File tree

doc/api/vfs.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,39 @@ added: REPLACEME
184184
The current mount-point path as an absolute string, or `null` when
185185
the VFS is not mounted.
186186

187+
### `vfs.layerId`
188+
189+
<!-- YAML
190+
added: REPLACEME
191+
-->
192+
193+
* {number}
194+
195+
A per-process monotonically increasing identifier assigned at
196+
construction. The id is stable across `mount()` / `unmount()` cycles
197+
for the lifetime of the instance, and is independent of the order in
198+
which VFS layers are mounted.
199+
200+
The layer id is the building block for cache scoping (see
201+
[Module loader integration][]):
202+
203+
* it surfaces in `import.meta.url` for ES modules loaded from this
204+
VFS, as a `?vfs-layer=<id>` search parameter, so that the cascaded
205+
loader's caches can be scoped per VFS;
206+
* it appears in the `NODE_DEBUG=vfs` output for `register` and
207+
`deregister` events;
208+
* it appears in the `ERR_INVALID_STATE` error message thrown when two
209+
VFS instances try to mount at overlapping prefixes.
210+
211+
```cjs
212+
const vfs = require('node:vfs');
213+
214+
const a = vfs.create();
215+
const b = vfs.create();
216+
console.log(a.layerId); // e.g. 0
217+
console.log(b.layerId); // a.layerId + 1
218+
```
219+
187220
### `vfs.provider`
188221

189222
<!-- YAML
@@ -272,6 +305,69 @@ The promise namespace mirrors `fs.promises` and includes `readFile`,
272305
`access`, `rm`, `truncate`, `link`, `mkdtemp`, `chmod`, `chown`, `lchown`,
273306
`utimes`, `lutimes`, `open`, `lchmod`, and `watch`.
274307

308+
## Module loader integration
309+
310+
Once a `VirtualFileSystem` is mounted, paths under the mount prefix
311+
participate in module resolution and loading. Both
312+
`require()` / `require.resolve()` (CommonJS) and `import` /
313+
`import.meta.resolve()` (ECMAScript modules) consult the VFS through
314+
the same toggleable hooks that `node:fs` uses, so files served from
315+
the VFS are first-class modules: `package.json` is honoured,
316+
extensionless files are sniffed for Wasm vs. JavaScript, conditional
317+
`exports` / `imports` work, and so on.
318+
319+
```cjs
320+
const vfs = require('node:vfs');
321+
322+
const myVfs = vfs.create();
323+
myVfs.mkdirSync('/lib');
324+
myVfs.writeFileSync('/lib/greet.js', 'module.exports = () => "hi";');
325+
myVfs.writeFileSync(
326+
'/lib/package.json', '{"main": "./greet.js"}');
327+
myVfs.mount('/virtual');
328+
329+
const greet = require('/virtual/lib');
330+
console.log(greet()); // 'hi'
331+
332+
myVfs.unmount();
333+
```
334+
335+
### Cache scoping and `import.meta.url`
336+
337+
Module loaders maintain caches that survive the lifetime of any
338+
single VFS. To keep entries from leaking once a VFS is unmounted
339+
without invalidating unrelated real-fs imports, two mechanisms are
340+
combined:
341+
342+
* **CommonJS caches** (`require.cache`, the internal stat and
343+
realpath caches, and the `package.json` caches) are filtered on
344+
`unmount()`: entries whose absolute filename would be claimed by
345+
the VFS going away are deleted. `__filename` and `module.filename`
346+
are unchanged - they remain plain absolute paths.
347+
348+
* **ECMAScript module URLs** are tagged at resolve time. When the
349+
resolver determines that a path belongs to a mounted VFS, it
350+
appends `?vfs-layer=<id>` (where `<id>` is the owning instance's
351+
[`vfs.layerId`][]) to the resolved URL. The tag therefore appears
352+
in `import.meta.url` and in cache keys, and on `unmount()` the
353+
cascaded loader's caches drop just the entries that carry the tag
354+
for the unmounting layer.
355+
356+
```mjs
357+
// inside /virtual/lib/greet.mjs after the VFS above is mounted
358+
console.log(import.meta.url);
359+
// e.g. 'file:///virtual/lib/greet.mjs?vfs-layer=0'
360+
```
361+
362+
User code that compares `import.meta.url` literally should account
363+
for the search parameter; use `new URL(import.meta.url).pathname` or
364+
`fileURLToPath()` to obtain the underlying path.
365+
366+
Mounting and unmounting do not invalidate ESM modules that are
367+
already executing. As with any other module-system teardown,
368+
unmounting a VFS while the import graph below it is still loading is
369+
the caller's responsibility to avoid.
370+
275371
## Class: `VirtualProvider`
276372
277373
<!-- YAML
@@ -395,11 +491,13 @@ fields use synthetic but stable values:
395491
* Times default to the moment the entry was created/last modified.
396492
397493
[Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management
494+
[Module loader integration]: #module-loader-integration
398495
[`MemoryProvider`]: #class-memoryprovider
399496
[`VirtualFileSystem`]: #class-virtualfilesystem
400497
[`VirtualProvider`]: #class-virtualprovider
401498
[`fs.BigIntStats`]: fs.md#class-fsbigintstats
402499
[`fs.Stats`]: fs.md#class-fsstats
403500
[`node:fs`]: fs.md
501+
[`vfs.layerId`]: #vfslayerid
404502
[`vfs.mount(prefix)`]: #vfsmountprefix
405503
[`vfs.unmount()`]: #vfsunmount

lib/internal/modules/cjs/loader.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ const kFormat = Symbol('kFormat');
114114
// Set first due to cycle with ESM loader functions.
115115
module.exports = {
116116
clearStatCache,
117+
clearStatCacheForVFS,
117118
kModuleSource,
118119
kModuleExport,
119120
kModuleExportNames,
@@ -296,6 +297,22 @@ function clearStatCache() {
296297
}
297298
}
298299

300+
/**
301+
* Drop only the stat-cache entries owned by the given VFS instance.
302+
* Real-fs entries and entries owned by other VFSes are untouched.
303+
* @param {{shouldHandle: (path: string) => boolean}} vfs
304+
*/
305+
function clearStatCacheForVFS(vfs) {
306+
if (statCache === null) {
307+
return;
308+
}
309+
for (const filename of statCache.keys()) {
310+
if (vfs.shouldHandle(filename)) {
311+
statCache.delete(filename);
312+
}
313+
}
314+
}
315+
299316
let _stat = stat;
300317
ObjectDefineProperty(Module, '_stat', {
301318
__proto__: null,

lib/internal/modules/esm/resolve.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ const {
4545
const { defaultGetFormatWithoutErrors } = require('internal/modules/esm/get_format');
4646
const { getConditionsSet } = require('internal/modules/esm/utils');
4747
const packageJsonReader = require('internal/modules/package_json_reader');
48-
const { loaderLegacyMainResolve, loaderStat, toRealPath } = require('internal/modules/helpers');
48+
const {
49+
loaderGetLayerForPath,
50+
loaderLegacyMainResolve,
51+
loaderStat,
52+
toRealPath,
53+
} = require('internal/modules/helpers');
4954

5055
/**
5156
* @typedef {import('internal/modules/esm/package_config.js').PackageConfig} PackageConfig
@@ -282,6 +287,21 @@ function finalizeResolution(resolved, base, preserveSymlinks) {
282287
resolved.hash = hash;
283288
}
284289

290+
// If the resolved path is owned by an installed VFS layer, append a
291+
// `vfs-layer=N` search param so cache entries are tagged with the
292+
// owning layer. On deregister, entries matching the unmounted layer
293+
// can be scope-purged without touching unrelated real-fs imports.
294+
// The tag is surfaced in `import.meta.url`, matching the
295+
// cache-busting pattern used by HMR tooling.
296+
const layerId = loaderGetLayerForPath(path);
297+
if (layerId !== undefined) {
298+
if (resolved.search) {
299+
resolved.search += `&vfs-layer=${layerId}`;
300+
} else {
301+
resolved.search = `?vfs-layer=${layerId}`;
302+
}
303+
}
304+
285305
return resolved;
286306
}
287307

lib/internal/modules/helpers.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ let _loaderReadFile = null;
6565
let _loaderRealpath = null;
6666
let _loaderLegacyMainResolve = null;
6767
let _loaderGetFormatOfExtensionlessFile = null;
68+
let _loaderGetLayerForPath = null;
6869

6970
/**
7071
* Set override functions for the module loader's fs operations.
@@ -78,6 +79,22 @@ function setLoaderFsOverrides(overrides = kEmptyObject) {
7879
_loaderRealpath = overrides.realpath ?? null;
7980
_loaderLegacyMainResolve = overrides.legacyMainResolve ?? null;
8081
_loaderGetFormatOfExtensionlessFile = overrides.getFormatOfExtensionlessFile ?? null;
82+
_loaderGetLayerForPath = overrides.getLayerForPath ?? null;
83+
}
84+
85+
/**
86+
* If `filename` is owned by an installed VFS layer, returns that layer's
87+
* monotonically increasing id; otherwise returns `undefined`. Used by the
88+
* ESM resolver to tag resolved URLs so cache entries can be scope-purged
89+
* on VFS deregister without touching unrelated real-fs imports.
90+
* @param {string} filename Absolute path
91+
* @returns {number|undefined}
92+
*/
93+
function loaderGetLayerForPath(filename) {
94+
if (_loaderGetLayerForPath !== null) {
95+
return _loaderGetLayerForPath(filename);
96+
}
97+
return undefined;
8198
}
8299

83100
/**
@@ -179,6 +196,19 @@ function clearRealpathCache() {
179196
realpathCache.clear();
180197
}
181198

199+
/**
200+
* Drop only realpath-cache entries owned by the given VFS instance.
201+
* Entries for unrelated paths are untouched.
202+
* @param {{shouldHandle: (path: string) => boolean}} vfs
203+
*/
204+
function purgeRealpathCacheForVFS(vfs) {
205+
for (const key of realpathCache.keys()) {
206+
if (vfs.shouldHandle(key)) {
207+
realpathCache.delete(key);
208+
}
209+
}
210+
}
211+
182212
/**
183213
* Wrapper for modulesBinding.readPackageJSON that supports VFS toggle.
184214
* @param {string} jsonPath
@@ -680,6 +710,7 @@ module.exports = {
680710
addBuiltinLibsToObject,
681711
assertBufferSource,
682712
clearRealpathCache,
713+
purgeRealpathCacheForVFS,
683714
constants,
684715
enableCompileCache,
685716
flushCompileCache,
@@ -689,6 +720,7 @@ module.exports = {
689720
getCompileCacheDir,
690721
initializeCjsConditions,
691722
loaderGetFormatOfExtensionlessFile,
723+
loaderGetLayerForPath,
692724
loaderGetNearestParentPackageJSON,
693725
loaderGetPackageScopeConfig,
694726
loaderGetPackageType,

lib/internal/modules/package_json_reader.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,24 @@ function clearPackageJSONCache() {
367367
deserializedPackageJSONCache.clear();
368368
}
369369

370+
/**
371+
* Drop only package.json-cache entries owned by the given VFS instance.
372+
* Real-fs entries and other-VFS entries are untouched.
373+
* @param {{shouldHandle: (path: string) => boolean}} vfs
374+
*/
375+
function purgePackageJSONCacheForVFS(vfs) {
376+
for (const key of moduleToParentPackageJSONCache.keys()) {
377+
if (vfs.shouldHandle(key)) {
378+
moduleToParentPackageJSONCache.delete(key);
379+
}
380+
}
381+
for (const key of deserializedPackageJSONCache.keys()) {
382+
if (typeof key === 'string' && vfs.shouldHandle(key)) {
383+
deserializedPackageJSONCache.delete(key);
384+
}
385+
}
386+
}
387+
370388
module.exports = {
371389
read,
372390
getNearestParentPackageJSON,
@@ -375,4 +393,5 @@ module.exports = {
375393
getPackageJSONURL,
376394
findPackageJSON,
377395
clearPackageJSONCache,
396+
purgePackageJSONCacheForVFS,
378397
};

0 commit comments

Comments
 (0)