Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lib: allow CJS source map cache to be reclaimed #51711

Merged
merged 1 commit into from
May 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -1335,7 +1335,7 @@ function wrapSafe(filename, content, cjsModuleInstance, codeCache, format) {
// Cache the source map for the module if present.
const { sourceMapURL } = script;
if (sourceMapURL) {
maybeCacheSourceMap(filename, content, this, false, undefined, sourceMapURL);
maybeCacheSourceMap(filename, content, cjsModuleInstance, false, undefined, sourceMapURL);
}

return {
Expand All @@ -1358,7 +1358,7 @@ function wrapSafe(filename, content, cjsModuleInstance, codeCache, format) {

// Cache the source map for the module if present.
if (result.sourceMapURL) {
maybeCacheSourceMap(filename, content, this, false, undefined, result.sourceMapURL);
maybeCacheSourceMap(filename, content, cjsModuleInstance, false, undefined, result.sourceMapURL);
}

return result;
Expand Down
7 changes: 3 additions & 4 deletions lib/internal/modules/esm/translators.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,12 @@ translators.set('module', function moduleStrategy(url, source, isMain) {
function loadCJSModule(module, source, url, filename, isMain) {
const compileResult = compileFunctionForCJSLoader(source, filename, isMain, false);

const { function: compiledWrapper, sourceMapURL } = compileResult;
// Cache the source map for the cjs module if present.
if (compileResult.sourceMapURL) {
maybeCacheSourceMap(url, source, null, false, undefined, compileResult.sourceMapURL);
if (sourceMapURL) {
maybeCacheSourceMap(url, source, module, false, undefined, sourceMapURL);
}

const compiledWrapper = compileResult.function;

const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
const __dirname = dirname(filename);
// eslint-disable-next-line func-name-matching,func-style
Expand Down
2 changes: 1 addition & 1 deletion lib/internal/modules/esm/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ function compileSourceTextModule(url, source, cascadedLoader) {
}
// Cache the source map for the module if present.
if (wrap.sourceMapURL) {
maybeCacheSourceMap(url, source, null, false, undefined, wrap.sourceMapURL);
maybeCacheSourceMap(url, source, wrap, false, undefined, wrap.sourceMapURL);
}
return wrap;
}
Expand Down
25 changes: 22 additions & 3 deletions lib/internal/source_map/prepare_stack_trace.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,15 @@ function getOriginalSymbolName(sourceMap, trace, curIndex) {
}
}

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

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

/**
* Retrieve exact line in the original source code from the source map's `sources` list or disk.
* @param {string} fileName - actual file name
* @param {number} lineNumber - actual line number
* @param {number} columnNumber - actual column number
* @returns {string | undefined} - the source content or undefined if file not found
*/
function getSourceMapErrorSource(fileName, lineNumber, columnNumber) {
const sm = findSourceMap(fileName);
if (sm === undefined) {
Expand Down
164 changes: 89 additions & 75 deletions lib/internal/source_map/source_map_cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
const {
ArrayPrototypePush,
JSONParse,
ObjectKeys,
RegExpPrototypeExec,
SafeMap,
StringPrototypeCodePointAt,
Expand All @@ -25,17 +24,15 @@ const {
} = require('internal/errors');
const { getLazy } = require('internal/util');

// Since the CJS module cache is mutable, which leads to memory leaks when
// modules are deleted, we use a WeakMap so that the source map cache will
// be purged automatically:
const getCjsSourceMapCache = getLazy(() => {
const { IterableWeakMap } = require('internal/util/iterable_weak_map');
return new IterableWeakMap();
const getModuleSourceMapCache = getLazy(() => {
const { SourceMapCacheMap } = require('internal/source_map/source_map_cache_map');
return new SourceMapCacheMap();
});

// The esm cache is not mutable, so we can use a Map without memory concerns:
const esmSourceMapCache = new SafeMap();
// The generated sources is not mutable, so we can use a Map without memory concerns:
// The generated source module/script instance is not accessible, so we can use
// a Map without memory concerns. Separate generated source entries with the module
// source entries to avoid overriding the module source entries with arbitrary
// source url magic comments.
const generatedSourceMapCache = new SafeMap();
const kLeadingProtocol = /^\w+:\/\//;
const kSourceMappingURLMagicComment = /\/[*/]#\s+sourceMappingURL=(?<sourceMappingURL>[^\s]+)/g;
Expand All @@ -52,6 +49,10 @@ function getSourceMapsEnabled() {
return sourceMapsEnabled;
}

/**
* Enables or disables source maps programmatically.
* @param {boolean} val
*/
function setSourceMapsEnabled(val) {
validateBoolean(val, 'val');

Expand All @@ -72,6 +73,14 @@ function setSourceMapsEnabled(val) {
sourceMapsEnabled = val;
}

/**
* Extracts the source url from the content if present. For example
* //# sourceURL=file:///path/to/file
*
* Read more at: https://tc39.es/source-map-spec/#linking-evald-code-to-named-generated-code
* @param {string} content - source content
* @returns {string | null} source url or null if not present
*/
function extractSourceURLMagicComment(content) {
let match;
let matchSourceURL;
Expand All @@ -90,6 +99,14 @@ function extractSourceURLMagicComment(content) {
return sourceURL;
}

/**
* Extracts the source map url from the content if present. For example
* //# sourceMappingURL=file:///path/to/file
*
* Read more at: https://tc39.es/source-map-spec/#linking-generated-code
* @param {string} content - source content
* @returns {string | null} source map url or null if not present
*/
function extractSourceMapURLMagicComment(content) {
let match;
let lastMatch;
Expand All @@ -104,7 +121,17 @@ function extractSourceMapURLMagicComment(content) {
return lastMatch.groups.sourceMappingURL;
}

function maybeCacheSourceMap(filename, content, cjsModuleInstance, isGeneratedSource, sourceURL, sourceMapURL) {
/**
* Caches the source map if it is present in the content, with the given filename, moduleInstance, and sourceURL.
* @param {string} filename - the actual filename
* @param {string} content - the actual source content
* @param {import('internal/modules/cjs/loader').Module | ModuleWrap} moduleInstance - a module instance that
* associated with the source, once this is reclaimed, the source map entry will be removed from the cache
* @param {boolean} isGeneratedSource - if the source was generated and evaluated with the global eval
* @param {string | undefined} sourceURL - the source url
* @param {string | undefined} sourceMapURL - the source map url
*/
function maybeCacheSourceMap(filename, content, moduleInstance, isGeneratedSource, sourceURL, sourceMapURL) {
const sourceMapsEnabled = getSourceMapsEnabled();
if (!(process.env.NODE_V8_COVERAGE || sourceMapsEnabled)) return;
const { normalizeReferrerURL } = require('internal/modules/helpers');
Expand All @@ -130,45 +157,32 @@ function maybeCacheSourceMap(filename, content, cjsModuleInstance, isGeneratedSo
}

const data = dataFromUrl(filename, sourceMapURL);
const url = data ? null : sourceMapURL;
if (cjsModuleInstance) {
getCjsSourceMapCache().set(cjsModuleInstance, {
__proto__: null,
filename,
lineLengths: lineLengths(content),
data,
url,
sourceURL,
});
} else if (isGeneratedSource) {
const entry = {
__proto__: null,
lineLengths: lineLengths(content),
data,
url,
sourceURL,
};
const entry = {
__proto__: null,
lineLengths: lineLengths(content),
data,
// Save the source map url if it is not a data url.
sourceMapURL: data ? null : sourceMapURL,
sourceURL,
};

if (isGeneratedSource) {
generatedSourceMapCache.set(filename, entry);
if (sourceURL) {
generatedSourceMapCache.set(sourceURL, entry);
}
} else {
// If there is no cjsModuleInstance and is not generated source assume we are in a
// "modules/esm" context.
const entry = {
__proto__: null,
lineLengths: lineLengths(content),
data,
url,
sourceURL,
};
esmSourceMapCache.set(filename, entry);
if (sourceURL) {
esmSourceMapCache.set(sourceURL, entry);
}
return;
}
// If it is not a generated source, we assume we are in a "cjs/esm"
// context.
const keys = sourceURL ? [filename, sourceURL] : [filename];
getModuleSourceMapCache().set(keys, entry, moduleInstance);
}

/**
* Caches the source map if it is present in the eval'd source.
* @param {string} content - the eval'd source code
*/
function maybeCacheGeneratedSourceMap(content) {
const sourceMapsEnabled = getSourceMapsEnabled();
if (!(process.env.NODE_V8_COVERAGE || sourceMapsEnabled)) return;
Expand All @@ -186,6 +200,14 @@ function maybeCacheGeneratedSourceMap(content) {
}
}

/**
* Resolves source map payload data from the source url and source map url.
* If the source map url is a data url, the data is returned.
* Otherwise the source map url is resolved to a file path and the file is read.
* @param {string} sourceURL - url of the source file
* @param {string} sourceMappingURL - url of the source map
* @returns {object} deserialized source map JSON object
*/
function dataFromUrl(sourceURL, sourceMappingURL) {
try {
const url = new URL(sourceMappingURL);
Expand Down Expand Up @@ -227,7 +249,11 @@ function lineLengths(content) {
return output;
}


/**
* Read source map from file.
* @param {string} mapURL - file url of the source map
* @returns {object} deserialized source map JSON object
*/
function sourceMapFromFile(mapURL) {
try {
const fs = require('fs');
Expand Down Expand Up @@ -281,56 +307,44 @@ function sourcesToAbsolute(baseURL, data) {
return data;
}

// WARNING: The `sourceMapCacheToObject` and `appendCJSCache` run during
// shutdown. In particular, they also run when Workers are terminated, making
// it important that they do not call out to any user-provided code, including
// built-in prototypes that might have been tampered with.
// WARNING: The `sourceMapCacheToObject` runs during shutdown. In particular,
// it also runs when Workers are terminated, making it important that it does
// not call out to any user-provided code, including built-in prototypes that
// might have been tampered with.

// Get serialized representation of source-map cache, this is used
// to persist a cache of source-maps to disk when NODE_V8_COVERAGE is enabled.
function sourceMapCacheToObject() {
const obj = { __proto__: null };

for (const { 0: k, 1: v } of esmSourceMapCache) {
obj[k] = v;
}

appendCJSCache(obj);

if (ObjectKeys(obj).length === 0) {
const moduleSourceMapCache = getModuleSourceMapCache();
if (moduleSourceMapCache.size === 0) {
return undefined;
}
return obj;
}

function appendCJSCache(obj) {
for (const value of getCjsSourceMapCache()) {
obj[value.filename] = {
const obj = { __proto__: null };
for (const { 0: k, 1: v } of moduleSourceMapCache) {
obj[k] = {
__proto__: null,
lineLengths: value.lineLengths,
data: value.data,
url: value.url,
lineLengths: v.lineLengths,
data: v.data,
url: v.sourceMapURL,
};
}
return obj;
}

/**
* Find a source map for a given actual source URL or path.
* @param {string} sourceURL - actual source URL or path
* @returns {import('internal/source_map/source_map').SourceMap | undefined} a source map or undefined if not found
*/
function findSourceMap(sourceURL) {
if (RegExpPrototypeExec(kLeadingProtocol, sourceURL) === null) {
sourceURL = pathToFileURL(sourceURL).href;
}
if (!SourceMap) {
SourceMap = require('internal/source_map/source_map').SourceMap;
}
let entry = esmSourceMapCache.get(sourceURL) ?? generatedSourceMapCache.get(sourceURL);
if (entry === undefined) {
for (const value of getCjsSourceMapCache()) {
const filename = value.filename;
const cachedSourceURL = value.sourceURL;
if (sourceURL === filename || sourceURL === cachedSourceURL) {
entry = value;
}
}
}
const entry = getModuleSourceMapCache().get(sourceURL) ?? generatedSourceMapCache.get(sourceURL);
if (entry === undefined) {
return undefined;
}
Expand Down
Loading