Skip to content

Commit 4d27198

Browse files
committed
lib: allow CJS source map cache to be reclaimed
Unifies the CJS and ESM source map cache map and allows the CJS cache entries to be queried more efficiently with a source url without iteration on an IterableWeakMap. Reclaims the CJS cache entry with a FinalizationRegistry instead. Add a test to verify that the CJS source map cache entry can be reclaimed.
1 parent 9448c61 commit 4d27198

File tree

8 files changed

+260
-81
lines changed

8 files changed

+260
-81
lines changed

lib/internal/modules/cjs/loader.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,7 +1271,7 @@ function wrapSafe(filename, content, cjsModuleInstance, codeCache) {
12711271

12721272
// Cache the source map for the module if present.
12731273
if (script.sourceMapURL) {
1274-
maybeCacheSourceMap(filename, content, this, false, undefined, script.sourceMapURL);
1274+
maybeCacheSourceMap(filename, content, cjsModuleInstance, false, undefined, script.sourceMapURL);
12751275
}
12761276

12771277
return runScriptInThisContext(script, true, false);
@@ -1302,7 +1302,7 @@ function wrapSafe(filename, content, cjsModuleInstance, codeCache) {
13021302

13031303
// Cache the source map for the module if present.
13041304
if (result.sourceMapURL) {
1305-
maybeCacheSourceMap(filename, content, this, false, undefined, result.sourceMapURL);
1305+
maybeCacheSourceMap(filename, content, cjsModuleInstance, false, undefined, result.sourceMapURL);
13061306
}
13071307

13081308
return result.function;

lib/internal/source_map/prepare_stack_trace.js

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,15 @@ function getOriginalSymbolName(sourceMap, trace, curIndex) {
114114
}
115115
}
116116

117-
// Places a snippet of code from where the exception was originally thrown
118-
// above the stack trace. This logic is modeled after GetErrorSource in
119-
// node_errors.cc.
117+
/**
118+
* Return a snippet of code from where the exception was originally thrown
119+
* above the stack trace. This called from GetErrorSource in node_errors.cc.
120+
* @param {import('internal/source_map/source_map').SourceMap} sourceMap - the source map to be used
121+
* @param {string} originalSourcePath - path or url of the original source
122+
* @param {number} originalLine - line number in the original source
123+
* @param {number} originalColumn - column number in the original source
124+
* @returns {string | undefined} - the exact line in the source content or undefined if file not found
125+
*/
120126
function getErrorSource(
121127
sourceMap,
122128
originalSourcePath,
@@ -154,6 +160,12 @@ function getErrorSource(
154160
return exceptionLine;
155161
}
156162

163+
/**
164+
* Retrieve the original source code from the source map's `sources` list or disk.
165+
* @param {import('internal/source_map/source_map').SourceMap.payload} payload
166+
* @param {string} originalSourcePath - path or url of the original source
167+
* @returns {string | undefined} - the source content or undefined if file not found
168+
*/
157169
function getOriginalSource(payload, originalSourcePath) {
158170
let source;
159171
// payload.sources has been normalized to be an array of absolute urls.
@@ -177,6 +189,13 @@ function getOriginalSource(payload, originalSourcePath) {
177189
return source;
178190
}
179191

192+
/**
193+
* Retrieve exact line in the original source code from the source map's `sources` list or disk.
194+
* @param {string} fileName - actual file name
195+
* @param {number} lineNumber - actual line number
196+
* @param {number} columnNumber - actual column number
197+
* @returns {string | undefined} - the source content or undefined if file not found
198+
*/
180199
function getSourceMapErrorSource(fileName, lineNumber, columnNumber) {
181200
const sm = findSourceMap(fileName);
182201
if (sm === undefined) {

lib/internal/source_map/source_map_cache.js

Lines changed: 124 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
const {
44
ArrayPrototypePush,
55
JSONParse,
6-
ObjectKeys,
76
RegExpPrototypeExec,
7+
SafeFinalizationRegistry,
88
SafeMap,
9+
SafeWeakRef,
910
StringPrototypeCodePointAt,
1011
StringPrototypeSplit,
1112
} = primordials;
@@ -23,19 +24,26 @@ const {
2324
const {
2425
setInternalPrepareStackTrace,
2526
} = require('internal/errors');
26-
const { getLazy } = require('internal/util');
27-
28-
// Since the CJS module cache is mutable, which leads to memory leaks when
29-
// modules are deleted, we use a WeakMap so that the source map cache will
30-
// be purged automatically:
31-
const getCjsSourceMapCache = getLazy(() => {
32-
const { IterableWeakMap } = require('internal/util/iterable_weak_map');
33-
return new IterableWeakMap();
34-
});
3527

36-
// The esm cache is not mutable, so we can use a Map without memory concerns:
37-
const esmSourceMapCache = new SafeMap();
38-
// The generated sources is not mutable, so we can use a Map without memory concerns:
28+
// The cached module instance can be removed from the global module registry
29+
// with approaches like mutating `require.cache`.
30+
// The `moduleSourceMapCache` exposes entries by `filename` and `sourceURL`.
31+
// In the case of mutated module registry, obsolete entries are removed from
32+
// the cache by the `moduleFinalizationRegistry`.
33+
const moduleSourceMapCache = new SafeMap();
34+
const moduleFinalizationRegistry = new SafeFinalizationRegistry((key) => {
35+
const instanceRef = moduleSourceMapCache.get(key).moduleInstanceRef;
36+
// Delete the entry if the instanceRef has been reclaimed.
37+
// If the instanceRef is not reclaimed, the entry was overridden by a new
38+
// module instance.
39+
if (instanceRef && instanceRef.deref() === undefined) {
40+
moduleSourceMapCache.delete(key);
41+
}
42+
});
43+
// The generated source module/script instance is not accessible, so we can use
44+
// a Map without memory concerns. Separate generated source entries with the module
45+
// source entries to avoid overriding the module source entries with arbitrary
46+
// source url magic comments.
3947
const generatedSourceMapCache = new SafeMap();
4048
const kLeadingProtocol = /^\w+:\/\//;
4149
const kSourceMappingURLMagicComment = /\/[*/]#\s+sourceMappingURL=(?<sourceMappingURL>[^\s]+)/g;
@@ -52,6 +60,10 @@ function getSourceMapsEnabled() {
5260
return sourceMapsEnabled;
5361
}
5462

63+
/**
64+
* Enables or disables source maps programmatically.
65+
* @param {boolean} val
66+
*/
5567
function setSourceMapsEnabled(val) {
5668
validateBoolean(val, 'val');
5769

@@ -72,6 +84,14 @@ function setSourceMapsEnabled(val) {
7284
sourceMapsEnabled = val;
7385
}
7486

87+
/**
88+
* Extracts the source url from the content if present. For example
89+
* //# sourceURL=file:///path/to/file
90+
*
91+
* Read more at: https://tc39.es/source-map-spec/#linking-evald-code-to-named-generated-code
92+
* @param {string} content - source content
93+
* @returns {string | null} source url or null if not present
94+
*/
7595
function extractSourceURLMagicComment(content) {
7696
let match;
7797
let matchSourceURL;
@@ -90,6 +110,14 @@ function extractSourceURLMagicComment(content) {
90110
return sourceURL;
91111
}
92112

113+
/**
114+
* Extracts the source map url from the content if present. For example
115+
* //# sourceMappingURL=file:///path/to/file
116+
*
117+
* Read more at: https://tc39.es/source-map-spec/#linking-generated-code
118+
* @param {string} content - source content
119+
* @returns {string | null} source map url or null if not present
120+
*/
93121
function extractSourceMapURLMagicComment(content) {
94122
let match;
95123
let lastMatch;
@@ -104,7 +132,17 @@ function extractSourceMapURLMagicComment(content) {
104132
return lastMatch.groups.sourceMappingURL;
105133
}
106134

107-
function maybeCacheSourceMap(filename, content, cjsModuleInstance, isGeneratedSource, sourceURL, sourceMapURL) {
135+
/**
136+
* Caches the source map if it is present in the content, with the given filename, moduleInstance, and sourceURL.
137+
* @param {string} filename - the actual filename
138+
* @param {string} content - the actual source content
139+
* @param {import('internal/modules/cjs/loader').Module | undefined} moduleInstance - a CJS module instance that
140+
* associated with the source, once this is reclaimed, the source map entry will be removed from the cache
141+
* @param {boolean} isGeneratedSource - if the source was generated and evaluated with the global eval
142+
* @param {string | undefined} sourceURL - the source url
143+
* @param {string | undefined} sourceMapURL - the source map url
144+
*/
145+
function maybeCacheSourceMap(filename, content, moduleInstance, isGeneratedSource, sourceURL, sourceMapURL) {
108146
const sourceMapsEnabled = getSourceMapsEnabled();
109147
if (!(process.env.NODE_V8_COVERAGE || sourceMapsEnabled)) return;
110148
const { normalizeReferrerURL } = require('internal/modules/helpers');
@@ -129,16 +167,7 @@ function maybeCacheSourceMap(filename, content, cjsModuleInstance, isGeneratedSo
129167

130168
const data = dataFromUrl(filename, sourceMapURL);
131169
const url = data ? null : sourceMapURL;
132-
if (cjsModuleInstance) {
133-
getCjsSourceMapCache().set(cjsModuleInstance, {
134-
__proto__: null,
135-
filename,
136-
lineLengths: lineLengths(content),
137-
data,
138-
url,
139-
sourceURL,
140-
});
141-
} else if (isGeneratedSource) {
170+
if (isGeneratedSource) {
142171
const entry = {
143172
__proto__: null,
144173
lineLengths: lineLengths(content),
@@ -150,23 +179,49 @@ function maybeCacheSourceMap(filename, content, cjsModuleInstance, isGeneratedSo
150179
if (sourceURL) {
151180
generatedSourceMapCache.set(sourceURL, entry);
152181
}
153-
} else {
154-
// If there is no cjsModuleInstance and is not generated source assume we are in a
155-
// "modules/esm" context.
156-
const entry = {
157-
__proto__: null,
158-
lineLengths: lineLengths(content),
159-
data,
160-
url,
161-
sourceURL,
162-
};
163-
esmSourceMapCache.set(filename, entry);
164-
if (sourceURL) {
165-
esmSourceMapCache.set(sourceURL, entry);
166-
}
182+
return;
183+
}
184+
185+
// If it is not a generated source, we assume we are in a "cjs/esm"
186+
// context.
187+
const entry = {
188+
__proto__: null,
189+
lineLengths: lineLengths(content),
190+
data,
191+
url,
192+
sourceURL,
193+
moduleInstanceRef: moduleInstance ? new SafeWeakRef(moduleInstance) : undefined,
194+
};
195+
setModuleSourceMapCache(filename, sourceURL, moduleInstance, entry);
196+
}
197+
198+
/**
199+
* Registers the module entry in the `moduleSourceMapCache` with the filename and sourceURL.
200+
* @param {string} filename - the actual filename, in the form of a file URL
201+
* @param {string | undefined} sourceURL - the source url
202+
* @param {import('internal/modules/cjs/loader').Module | undefined} moduleInstance - a CJS module instance
203+
* @param {object} entry - the source map cache entry
204+
*/
205+
function setModuleSourceMapCache(filename, sourceURL, moduleInstance, entry) {
206+
moduleSourceMapCache.set(filename, entry);
207+
if (sourceURL) {
208+
moduleSourceMapCache.set(sourceURL, entry);
209+
}
210+
// Skip if the module instance is not present.
211+
if (moduleInstance == null) return;
212+
213+
// Register the module instance with the finalization registry to clear the
214+
// entry once the module instance is been reclaimed.
215+
moduleFinalizationRegistry.register(moduleInstance, filename);
216+
if (sourceURL) {
217+
moduleFinalizationRegistry.register(moduleInstance, sourceURL);
167218
}
168219
}
169220

221+
/**
222+
* Caches the source map if it is present in the eval'd source.
223+
* @param {string} content - the eval'd source code
224+
*/
170225
function maybeCacheGeneratedSourceMap(content) {
171226
const sourceMapsEnabled = getSourceMapsEnabled();
172227
if (!(process.env.NODE_V8_COVERAGE || sourceMapsEnabled)) return;
@@ -184,6 +239,14 @@ function maybeCacheGeneratedSourceMap(content) {
184239
}
185240
}
186241

242+
/**
243+
* Resolves source map payload data from the source url and source map url.
244+
* If the source map url is a data url, the data is returned.
245+
* Otherwise the source map url is resolved to a file path and the file is read.
246+
* @param {string} sourceURL - url of the source file
247+
* @param {string} sourceMappingURL - url of the source map
248+
* @returns {object} deserialized source map JSON object
249+
*/
187250
function dataFromUrl(sourceURL, sourceMappingURL) {
188251
try {
189252
const url = new URL(sourceMappingURL);
@@ -225,7 +288,11 @@ function lineLengths(content) {
225288
return output;
226289
}
227290

228-
291+
/**
292+
* Read source map from file.
293+
* @param {string} mapURL - file url of the source map
294+
* @returns {object} deserialized source map JSON object
295+
*/
229296
function sourceMapFromFile(mapURL) {
230297
try {
231298
const fs = require('fs');
@@ -279,56 +346,43 @@ function sourcesToAbsolute(baseURL, data) {
279346
return data;
280347
}
281348

282-
// WARNING: The `sourceMapCacheToObject` and `appendCJSCache` run during
283-
// shutdown. In particular, they also run when Workers are terminated, making
284-
// it important that they do not call out to any user-provided code, including
285-
// built-in prototypes that might have been tampered with.
349+
// WARNING: The `sourceMapCacheToObject` run during shutdown. In particular,
350+
// they also run when Workers are terminated, making it important that they do
351+
// not call out to any user-provided code, including built-in prototypes that
352+
// might have been tampered with.
286353

287354
// Get serialized representation of source-map cache, this is used
288355
// to persist a cache of source-maps to disk when NODE_V8_COVERAGE is enabled.
289356
function sourceMapCacheToObject() {
290-
const obj = { __proto__: null };
291-
292-
for (const { 0: k, 1: v } of esmSourceMapCache) {
293-
obj[k] = v;
294-
}
295-
296-
appendCJSCache(obj);
297-
298-
if (ObjectKeys(obj).length === 0) {
357+
if (moduleSourceMapCache.size === 0) {
299358
return undefined;
300359
}
301-
return obj;
302-
}
303360

304-
function appendCJSCache(obj) {
305-
for (const value of getCjsSourceMapCache()) {
306-
obj[value.filename] = {
361+
const obj = { __proto__: null };
362+
for (const { 0: k, 1: v } of moduleSourceMapCache) {
363+
obj[k] = {
307364
__proto__: null,
308-
lineLengths: value.lineLengths,
309-
data: value.data,
310-
url: value.url,
365+
lineLengths: v.lineLengths,
366+
data: v.data,
367+
url: v.url,
311368
};
312369
}
370+
return obj;
313371
}
314372

373+
/**
374+
* Find a source map for a given actual source URL or path.
375+
* @param {string} sourceURL - actual source URL or path
376+
* @returns {import('internal/source_map/source_map').SourceMap | undefined} a source map or undefined if not found
377+
*/
315378
function findSourceMap(sourceURL) {
316379
if (RegExpPrototypeExec(kLeadingProtocol, sourceURL) === null) {
317380
sourceURL = pathToFileURL(sourceURL).href;
318381
}
319382
if (!SourceMap) {
320383
SourceMap = require('internal/source_map/source_map').SourceMap;
321384
}
322-
let entry = esmSourceMapCache.get(sourceURL) ?? generatedSourceMapCache.get(sourceURL);
323-
if (entry === undefined) {
324-
for (const value of getCjsSourceMapCache()) {
325-
const filename = value.filename;
326-
const cachedSourceURL = value.sourceURL;
327-
if (sourceURL === filename || sourceURL === cachedSourceURL) {
328-
entry = value;
329-
}
330-
}
331-
}
385+
const entry = moduleSourceMapCache.get(sourceURL) ?? generatedSourceMapCache.get(sourceURL);
332386
if (entry === undefined) {
333387
return undefined;
334388
}

test/fixtures/source-map/no-throw.js

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)