Skip to content

Commit 61ada45

Browse files
committed
vfs: scope-purge loader caches via per-VFS owned-keys sets
Each VirtualFileSystem now tracks the absolute filenames it has handled (ownedFilenames) and the Module._pathCache keys that resolve to a VFS-owned filename (ownedPathCacheKeys). Recording happens at routing time inside the loader overrides (findVFSForStat / findVFSForRead / findVFSWith, the inline package.json override loops, and a pathCache write recorder installed on the cjs loader). On deregister, purgeLoaderCachesForVFS walks the per-VFS sets and removes entries from Module._cache, Module._pathCache, the stat / realpath / package.json caches in O(owned) instead of scanning every cache and calling vfs.shouldHandle() on each entry. The pathCache recorder uses a Set.has() lookup over activeVFSList so the per-write overhead is M cheap Set checks rather than M path normalizations. Also encapsulates the previous direct access to Module._cache / Module._pathCache from internal/vfs/setup.js behind the cjs loader's new purgeModuleCacheForVFS / setPathCacheWriteRecorder / cachePathResolution helpers, so the loader's private data structures stay owned by the loader module. Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 1f09634 commit 61ada45

5 files changed

Lines changed: 202 additions & 77 deletions

File tree

lib/internal/modules/cjs/loader.js

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ const {
3838
Error,
3939
FunctionPrototypeCall,
4040
JSONParse,
41-
MapPrototypeForEach,
4241
ObjectDefineProperty,
4342
ObjectFreeze,
4443
ObjectGetOwnPropertyDescriptor,
@@ -53,6 +52,7 @@ const {
5352
RegExpPrototypeExec,
5453
SafeMap,
5554
SafeSet,
55+
SetPrototypeForEach,
5656
String,
5757
StringPrototypeCharAt,
5858
StringPrototypeCharCodeAt,
@@ -116,6 +116,8 @@ const kFormat = Symbol('kFormat');
116116
module.exports = {
117117
clearStatCache,
118118
clearStatCacheForVFS,
119+
purgeModuleCacheForVFS,
120+
setPathCacheWriteRecorder,
119121
kModuleSource,
120122
kModuleExport,
121123
kModuleExportNames,
@@ -301,17 +303,73 @@ function clearStatCache() {
301303
/**
302304
* Drop only the stat-cache entries owned by the given VFS instance.
303305
* Real-fs entries and entries owned by other VFSes are untouched.
304-
* @param {{shouldHandle: (path: string) => boolean}} vfs
306+
* Iterates the per-VFS ownedFilenames set populated by the loader
307+
* overrides at routing time - O(owned) rather than O(statCache).
308+
* @param {{ownedFilenames: SafeSet<string>}} vfs
305309
*/
306310
function clearStatCacheForVFS(vfs) {
307-
if (statCache !== null) {
308-
// MapPrototypeForEach (not for-of statCache.keys()) so a polluted
309-
// Map iterator can't break this purge path.
310-
MapPrototypeForEach(statCache, (_value, filename) => {
311-
if (vfs.shouldHandle(filename)) {
312-
statCache.delete(filename);
313-
}
314-
});
311+
if (statCache === null) {
312+
return;
313+
}
314+
// SafeSetIterator yields filenames; SafeSet's iteration helpers
315+
// protect against pollution of the global Set prototype.
316+
SetPrototypeForEach(vfs.ownedFilenames, (filename) => {
317+
statCache.delete(filename);
318+
});
319+
}
320+
321+
/**
322+
* Drop the module and path cache entries owned by the given VFS instance.
323+
* Real-fs entries and entries owned by other VFSes are untouched. This
324+
* keeps Module._cache / Module._pathCache encapsulated so callers don't
325+
* reach into the loader's private data structures directly, and runs
326+
* in O(owned) by walking the per-VFS sets populated at routing time.
327+
* @param {{ownedFilenames: SafeSet<string>, ownedPathCacheKeys: SafeSet<string>}} vfs
328+
*/
329+
function purgeModuleCacheForVFS(vfs) {
330+
// Module._cache: keyed by absolute filename. Iterate only the
331+
// filenames the VFS handled rather than scanning the whole cache.
332+
SetPrototypeForEach(vfs.ownedFilenames, (filename) => {
333+
delete Module._cache[filename];
334+
});
335+
336+
// Module._pathCache: keyed by "request\0parent" strings. The keys
337+
// are recorded at write time via the pathCache write recorder
338+
// installed by setup.js, so we iterate only the recorded keys.
339+
SetPrototypeForEach(vfs.ownedPathCacheKeys, (key) => {
340+
delete Module._pathCache[key];
341+
});
342+
}
343+
344+
/**
345+
* Hook invoked whenever Module._pathCache is written. Allows the VFS
346+
* layer to associate the new pathCache key with the VFS that owns the
347+
* resolved filename, so on unmount the key can be removed by direct
348+
* lookup instead of scanning the whole pathCache.
349+
* @type {((cacheKey: string, resolvedFilename: string) => void) | null}
350+
*/
351+
let pathCacheWriteRecorder = null;
352+
353+
/**
354+
* Install (or clear) the pathCache write recorder. Called by
355+
* internal/vfs/setup.js on hook install / uninstall.
356+
* @param {((cacheKey: string, resolvedFilename: string) => void) | null} fn
357+
*/
358+
function setPathCacheWriteRecorder(fn) {
359+
pathCacheWriteRecorder = fn ?? null;
360+
}
361+
362+
/**
363+
* Encapsulated writer for Module._pathCache. Goes through the recorder
364+
* so external observers (currently: VFS scope-purge) can track which
365+
* keys belong to which owner without exposing the cache itself.
366+
* @param {string} cacheKey
367+
* @param {string} resolvedFilename
368+
*/
369+
function cachePathResolution(cacheKey, resolvedFilename) {
370+
Module._pathCache[cacheKey] = resolvedFilename;
371+
if (pathCacheWriteRecorder !== null) {
372+
pathCacheWriteRecorder(cacheKey, resolvedFilename);
315373
}
316374
}
317375

@@ -906,7 +964,7 @@ Module._findPath = function(request, paths, isMain, conditions = getCjsCondition
906964
}
907965

908966
if (filename) {
909-
Module._pathCache[cacheKey] = filename;
967+
cachePathResolution(cacheKey, filename);
910968
return filename;
911969
}
912970

@@ -1586,7 +1644,7 @@ Module._resolveFilename = function(request, parent, isMain, options) {
15861644
if (selfResolved) {
15871645
const cacheKey = request + '\x00' +
15881646
(paths.length === 1 ? paths[0] : ArrayPrototypeJoin(paths, '\x00'));
1589-
Module._pathCache[cacheKey] = selfResolved;
1647+
cachePathResolution(cacheKey, selfResolved);
15901648
return selfResolved;
15911649
}
15921650

lib/internal/modules/helpers.js

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
const {
44
ArrayPrototypeForEach,
5-
MapPrototypeForEach,
65
ObjectDefineProperty,
76
ObjectFreeze,
87
ObjectPrototypeHasOwnProperty,
98
SafeMap,
109
SafeSet,
10+
SetPrototypeForEach,
1111
StringPrototypeCharCodeAt,
1212
StringPrototypeIncludes,
1313
StringPrototypeSlice,
@@ -199,16 +199,13 @@ function clearRealpathCache() {
199199

200200
/**
201201
* Drop only realpath-cache entries owned by the given VFS instance.
202-
* Entries for unrelated paths are untouched.
203-
* @param {{shouldHandle: (path: string) => boolean}} vfs
202+
* Entries for unrelated paths are untouched. Iterates the per-VFS
203+
* ownedFilenames set rather than the whole realpath cache.
204+
* @param {{ownedFilenames: SafeSet<string>}} vfs
204205
*/
205206
function purgeRealpathCacheForVFS(vfs) {
206-
// MapPrototypeForEach (not for-of realpathCache.keys()) so a polluted
207-
// Map iterator can't break this purge path.
208-
MapPrototypeForEach(realpathCache, (_value, key) => {
209-
if (vfs.shouldHandle(key)) {
210-
realpathCache.delete(key);
211-
}
207+
SetPrototypeForEach(vfs.ownedFilenames, (filename) => {
208+
realpathCache.delete(filename);
212209
});
213210
}
214211

lib/internal/modules/package_json_reader.js

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
const {
44
ArrayIsArray,
55
JSONParse,
6-
MapPrototypeForEach,
76
ObjectDefineProperty,
87
RegExpPrototypeExec,
98
SafeMap,
9+
SetPrototypeForEach,
1010
StringPrototypeIndexOf,
1111
StringPrototypeSlice,
1212
} = primordials;
@@ -370,21 +370,18 @@ function clearPackageJSONCache() {
370370

371371
/**
372372
* Drop only package.json-cache entries owned by the given VFS instance.
373-
* Real-fs entries and other-VFS entries are untouched.
374-
* @param {{shouldHandle: (path: string) => boolean}} vfs
373+
* Real-fs entries and other-VFS entries are untouched. Iterates the
374+
* per-VFS ownedFilenames set (populated at routing time by the loader
375+
* overrides) rather than scanning the global caches. The set is a
376+
* superset of the VFS-owned keys, so the same iteration covers both
377+
* the input-path cache and the package.json-path cache; misses are
378+
* harmless `Map.delete` no-ops.
379+
* @param {{ownedFilenames: SafeSet<string>}} vfs
375380
*/
376381
function purgePackageJSONCacheForVFS(vfs) {
377-
// MapPrototypeForEach (not for-of) so a polluted Map iterator can't
378-
// break this purge path.
379-
MapPrototypeForEach(moduleToParentPackageJSONCache, (_value, key) => {
380-
if (vfs.shouldHandle(key)) {
381-
moduleToParentPackageJSONCache.delete(key);
382-
}
383-
});
384-
MapPrototypeForEach(deserializedPackageJSONCache, (_value, key) => {
385-
if (typeof key === 'string' && vfs.shouldHandle(key)) {
386-
deserializedPackageJSONCache.delete(key);
387-
}
382+
SetPrototypeForEach(vfs.ownedFilenames, (filename) => {
383+
moduleToParentPackageJSONCache.delete(filename);
384+
deserializedPackageJSONCache.delete(filename);
388385
});
389386
}
390387

lib/internal/vfs/file_system.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
const {
44
MathRandom,
55
ObjectFreeze,
6+
SafeSet,
67
Symbol,
78
SymbolDispose,
89
} = primordials;
@@ -44,6 +45,12 @@ const kMountPoint = Symbol('kMountPoint');
4445
const kMounted = Symbol('kMounted');
4546
const kPromises = Symbol('kPromises');
4647
const kLayerId = Symbol('kLayerId');
48+
// Per-VFS sets used by the loader to scope-purge module caches on unmount
49+
// without iterating every cached entry. Populated by the loader overrides
50+
// in internal/vfs/setup.js whenever a request is claimed by this VFS, and
51+
// cleared on unmount once the purge has run.
52+
const kOwnedFilenames = Symbol('kOwnedFilenames');
53+
const kOwnedPathCacheKeys = Symbol('kOwnedPathCacheKeys');
4754

4855
// Per-process monotonically increasing counter that gives each
4956
// VirtualFileSystem instance a unique, stable id. Useful for ordering
@@ -115,6 +122,8 @@ class VirtualFileSystem {
115122
this[kMounted] = false;
116123
this[kPromises] = null; // Lazy-initialized
117124
this[kLayerId] = nextLayerId++;
125+
this[kOwnedFilenames] = new SafeSet();
126+
this[kOwnedPathCacheKeys] = new SafeSet();
118127
}
119128

120129
/**
@@ -213,6 +222,55 @@ class VirtualFileSystem {
213222
return isUnderMountPoint(normalized, mountPoint);
214223
}
215224

225+
/**
226+
* Record an absolute filename that the module loader routed through
227+
* this VFS. The recorded paths are used on unmount to scope-purge
228+
* per-filename loader caches (Module._cache, stat, realpath,
229+
* package.json) without scanning the whole cache.
230+
* @param {string} filename
231+
*/
232+
recordOwnedFilename(filename) {
233+
this[kOwnedFilenames].add(filename);
234+
}
235+
236+
/**
237+
* Record a Module._pathCache key whose resolved value points into
238+
* this VFS. Used on unmount to scope-purge the path cache by stored
239+
* key rather than scanning every entry.
240+
* @param {string} key
241+
*/
242+
recordOwnedPathCacheKey(key) {
243+
this[kOwnedPathCacheKeys].add(key);
244+
}
245+
246+
/**
247+
* The set of filenames recorded via recordOwnedFilename. Returned
248+
* directly (not copied) so callers can iterate with the primordials
249+
* iterator helpers used in the loader's cache purge paths.
250+
* @returns {SafeSet<string>}
251+
*/
252+
get ownedFilenames() {
253+
return this[kOwnedFilenames];
254+
}
255+
256+
/**
257+
* The set of Module._pathCache keys recorded via
258+
* recordOwnedPathCacheKey.
259+
* @returns {SafeSet<string>}
260+
*/
261+
get ownedPathCacheKeys() {
262+
return this[kOwnedPathCacheKeys];
263+
}
264+
265+
/**
266+
* Drop the per-VFS ownership tracking after the loader has used it
267+
* to purge its caches on unmount.
268+
*/
269+
clearOwnedKeys() {
270+
this[kOwnedFilenames].clear();
271+
this[kOwnedPathCacheKeys].clear();
272+
}
273+
216274
// ==================== Path Resolution ====================
217275

218276
/**

0 commit comments

Comments
 (0)