Skip to content

Commit 75bb5c7

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 294a19c commit 75bb5c7

6 files changed

Lines changed: 240 additions & 20 deletions

File tree

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,
@@ -291,6 +292,22 @@ function clearStatCache() {
291292
}
292293
}
293294

295+
/**
296+
* Drop only the stat-cache entries owned by the given VFS instance.
297+
* Real-fs entries and entries owned by other VFSes are untouched.
298+
* @param {{shouldHandle: (path: string) => boolean}} vfs
299+
*/
300+
function clearStatCacheForVFS(vfs) {
301+
if (statCache === null) {
302+
return;
303+
}
304+
for (const filename of statCache.keys()) {
305+
if (vfs.shouldHandle(filename)) {
306+
statCache.delete(filename);
307+
}
308+
}
309+
}
310+
294311
let _stat = stat;
295312
ObjectDefineProperty(Module, '_stat', {
296313
__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
@@ -281,6 +286,21 @@ function finalizeResolution(resolved, base, preserveSymlinks) {
281286
resolved.hash = hash;
282287
}
283288

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

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
};

lib/internal/vfs/setup.js

Lines changed: 96 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ const {
77
ArrayPrototypeSplice,
88
JSONParse,
99
JSONStringify,
10+
ObjectKeys,
1011
PromiseResolve,
1112
String,
1213
StringPrototypeEndsWith,
14+
StringPrototypeIncludes,
1315
StringPrototypeStartsWith,
1416
} = primordials;
1517

@@ -101,34 +103,101 @@ function deregisterVFS(vfs) {
101103
if (index === -1) return;
102104
ArrayPrototypeSplice(activeVFSList, index, 1);
103105
debug('deregister layer=%d active=%d', vfs.layerId, activeVFSList.length);
104-
// Loader/path caches are shared across all VFSes and we can't tell
105-
// which entries belonged to the one going away, so flush them on
106-
// every unmount. The cost is bounded; correctness wins.
107-
clearLoaderCaches();
106+
// Scope-purge: only drop loader-cache entries that belong to the VFS
107+
// that is going away. Other VFSes and real-fs imports are untouched.
108+
purgeLoaderCachesForVFS(vfs);
108109
if (activeVFSList.length === 0) {
109110
uninstallHooks();
110111
}
111112
}
112113

113114
/**
114-
* Clear every JS-reachable loader cache that could hold a VFS-resolved
115-
* entry. Called from deregisterVFS only when the last VFS unmounts.
115+
* Returns true if `cjsFilename` is a path that `vfs` claims via
116+
* `shouldHandle()`. Used to filter CJS cache entries on deregister.
117+
* @param {object} vfs
118+
* @param {string} cjsFilename
119+
* @returns {boolean}
116120
*/
117-
function clearLoaderCaches() {
121+
function isOwnedByVFS(vfs, cjsFilename) {
122+
if (typeof cjsFilename !== 'string') return false;
123+
try {
124+
return vfs.shouldHandle(resolve(cjsFilename));
125+
} catch {
126+
return false;
127+
}
128+
}
129+
130+
/**
131+
* Returns true if `url` carries the `vfs-layer=<layerId>` tag emitted by
132+
* esm/resolve.js for the given VFS layer. Used to filter ESM cache
133+
* entries on deregister.
134+
* @param {string} url
135+
* @param {number} layerId
136+
* @returns {boolean}
137+
*/
138+
function urlBelongsToLayer(url, layerId) {
139+
if (typeof url !== 'string') return false;
140+
return StringPrototypeIncludes(url, `vfs-layer=${layerId}`);
141+
}
142+
143+
/**
144+
* Drop the cache entries owned by `vfs` from the JS-reachable loader
145+
* caches. Real-fs entries and other-VFS entries are left in place.
146+
* @param {object} vfs The VFS being deregistered
147+
*/
148+
function purgeLoaderCachesForVFS(vfs) {
149+
const layerId = vfs.layerId;
150+
151+
// CJS module cache, keyed by absolute filename.
118152
const cjsLoader = require('internal/modules/cjs/loader');
119-
cjsLoader.Module._pathCache = { __proto__: null };
120-
cjsLoader.clearStatCache();
153+
const moduleCache = cjsLoader.Module._cache;
154+
for (const filename of ObjectKeys(moduleCache)) {
155+
if (isOwnedByVFS(vfs, filename)) {
156+
delete moduleCache[filename];
157+
}
158+
}
159+
160+
// CJS path cache: keyed by request + parent paths string. The
161+
// cached value is the resolved filename, so filter on that.
162+
const pathCache = cjsLoader.Module._pathCache;
163+
for (const key of ObjectKeys(pathCache)) {
164+
if (isOwnedByVFS(vfs, pathCache[key])) {
165+
delete pathCache[key];
166+
}
167+
}
168+
169+
// CJS stat cache: keys are absolute filenames.
170+
cjsLoader.clearStatCacheForVFS(vfs);
171+
172+
// Realpath cache used by helpers.toRealPath: keys are absolute paths.
121173
const helpers = require('internal/modules/helpers');
122-
helpers.clearRealpathCache();
123-
const { clearPackageJSONCache } = require('internal/modules/package_json_reader');
124-
clearPackageJSONCache();
125-
// The ESM cascaded loader's loadCache is intentionally NOT cleared here:
126-
// clearing it mid-flight (while another import() is awaiting nested
127-
// resolution) would let a second ModuleJob be created for the same URL,
128-
// breaking module identity. The trade-off is that an ESM module already
129-
// loaded from a VFS path remains cached after unmount and across a
130-
// re-mount with different content, consistent with how ESM caches
131-
// modules everywhere else in Node.js.
174+
helpers.purgeRealpathCacheForVFS(vfs);
175+
176+
// package.json caches: keyed by absolute path.
177+
const pkgReader = require('internal/modules/package_json_reader');
178+
pkgReader.purgePackageJSONCacheForVFS(vfs);
179+
180+
// ESM cascaded loader: scope-purge by URL tag.
181+
const esmLoader = require('internal/modules/esm/loader');
182+
if (esmLoader.isCascadedLoaderInitialized()) {
183+
const loader = esmLoader.getOrInitializeCascadedLoader();
184+
const loadCache = loader.loadCache;
185+
// LoadCache extends SafeMap (url -> { [type]: job }). We iterate
186+
// keys() and delete entries whose URL carries this layer's tag.
187+
if (loadCache && typeof loadCache.delete === 'function') {
188+
for (const url of loadCache.keys()) {
189+
if (urlBelongsToLayer(url, layerId)) {
190+
// Drop every type variant for this URL.
191+
const variants = loadCache.get(url);
192+
if (variants) {
193+
for (const type of ObjectKeys(variants)) {
194+
loadCache.delete(url, type);
195+
}
196+
}
197+
}
198+
}
199+
}
200+
}
132201
}
133202

134203
/**
@@ -934,6 +1003,14 @@ function installModuleLoaderOverrides() {
9341003
}
9351004
return 0; // EXTENSIONLESS_FORMAT_JAVASCRIPT
9361005
},
1006+
getLayerForPath(filename) {
1007+
const normalized = resolve(filename);
1008+
for (let i = 0; i < activeVFSList.length; i++) {
1009+
const vfs = activeVFSList[i];
1010+
if (vfs.shouldHandle(normalized)) return vfs.layerId;
1011+
}
1012+
return undefined;
1013+
},
9371014
});
9381015

9391016
setLoaderPackageOverrides({
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Flags: --experimental-vfs
2+
'use strict';
3+
4+
// Deregistering one of several mounted VFSes must scope-purge the
5+
// loader caches: entries that belong to the VFS going away are
6+
// dropped, entries from other VFSes are kept warm. ESM cache entries
7+
// are tagged with `?vfs-layer=N` and surface in `import.meta.url`.
8+
9+
const common = require('../common');
10+
const assert = require('assert');
11+
const vfs = require('node:vfs');
12+
13+
// 1) Multi-mount: deregister of one VFS keeps the other's CJS module
14+
// cache warm. The test relies on the require.cache being preserved.
15+
{
16+
const a = vfs.create();
17+
a.writeFileSync('/value.js', 'module.exports = "a-cached"');
18+
a.mount('/mnt-purge-a');
19+
20+
const b = vfs.create();
21+
b.writeFileSync('/value.js', 'module.exports = "b-cached"');
22+
b.mount('/mnt-purge-b');
23+
24+
// Warm both caches.
25+
assert.strictEqual(require('/mnt-purge-a/value.js'), 'a-cached');
26+
assert.strictEqual(require('/mnt-purge-b/value.js'), 'b-cached');
27+
28+
const bKey = require.resolve('/mnt-purge-b/value.js');
29+
assert.ok(bKey in require.cache, 'b should be cached before unmount');
30+
31+
// Unmount A. B's cache entry must survive.
32+
a.unmount();
33+
assert.ok(
34+
bKey in require.cache,
35+
'unmounting a different VFS must not evict b\'s cache entry',
36+
);
37+
38+
// Re-require yields the same module instance (identity preserved).
39+
assert.strictEqual(require('/mnt-purge-b/value.js'), 'b-cached');
40+
41+
b.unmount();
42+
}
43+
44+
// 2) ESM URLs are tagged with `vfs-layer=<id>` and the tag surfaces in
45+
// `import.meta.url`.
46+
(async () => {
47+
const v = vfs.create();
48+
v.writeFileSync('/m.mjs', 'export const url = import.meta.url;');
49+
v.mount('/mnt-tag');
50+
51+
const ns = await import('/mnt-tag/m.mjs');
52+
assert.match(ns.url, new RegExp(`vfs-layer=${v.layerId}`));
53+
54+
v.unmount();
55+
})().then(common.mustCall());

0 commit comments

Comments
 (0)