module: refactor to use iterable-weak-map

Using an iterable WeakMap (a data-structure that uses WeakRef and
WeakMap), we are able to: stop relying on Module._cache to
serialize source maps; stop requiring an error object when calling
findSourceMap().

PR-URL: https://github.com/nodejs/node/pull/35915
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
This commit is contained in:
bcoe 2020-11-02 16:57:33 -08:00 committed by Benjamin Coe
parent 1fd7d8e903
commit 8fa90358d7
No known key found for this signature in database
GPG Key ID: 84D97FAF3C07DF69
16 changed files with 281 additions and 62 deletions

View File

@ -135,7 +135,10 @@ import { findSourceMap, SourceMap } from 'module';
const { findSourceMap, SourceMap } = require('module');
```
### `module.findSourceMap(path[, error])`
<!-- Anchors to make sure old links find a target -->
<a id="module_module_findsourcemap_path_error"></a>
### `module.findSourceMap(path)`
<!-- YAML
added:
- v13.7.0
@ -143,18 +146,11 @@ added:
-->
* `path` {string}
* `error` {Error}
* Returns: {module.SourceMap}
`path` is the resolved path for the file for which a corresponding source map
should be fetched.
The `error` instance should be passed as the second parameter to `findSourceMap`
in exceptional flows, such as when an overridden
[`Error.prepareStackTrace(error, trace)`][] is invoked. Modules are not added to
the module cache until they are successfully loaded. In these cases, source maps
are associated with the `error` instance along with the `path`.
### Class: `module.SourceMap`
<!-- YAML
added:
@ -204,7 +200,6 @@ consists of the following keys:
[ES Modules]: esm.md
[Source map v3 format]: https://sourcemaps.info/spec.html#h.mofvlxcwqzej
[`--enable-source-maps`]: cli.md#cli_enable_source_maps
[`Error.prepareStackTrace(error, trace)`]: https://v8.dev/docs/stack-trace-api#customizing-stack-traces
[`NODE_V8_COVERAGE=dir`]: cli.md#cli_node_v8_coverage_dir
[`SourceMap`]: #module_class_module_sourcemap
[`createRequire()`]: #module_module_createrequire_filename

View File

@ -967,7 +967,7 @@ This section was moved to
[Modules: `module` core module](module.md#module_source_map_v3_support).
<!-- Anchors to make sure old links find a target -->
* <a id="modules_module_findsourcemap_path_error" href="module.html#module_module_findsourcemap_path_error">`module.findSourceMap(path[, error])`</a>
* <a id="modules_module_findsourcemap_path_error" href="module.html#module_module_findsourcemap_path">`module.findSourceMap(path)`</a>
* <a id="modules_class_module_sourcemap" href="module.html#module_class_module_sourcemap">Class: `module.SourceMap`</a>
* <a id="modules_new_sourcemap_payload" href="module.html#module_new_sourcemap_payload">`new SourceMap(payload)`</a>
* <a id="modules_sourcemap_payload" href="module.html#module_sourcemap_payload">`sourceMap.payload`</a>

View File

@ -86,6 +86,7 @@ function makeSafe(unsafe, safe) {
Object.freeze(safe);
return safe;
}
primordials.makeSafe = makeSafe;
// Subclass the constructors because we need to use their prototype
// methods later.

View File

@ -57,7 +57,7 @@ const prepareStackTrace = (globalThis, error, trace) => {
let str = i !== 0 ? '\n at ' : '';
str = `${str}${t}`;
try {
const sm = findSourceMap(t.getFileName(), error);
const sm = findSourceMap(t.getFileName());
if (sm) {
// Source Map V3 lines/columns use zero-based offsets whereas, in
// stack traces, they start at 1/1.
@ -119,6 +119,7 @@ function getErrorSource(payload, originalSource, firstLine, firstColumn) {
source = readFileSync(originalSourceNoScheme, 'utf8');
} catch (err) {
debug(err);
return '';
}
}

View File

@ -1,6 +1,7 @@
'use strict';
const {
ArrayPrototypeMap,
JSONParse,
ObjectCreate,
ObjectKeys,
@ -8,8 +9,10 @@ const {
ObjectPrototypeHasOwnProperty,
Map,
MapPrototypeEntries,
WeakMap,
WeakMapPrototypeGet,
RegExpPrototypeTest,
SafeMap,
StringPrototypeMatch,
StringPrototypeSplit,
uncurryThis,
} = primordials;
@ -27,17 +30,17 @@ let debug = require('internal/util/debuglog').debuglog('source_map', (fn) => {
});
const fs = require('fs');
const { getOptionValue } = require('internal/options');
const { IterableWeakMap } = require('internal/util/iterable_weak_map');
const {
normalizeReferrerURL,
} = require('internal/modules/cjs/helpers');
// For cjs, since Module._cache is exposed to users, we use a WeakMap
// keyed on module, facilitating garbage collection.
const cjsSourceMapCache = new WeakMap();
// The esm cache is not exposed to users, so we can use a Map keyed
// on filenames.
const esmSourceMapCache = new Map();
const { fileURLToPath, URL } = require('url');
let Module;
// 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 cjsSourceMapCache = new IterableWeakMap();
// The esm cache is not mutable, so we can use a Map without memory concerns:
const esmSourceMapCache = new SafeMap();
const { fileURLToPath, pathToFileURL, URL } = require('internal/url');
let SourceMap;
let sourceMapsEnabled;
@ -70,13 +73,14 @@ function maybeCacheSourceMap(filename, content, cjsModuleInstance) {
debug(err.stack);
return;
}
const match = content.match(/\/[*/]#\s+sourceMappingURL=(?<sourceMappingURL>[^\s]+)/);
const match = StringPrototypeMatch(
content,
/\/[*/]#\s+sourceMappingURL=(?<sourceMappingURL>[^\s]+)/
);
if (match) {
const data = dataFromUrl(filename, match.groups.sourceMappingURL);
const url = data ? null : match.groups.sourceMappingURL;
if (cjsModuleInstance) {
if (!Module) Module = require('internal/modules/cjs/loader').Module;
cjsSourceMapCache.set(cjsModuleInstance, {
filename,
lineLengths: lineLengths(content),
@ -120,7 +124,7 @@ function lineLengths(content) {
// We purposefully keep \r as part of the line-length calculation, in
// cases where there is a \r\n separator, so that this can be taken into
// account in coverage calculations.
return content.split(/\n|\u2028|\u2029/).map((line) => {
return ArrayPrototypeMap(StringPrototypeSplit(content, /\n|\u2028|\u2029/), (line) => {
return line.length;
});
}
@ -139,8 +143,8 @@ function sourceMapFromFile(mapURL) {
// data:[<mediatype>][;base64],<data> see:
// https://tools.ietf.org/html/rfc2397#section-2
function sourceMapFromDataUrl(sourceURL, url) {
const [format, data] = url.split(',');
const splitFormat = format.split(';');
const [format, data] = StringPrototypeSplit(url, ',');
const splitFormat = StringPrototypeSplit(format, ';');
const contentType = splitFormat[0];
const base64 = splitFormat[splitFormat.length - 1] === 'base64';
if (contentType === 'application/json') {
@ -207,48 +211,32 @@ function sourceMapCacheToObject() {
return obj;
}
// Since WeakMap can't be iterated over, we use Module._cache's
// keys to facilitate Source Map serialization.
//
// TODO(bcoe): this means we don't currently serialize source-maps attached
// to error instances, only module instances.
function appendCJSCache(obj) {
if (!Module) return;
const cjsModuleCache = ObjectGetValueSafe(Module, '_cache');
const cjsModules = ObjectKeys(cjsModuleCache);
for (let i = 0; i < cjsModules.length; i++) {
const key = cjsModules[i];
const module = ObjectGetValueSafe(cjsModuleCache, key);
const value = WeakMapPrototypeGet(cjsSourceMapCache, module);
if (value) {
// This is okay because `obj` has a null prototype.
obj[`file://${key}`] = {
lineLengths: ObjectGetValueSafe(value, 'lineLengths'),
data: ObjectGetValueSafe(value, 'data'),
url: ObjectGetValueSafe(value, 'url')
};
}
for (const value of cjsSourceMapCache) {
obj[ObjectGetValueSafe(value, 'filename')] = {
lineLengths: ObjectGetValueSafe(value, 'lineLengths'),
data: ObjectGetValueSafe(value, 'data'),
url: ObjectGetValueSafe(value, 'url')
};
}
}
// Attempt to lookup a source map, which is either attached to a file URI, or
// keyed on an error instance.
// TODO(bcoe): once WeakRefs are available in Node.js, refactor to drop
// requirement of error parameter.
function findSourceMap(uri, error) {
if (!Module) Module = require('internal/modules/cjs/loader').Module;
function findSourceMap(sourceURL) {
if (!RegExpPrototypeTest(/^\w+:\/\//, sourceURL)) {
sourceURL = pathToFileURL(sourceURL).href;
}
if (!SourceMap) {
SourceMap = require('internal/source_map/source_map').SourceMap;
}
let sourceMap = cjsSourceMapCache.get(Module._cache[uri]);
if (!uri.startsWith('file://')) uri = normalizeReferrerURL(uri);
let sourceMap = esmSourceMapCache.get(sourceURL);
if (sourceMap === undefined) {
sourceMap = esmSourceMapCache.get(uri);
}
if (sourceMap === undefined) {
const candidateSourceMap = cjsSourceMapCache.get(error);
if (candidateSourceMap && uri === candidateSourceMap.filename) {
sourceMap = candidateSourceMap;
for (const value of cjsSourceMapCache) {
const filename = ObjectGetValueSafe(value, 'filename');
if (sourceURL === filename) {
sourceMap = {
data: ObjectGetValueSafe(value, 'data')
};
}
}
}
if (sourceMap && sourceMap.data) {

View File

@ -0,0 +1,86 @@
'use strict';
const {
makeSafe,
Object,
SafeSet,
SafeWeakMap,
SymbolIterator,
} = primordials;
// TODO(aduh95): Add FinalizationRegistry to primordials
const SafeFinalizationRegistry = makeSafe(
globalThis.FinalizationRegistry,
class SafeFinalizationRegistry extends globalThis.FinalizationRegistry {}
);
// TODO(aduh95): Add WeakRef to primordials
const SafeWeakRef = makeSafe(
globalThis.WeakRef,
class SafeWeakRef extends globalThis.WeakRef {}
);
// This class is modified from the example code in the WeakRefs specification:
// https://github.com/tc39/proposal-weakrefs
// Licensed under ECMA's MIT-style license, see:
// https://github.com/tc39/ecma262/blob/master/LICENSE.md
class IterableWeakMap {
#weakMap = new SafeWeakMap();
#refSet = new SafeSet();
#finalizationGroup = new SafeFinalizationRegistry(cleanup);
set(key, value) {
const entry = this.#weakMap.get(key);
if (entry) {
// If there's already an entry for the object represented by "key",
// the value can be updated without creating a new WeakRef:
this.#weakMap.set(key, { value, ref: entry.ref });
} else {
const ref = new SafeWeakRef(key);
this.#weakMap.set(key, { value, ref });
this.#refSet.add(ref);
this.#finalizationGroup.register(key, {
set: this.#refSet,
ref
}, ref);
}
}
get(key) {
return this.#weakMap.get(key)?.value;
}
has(key) {
return this.#weakMap.has(key);
}
delete(key) {
const entry = this.#weakMap.get(key);
if (!entry) {
return false;
}
this.#weakMap.delete(key);
this.#refSet.delete(entry.ref);
this.#finalizationGroup.unregister(entry.ref);
return true;
}
*[SymbolIterator]() {
for (const ref of this.#refSet) {
const key = ref.deref();
if (!key) continue;
const { value } = this.#weakMap.get(key);
yield value;
}
}
}
function cleanup({ set, ref }) {
set.delete(ref);
}
Object.freeze(IterableWeakMap.prototype);
module.exports = {
IterableWeakMap,
};

View File

@ -230,6 +230,7 @@
'lib/internal/util/debuglog.js',
'lib/internal/util/inspect.js',
'lib/internal/util/inspector.js',
'lib/internal/util/iterable_weak_map.js',
'lib/internal/util/types.js',
'lib/internal/http2/core.js',
'lib/internal/http2/compat.js',

View File

@ -0,0 +1,4 @@
"use strict";
exports.__esModule = true;
require("./throw-on-require");
//# sourceMappingURL=throw-on-require-entry.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"throw-on-require-entry.js","sourceRoot":"","sources":["throw-on-require-entry.ts"],"names":[],"mappings":";;AAAA,8BAA2B"}

View File

@ -0,0 +1,15 @@
var ATrue;
(function (ATrue) {
ATrue[ATrue["IsTrue"] = 1] = "IsTrue";
ATrue[ATrue["IsFalse"] = 0] = "IsFalse";
})(ATrue || (ATrue = {}));
if (false) {
console.info('unreachable');
}
else if (true) {
throw Error('throw early');
}
else {
console.info('unreachable');
}
//# sourceMappingURL=throw-on-require.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"throw-on-require.js","sourceRoot":"","sources":["throw-on-require.ts"],"names":[],"mappings":"AAAA,IAAK,KAGJ;AAHD,WAAK,KAAK;IACR,qCAAU,CAAA;IACV,uCAAW,CAAA;AACb,CAAC,EAHI,KAAK,KAAL,KAAK,QAGT;AAED,IAAI,KAAK,EAAE;IACT,OAAO,CAAC,IAAI,CAAC,aAAa,CAAC,CAAA;CAC5B;KAAM,IAAI,IAAI,EAAE;IACf,MAAM,KAAK,CAAC,aAAa,CAAC,CAAA;CAC3B;KAAM;IACL,OAAO,CAAC,IAAI,CAAC,aAAa,CAAC,CAAA;CAC5B"}

View File

@ -0,0 +1,12 @@
enum ATrue {
IsTrue = 1,
IsFalse = 0
}
if (false) {
console.info('unreachable')
} else if (true) {
throw Error('throw early')
} else {
console.info('unreachable')
}

View File

@ -92,6 +92,7 @@ const expectedModules = new Set([
'NativeModule internal/util',
'NativeModule internal/util/debuglog',
'NativeModule internal/util/inspect',
'NativeModule internal/util/iterable_weak_map',
'NativeModule internal/util/types',
'NativeModule internal/validators',
'NativeModule internal/vm/module',

View File

@ -0,0 +1,95 @@
// Flags: --expose-gc --expose-internals
'use strict';
require('../common');
const { deepStrictEqual, strictEqual } = require('assert');
const { IterableWeakMap } = require('internal/util/iterable_weak_map');
// It drops entry if a reference is no longer held.
{
const wm = new IterableWeakMap();
const _cache = {
moduleA: {},
moduleB: {},
moduleC: {},
};
wm.set(_cache.moduleA, 'hello');
wm.set(_cache.moduleB, 'discard');
wm.set(_cache.moduleC, 'goodbye');
delete _cache.moduleB;
setImmediate(() => {
_cache;
globalThis.gc();
const values = [...wm];
deepStrictEqual(values, ['hello', 'goodbye']);
});
}
// It updates an existing entry, if the same key is provided twice.
{
const wm = new IterableWeakMap();
const _cache = {
moduleA: {},
moduleB: {},
};
wm.set(_cache.moduleA, 'hello');
wm.set(_cache.moduleB, 'goodbye');
wm.set(_cache.moduleB, 'goodnight');
const values = [...wm];
deepStrictEqual(values, ['hello', 'goodnight']);
}
// It allows entry to be deleted by key.
{
const wm = new IterableWeakMap();
const _cache = {
moduleA: {},
moduleB: {},
moduleC: {},
};
wm.set(_cache.moduleA, 'hello');
wm.set(_cache.moduleB, 'discard');
wm.set(_cache.moduleC, 'goodbye');
wm.delete(_cache.moduleB);
const values = [...wm];
deepStrictEqual(values, ['hello', 'goodbye']);
}
// It handles delete for key that does not exist.
{
const wm = new IterableWeakMap();
const _cache = {
moduleA: {},
moduleB: {},
moduleC: {},
};
wm.set(_cache.moduleA, 'hello');
wm.set(_cache.moduleC, 'goodbye');
wm.delete(_cache.moduleB);
const values = [...wm];
deepStrictEqual(values, ['hello', 'goodbye']);
}
// It allows an entry to be fetched by key.
{
const wm = new IterableWeakMap();
const _cache = {
moduleA: {},
moduleB: {},
moduleC: {},
};
wm.set(_cache.moduleA, 'hello');
wm.set(_cache.moduleB, 'discard');
wm.set(_cache.moduleC, 'goodbye');
strictEqual(wm.get(_cache.moduleB), 'discard');
}
// It returns true for has() if key exists.
{
const wm = new IterableWeakMap();
const _cache = {
moduleA: {},
};
wm.set(_cache.moduleA, 'hello');
strictEqual(wm.has(_cache.moduleA), true);
}

View File

@ -31,7 +31,7 @@ const { readFileSync } = require('fs');
Error.prepareStackTrace = (error, trace) => {
const throwingRequireCallSite = trace[0];
if (throwingRequireCallSite.getFileName().endsWith('typescript-throw.js')) {
sourceMap = findSourceMap(throwingRequireCallSite.getFileName(), error);
sourceMap = findSourceMap(throwingRequireCallSite.getFileName());
callSite = throwingRequireCallSite;
}
};

View File

@ -283,6 +283,24 @@ function nextdir() {
assert.ok(output.stderr.toString().includes('webpack:///webpack.js:14:9'));
}
// Stores and applies source map associated with file that throws while
// being required.
{
const coverageDirectory = nextdir();
const output = spawnSync(process.execPath, [
'--enable-source-maps',
require.resolve('../fixtures/source-map/throw-on-require-entry.js')
], { env: { ...process.env, NODE_V8_COVERAGE: coverageDirectory } });
const sourceMap = getSourceMapFromCache(
'throw-on-require.js',
coverageDirectory
);
// Rewritten stack trace.
assert.match(output.stderr.toString(), /throw-on-require\.ts:9:9/);
// Source map should have been serialized.
assert.ok(sourceMap);
}
function getSourceMapFromCache(fixtureFile, coverageDirectory) {
const jsonFiles = fs.readdirSync(coverageDirectory);
for (const jsonFile of jsonFiles) {