Skip to content

Commit

Permalink
lib: allow CJS source map cache to be reclaimed
Browse files Browse the repository at this point in the history
Unifies the CJS and ESM source map cache map with SourceMapCacheMap
and allows the CJS cache entries to be queried more efficiently with
a source url without iteration on an IterableWeakMap.

Add a test to verify that the CJS source map cache entry can be
reclaimed.

PR-URL: #51711
Reviewed-By: Joyee Cheung <joyeec9h3@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
legendecas authored and targos committed Jun 1, 2024

Verified

This commit was signed with the committer’s verified signature.
samsonasik Abdul Malik Ikhsan
1 parent 374743c commit fe007cd
Showing 16 changed files with 393 additions and 277 deletions.
4 changes: 2 additions & 2 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
@@ -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 {
@@ -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;
7 changes: 3 additions & 4 deletions lib/internal/modules/esm/translators.js
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/internal/modules/esm/utils.js
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 22 additions & 3 deletions lib/internal/source_map/prepare_stack_trace.js
Original file line number Diff line number Diff line change
@@ -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,
@@ -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.
@@ -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) {
164 changes: 89 additions & 75 deletions lib/internal/source_map/source_map_cache.js
Original file line number Diff line number Diff line change
@@ -3,7 +3,6 @@
const {
ArrayPrototypePush,
JSONParse,
ObjectKeys,
RegExpPrototypeExec,
SafeMap,
StringPrototypeCodePointAt,
@@ -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;
@@ -52,6 +49,10 @@ function getSourceMapsEnabled() {
return sourceMapsEnabled;
}

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

@@ -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;
@@ -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;
@@ -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');
@@ -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;
@@ -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);
@@ -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');
@@ -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;
}
115 changes: 115 additions & 0 deletions lib/internal/source_map/source_map_cache_map.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
'use strict';

const {
ArrayPrototypeForEach,
ObjectFreeze,
SafeFinalizationRegistry,
SafeMap,
SafeWeakRef,
SymbolIterator,
} = primordials;
const {
privateSymbols: {
source_map_data_private_symbol,
},
} = internalBinding('util');

/**
* Specialized map of WeakRefs to module instances that caches source map
* entries by `filename` and `sourceURL`. Cached entries can be iterated with
* `for..of` syntax.
*
* The cache map maintains the cache entries by:
* - `weakModuleMap`(Map): a strong sourceURL -> WeakRef(Module),
* - WeakRef(Module[source_map_data_private_symbol]): source map data.
*
* Obsolete `weakModuleMap` entries are removed by the `finalizationRegistry`
* callback. This pattern decouples the strong url reference to the source map
* data and allow the cache to be reclaimed eagerly, without depending on an
* undeterministic callback of a finalization registry.
*/
class SourceMapCacheMap {
/**
* @type {Map<string, WeakRef<*>>}
* The cached module instance can be removed from the global module registry
* with approaches like mutating `require.cache`.
* The `weakModuleMap` exposes entries by `filename` and `sourceURL`.
* In the case of mutated module registry, obsolete entries are removed from
* the cache by the `finalizationRegistry`.
*/
#weakModuleMap = new SafeMap();

#cleanup = ({ keys }) => {
// Delete the entry if the weak target has been reclaimed.
// If the weak target is not reclaimed, the entry was overridden by a new
// weak target.
ArrayPrototypeForEach(keys, (key) => {
const ref = this.#weakModuleMap.get(key);
if (ref && ref.deref() === undefined) {
this.#weakModuleMap.delete(key);
}
});
};
#finalizationRegistry = new SafeFinalizationRegistry(this.#cleanup);

/**
* Sets the value for the given key, associated with the given module
* instance.
* @param {string[]} keys array of urls to index the value entry.
* @param {*} sourceMapData the value entry.
* @param {object} moduleInstance an object that can be weakly referenced and
* invalidate the [key, value] entry after this object is reclaimed.
*/
set(keys, sourceMapData, moduleInstance) {
const weakRef = new SafeWeakRef(moduleInstance);
ArrayPrototypeForEach(keys, (key) => this.#weakModuleMap.set(key, weakRef));
moduleInstance[source_map_data_private_symbol] = sourceMapData;
this.#finalizationRegistry.register(moduleInstance, { keys });
}

/**
* Get an entry by the given key.
* @param {string} key a file url or source url
*/
get(key) {
const weakRef = this.#weakModuleMap.get(key);
const moduleInstance = weakRef?.deref();
if (moduleInstance === undefined) {
return;
}
return moduleInstance[source_map_data_private_symbol];
}

/**
* Estimate the size of the cache. The actual size may be smaller because
* some entries may be reclaimed with the module instance.
*/
get size() {
return this.#weakModuleMap.size;
}

[SymbolIterator]() {
const iterator = this.#weakModuleMap.entries();

const next = () => {
const result = iterator.next();
if (result.done) return result;
const { 0: key, 1: weakRef } = result.value;
const moduleInstance = weakRef.deref();
if (moduleInstance == null) return next();
const value = moduleInstance[source_map_data_private_symbol];
return { done: false, value: [key, value] };
};

return {
[SymbolIterator]() { return this; },
next,
};
}
}

