Skip to content

Commit d8375d6

Browse files
legendecastargos
authored andcommitted
lib: decorate async stack trace in source maps
Decorate stack frame with 'async' and 'new' keywords based on the type of the call site info. PR-URL: #53860 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Michaël Zasso <targos@protonmail.com> Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
1 parent 66f7c59 commit d8375d6

11 files changed

+166
-45
lines changed

lib/internal/source_map/prepare_stack_trace.js

+68-43
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ const {
2424
const { fileURLToPath } = require('internal/url');
2525
const { setGetSourceMapErrorSource } = internalBinding('errors');
2626

27+
const kStackLineAt = '\n at ';
28+
2729
// Create a prettified stacktrace, inserting context from source maps
2830
// if possible.
2931
function prepareStackTraceWithSourceMaps(error, trace) {
@@ -40,75 +42,98 @@ function prepareStackTraceWithSourceMaps(error, trace) {
4042

4143
let lastSourceMap;
4244
let lastFileName;
43-
const preparedTrace = ArrayPrototypeJoin(ArrayPrototypeMap(trace, (t, i) => {
44-
const str = '\n at ';
45+
const preparedTrace = ArrayPrototypeJoin(ArrayPrototypeMap(trace, (callSite, i) => {
4546
try {
4647
// A stack trace will often have several call sites in a row within the
4748
// same file, cache the source map and file content accordingly:
48-
let fileName = t.getFileName();
49+
let fileName = callSite.getFileName();
4950
if (fileName === undefined) {
50-
fileName = t.getEvalOrigin();
51+
fileName = callSite.getEvalOrigin();
5152
}
5253
const sm = fileName === lastFileName ?
5354
lastSourceMap :
5455
findSourceMap(fileName);
5556
lastSourceMap = sm;
5657
lastFileName = fileName;
5758
if (sm) {
58-
// Source Map V3 lines/columns start at 0/0 whereas stack traces
59-
// start at 1/1:
60-
const {
61-
originalLine,
62-
originalColumn,
63-
originalSource,
64-
} = sm.findEntry(t.getLineNumber() - 1, t.getColumnNumber() - 1);
65-
if (originalSource && originalLine !== undefined &&
66-
originalColumn !== undefined) {
67-
const name = getOriginalSymbolName(sm, trace, i);
68-
// Construct call site name based on: v8.dev/docs/stack-trace-api:
69-
const fnName = t.getFunctionName() ?? t.getMethodName();
70-
const typeName = t.getTypeName();
71-
const namePrefix = typeName !== null && typeName !== 'global' ? `${typeName}.` : '';
72-
const originalName = `${namePrefix}${fnName || '<anonymous>'}`;
73-
// The original call site may have a different symbol name
74-
// associated with it, use it:
75-
const prefix = (name && name !== originalName) ?
76-
`${name}` :
77-
`${originalName}`;
78-
const hasName = !!(name || originalName);
79-
const originalSourceNoScheme =
80-
StringPrototypeStartsWith(originalSource, 'file://') ?
81-
fileURLToPath(originalSource) : originalSource;
82-
// Replace the transpiled call site with the original:
83-
return `${str}${prefix}${hasName ? ' (' : ''}` +
84-
`${originalSourceNoScheme}:${originalLine + 1}:` +
85-
`${originalColumn + 1}${hasName ? ')' : ''}`;
86-
}
59+
return `${kStackLineAt}${serializeJSStackFrame(sm, callSite, trace[i + 1])}`;
8760
}
8861
} catch (err) {
8962
debug(err);
9063
}
91-
return `${str}${t}`;
64+
return `${kStackLineAt}${callSite}`;
9265
}), '');
9366
return `${errorString}${preparedTrace}`;
9467
}
9568

69+
/**
70+
* Serialize a single call site in the stack trace.
71+
* Refer to SerializeJSStackFrame in deps/v8/src/objects/call-site-info.cc for
72+
* more details about the default ToString(CallSite).
73+
* The CallSite API is documented at https://v8.dev/docs/stack-trace-api.
74+
* @param {import('internal/source_map/source_map').SourceMap} sm
75+
* @param {CallSite} callSite - the CallSite object to be serialized
76+
* @param {CallSite} callerCallSite - caller site info
77+
* @returns {string} - the serialized call site
78+
*/
79+
function serializeJSStackFrame(sm, callSite, callerCallSite) {
80+
// Source Map V3 lines/columns start at 0/0 whereas stack traces
81+
// start at 1/1:
82+
const {
83+
originalLine,
84+
originalColumn,
85+
originalSource,
86+
} = sm.findEntry(callSite.getLineNumber() - 1, callSite.getColumnNumber() - 1);
87+
if (originalSource === undefined || originalLine === undefined ||
88+
originalColumn === undefined) {
89+
return `${callSite}`;
90+
}
91+
const name = getOriginalSymbolName(sm, callSite, callerCallSite);
92+
const originalSourceNoScheme =
93+
StringPrototypeStartsWith(originalSource, 'file://') ?
94+
fileURLToPath(originalSource) : originalSource;
95+
// Construct call site name based on: v8.dev/docs/stack-trace-api:
96+
const fnName = callSite.getFunctionName() ?? callSite.getMethodName();
97+
98+
let prefix = '';
99+
if (callSite.isAsync()) {
100+
// Promise aggregation operation frame has no locations. This must be an
101+
// async stack frame.
102+
prefix = 'async ';
103+
} else if (callSite.isConstructor()) {
104+
prefix = 'new ';
105+
}
106+
107+
const typeName = callSite.getTypeName();
108+
const namePrefix = typeName !== null && typeName !== 'global' ? `${typeName}.` : '';
109+
const originalName = `${namePrefix}${fnName || '<anonymous>'}`;
110+
// The original call site may have a different symbol name
111+
// associated with it, use it:
112+
const mappedName = (name && name !== originalName) ?
113+
`${name}` :
114+
`${originalName}`;
115+
const hasName = !!(name || originalName);
116+
// Replace the transpiled call site with the original:
117+
return `${prefix}${mappedName}${hasName ? ' (' : ''}` +
118+
`${originalSourceNoScheme}:${originalLine + 1}:` +
119+
`${originalColumn + 1}${hasName ? ')' : ''}`;
120+
}
121+
96122
// Transpilers may have removed the original symbol name used in the stack
97123
// trace, if possible restore it from the names field of the source map:
98-
function getOriginalSymbolName(sourceMap, trace, curIndex) {
124+
function getOriginalSymbolName(sourceMap, callSite, callerCallSite) {
99125
// First check for a symbol name associated with the enclosing function:
100126
const enclosingEntry = sourceMap.findEntry(
101-
trace[curIndex].getEnclosingLineNumber() - 1,
102-
trace[curIndex].getEnclosingColumnNumber() - 1,
127+
callSite.getEnclosingLineNumber() - 1,
128+
callSite.getEnclosingColumnNumber() - 1,
103129
);
104130
if (enclosingEntry.name) return enclosingEntry.name;
105-
// Fallback to using the symbol name attached to the next stack frame:
106-
const currentFileName = trace[curIndex].getFileName();
107-
const nextCallSite = trace[curIndex + 1];
108-
if (nextCallSite && currentFileName === nextCallSite.getFileName()) {
131+
// Fallback to using the symbol name attached to the caller site:
132+
const currentFileName = callSite.getFileName();
133+
if (callerCallSite && currentFileName === callerCallSite.getFileName()) {
109134
const { name } = sourceMap.findEntry(
110-
nextCallSite.getLineNumber() - 1,
111-
nextCallSite.getColumnNumber() - 1,
135+
callerCallSite.getLineNumber() - 1,
136+
callerCallSite.getColumnNumber() - 1,
112137
);
113138
return name;
114139
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Flags: --enable-source-maps
2+
import '../../../common/index.mjs';
3+
async function Throw() {
4+
await 0;
5+
throw new Error('message');
6+
}
7+
(async function main() {
8+
await Promise.all([0, 1, 2, Throw()]);
9+
})();
10+
// To recreate:
11+
//
12+
// npx --package typescript tsc --module nodenext --target esnext --outDir test/fixtures/source-map/output --sourceMap test/fixtures/source-map/output/source_map_throw_async_stack_trace.mts
13+
//# sourceMappingURL=source_map_throw_async_stack_trace.mjs.map

test/fixtures/source-map/output/source_map_throw_async_stack_trace.mjs.map

+1
Original file line numberDiff line numberDiff line change
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Flags: --enable-source-maps
2+
3+
import '../../../common/index.mjs';
4+
5+
interface Foo {
6+
/** line
7+
*
8+
* blocks */
9+
}
10+
11+
async function Throw() {
12+
await 0;
13+
throw new Error('message')
14+
}
15+
16+
(async function main() {
17+
await Promise.all([0, 1, 2, Throw()]);
18+
})()
19+
20+
// To recreate:
21+
//
22+
// npx --package typescript tsc --module nodenext --target esnext --outDir test/fixtures/source-map/output --sourceMap test/fixtures/source-map/output/source_map_throw_async_stack_trace.mts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
*output*source_map_throw_async_stack_trace.mts:13
2+
throw new Error('message')
3+
^
4+
5+
6+
Error: message
7+
at Throw (*output*source_map_throw_async_stack_trace.mts:13:9)
8+
at async Promise.all (index 3)
9+
at async main (*output*source_map_throw_async_stack_trace.mts:17:3)
10+
11+
Node.js *
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Flags: --enable-source-maps
2+
import '../../../common/index.mjs';
3+
class Foo {
4+
constructor() {
5+
throw new Error('message');
6+
}
7+
}
8+
new Foo();
9+
// To recreate:
10+
//
11+
// npx --package typescript tsc --module nodenext --target esnext --outDir test/fixtures/source-map/output --sourceMap test/fixtures/source-map/output/source_map_throw_construct.mts
12+
//# sourceMappingURL=source_map_throw_construct.mjs.map

test/fixtures/source-map/output/source_map_throw_construct.mjs.map

+1
Original file line numberDiff line numberDiff line change
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Flags: --enable-source-maps
2+
3+
import '../../../common/index.mjs';
4+
5+
interface Block {
6+
/** line
7+
*
8+
* blocks */
9+
}
10+
11+
class Foo {
12+
constructor() {
13+
throw new Error('message');
14+
}
15+
}
16+
17+
new Foo();
18+
19+
// To recreate:
20+
//
21+
// npx --package typescript tsc --module nodenext --target esnext --outDir test/fixtures/source-map/output --sourceMap test/fixtures/source-map/output/source_map_throw_construct.mts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
*output*source_map_throw_construct.mts:13
2+
throw new Error('message');
3+
^
4+
5+
6+
Error: message
7+
at new Foo (*output*source_map_throw_construct.mts:13:11)
8+
at <anonymous> (*output*source_map_throw_construct.mts:17:1)
9+
*
10+
*
11+
*
12+
13+
Node.js *

test/fixtures/source-map/output/source_map_throw_set_immediate.snapshot

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
Error: goodbye
77
at Hello (*uglify-throw-original.js:5:9)
88
at Immediate.<anonymous> (*uglify-throw-original.js:9:3)
9-
at process.processImmediate (node:internal*timers:483:21)
9+
*
1010

1111
Node.js *

test/parallel/test-node-output-sourcemaps.mjs

+3-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe('sourcemaps output', { concurrency: !process.env.TEST_PARALLEL }, () =>
1313
.replaceAll(/\/(\w)/g, '*$1')
1414
.replaceAll('*test*', '*')
1515
.replaceAll('*fixtures*source-map*', '*')
16-
.replaceAll(/(\W+).*node:internal\*modules.*/g, '$1*');
16+
.replaceAll(/(\W+).*node:.*/g, '$1*');
1717
if (common.isWindows) {
1818
const currentDeviceLetter = path.parse(process.cwd()).root.substring(0, 1).toLowerCase();
1919
const regex = new RegExp(`${currentDeviceLetter}:/?`, 'gi');
@@ -34,7 +34,9 @@ describe('sourcemaps output', { concurrency: !process.env.TEST_PARALLEL }, () =>
3434
{ name: 'source-map/output/source_map_prepare_stack_trace.js' },
3535
{ name: 'source-map/output/source_map_reference_error_tabs.js' },
3636
{ name: 'source-map/output/source_map_sourcemapping_url_string.js' },
37+
{ name: 'source-map/output/source_map_throw_async_stack_trace.mjs' },
3738
{ name: 'source-map/output/source_map_throw_catch.js' },
39+
{ name: 'source-map/output/source_map_throw_construct.mjs' },
3840
{ name: 'source-map/output/source_map_throw_first_tick.js' },
3941
{ name: 'source-map/output/source_map_throw_icu.js' },
4042
{ name: 'source-map/output/source_map_throw_set_immediate.js' },

0 commit comments

Comments
 (0)