ObjectFreeze(SourceMapCacheMap.prototype);

module.exports = {
SourceMapCacheMap,
};
84 changes: 0 additions & 84 deletions lib/internal/util/iterable_weak_map.js

This file was deleted.

3 changes: 2 additions & 1 deletion src/env_properties.h
Original file line number Diff line number Diff line change
@@ -35,7 +35,8 @@
V(napi_wrapper, "node:napi:wrapper") \
V(untransferable_object_private_symbol, "node:untransferableObject") \
V(exit_info_private_symbol, "node:exit_info_private_symbol") \
V(promise_trace_id, "node:promise_trace_id")
V(promise_trace_id, "node:promise_trace_id") \
V(source_map_data_private_symbol, "node:source_map_data_private_symbol")

// Symbols are per-isolate primitives but Environment proxies them
// for the sake of convenience.
8 changes: 8 additions & 0 deletions src/module_wrap.cc
Original file line number Diff line number Diff line change
@@ -312,6 +312,14 @@ void ModuleWrap::New(const FunctionCallbackInfo<Value>& args) {
return;
}

// Initialize an empty slot for source map cache before the object is frozen.
if (that->SetPrivate(context,
realm->isolate_data()->source_map_data_private_symbol(),
Undefined(isolate))
.IsNothing()) {
return;
}

// Use the extras object as an object whose GetCreationContext() will be the
// original `context`, since the `Context` itself strictly speaking cannot
// be stored in an internal field.
34 changes: 34 additions & 0 deletions test/fixtures/source-map/no-throw.js
42 changes: 42 additions & 0 deletions test/fixtures/source-map/no-throw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
class Foo {
x;
constructor (x = 33) {
this.x = x ? x : 99
if (this.x) {
this.methodA()
} else {
this.methodB()
}
this.methodC()
}
methodA () {

}
methodB () {

}
methodC () {

}
methodD () {

}
}

const a = new Foo(0)
const b = new Foo(33)
a.methodD()

declare const module: {
exports: any
}

module.exports = {
a,
b,
Foo,
}

// To recreate:
//
// npx tsc --outDir test/fixtures/source-map --inlineSourceMap --inlineSources test/fixtures/source-map/no-throw.ts
35 changes: 35 additions & 0 deletions test/fixtures/source-map/no-throw2.js
4 changes: 2 additions & 2 deletions test/fixtures/source-map/output/source_map_disabled_by_api.js
Original file line number Diff line number Diff line change
@@ -15,10 +15,10 @@ try {
console.log(e);
}

// Delete the CJS module cache and loading the module again with source maps
// support enabled programmatically.
delete require.cache[require
.resolve('../enclosing-call-site-min.js')];

// Re-enable.
process.setSourceMapsEnabled(true);
assert.strictEqual(process.sourceMapsEnabled, true);

Original file line number Diff line number Diff line change
@@ -20,10 +20,8 @@ try {
console.log(e);
}

delete require.cache[require
.resolve('../enclosing-call-site-min.js')];

// Disable
// Source maps support is disabled programmatically even without deleting the
// CJS module cache.
process.setSourceMapsEnabled(false);
assert.strictEqual(process.sourceMapsEnabled, false);

101 changes: 0 additions & 101 deletions test/parallel/test-internal-iterable-weak-map.js

This file was deleted.

36 changes: 36 additions & 0 deletions test/parallel/test-source-map-cjs-require-cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Flags: --enable-source-maps --max-old-space-size=10 --expose-gc

/**
* This test verifies that the source map of a CJS module is cleared after the
* CJS module is reclaimed by GC.
*/

'use strict';
const common = require('../common');
const assert = require('node:assert');
const { findSourceMap } = require('node:module');

const moduleId = require.resolve('../fixtures/source-map/no-throw.js');
const moduleIdRepeat = require.resolve('../fixtures/source-map/no-throw2.js');

function run(moduleId) {
require(moduleId);
delete require.cache[moduleId];
const idx = module.children.findIndex((child) => child.id === moduleId);
assert.ok(idx >= 0);
module.children.splice(idx, 1);

// Verify that the source map is still available
assert.notStrictEqual(findSourceMap(moduleId), undefined);
}

// Run the test in a function scope so that every variable can be reclaimed by GC.
run(moduleId);

// Run until the source map is cleared by GC, or fail the test after determined iterations.
common.gcUntil('SourceMap of deleted CJS module is cleared', () => {
// Repetitively load a second module with --max-old-space-size=10 to make GC more aggressive.
run(moduleIdRepeat);
// Verify that the source map is cleared.
return findSourceMap(moduleId) == null;
});

0 comments on commit fe007cd

Please sign in to comment.