Skip to content

Commit 0f58a3d

Browse files
authored
util: hide duplicated stack frames when using util.inspect
Long stack traces often have duplicated stack frames from recursive calls. These make it difficult to identify important parts of the stack. This hides the duplicated ones and notifies the user which lines were hidden. PR-URL: #59447 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Michaël Zasso <targos@protonmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com> Reviewed-By: Moshe Atlow <moshe@atlow.co.il> Reviewed-By: Jordan Harband <ljharb@gmail.com>
1 parent 8e2b093 commit 0f58a3d

File tree

2 files changed

+300
-3
lines changed

2 files changed

+300
-3
lines changed

lib/internal/util/inspect.js

Lines changed: 131 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1567,13 +1567,122 @@ function identicalSequenceRange(a, b) {
15671567
len++;
15681568
}
15691569
if (len > 3) {
1570-
return { len, offset: i };
1570+
return [len, i];
15711571
}
15721572
}
15731573
}
15741574
}
15751575

1576-
return { len: 0, offset: 0 };
1576+
return [0, 0];
1577+
}
1578+
1579+
function getDuplicateErrorFrameRanges(frames) {
1580+
// Build a map: frame line -> sorted list of indices where it occurs
1581+
const result = [];
1582+
const lineToPositions = new SafeMap();
1583+
1584+
for (let i = 0; i < frames.length; i++) {
1585+
const positions = lineToPositions.get(frames[i]);
1586+
if (positions === undefined) {
1587+
lineToPositions.set(frames[i], [i]);
1588+
} else {
1589+
positions[positions.length] = i;
1590+
}
1591+
}
1592+
1593+
const minimumDuplicateRange = 3;
1594+
// Not enough duplicate lines to consider collapsing
1595+
if (frames.length - lineToPositions.size <= minimumDuplicateRange) {
1596+
return result;
1597+
}
1598+
1599+
for (let i = 0; i < frames.length - minimumDuplicateRange; i++) {
1600+
const positions = lineToPositions.get(frames[i]);
1601+
// Find the next occurrence of the same line after i, if any
1602+
if (positions.length === 1 || positions[positions.length - 1] === i) {
1603+
continue;
1604+
}
1605+
1606+
const current = positions.indexOf(i) + 1;
1607+
if (current === positions.length) {
1608+
continue;
1609+
}
1610+
1611+
// Theoretical maximum range, adjusted while iterating
1612+
let range = positions[positions.length - 1] - i;
1613+
if (range < minimumDuplicateRange) {
1614+
continue;
1615+
}
1616+
let extraSteps;
1617+
if (current + 1 < positions.length) {
1618+
// Optimize initial step size by choosing the greatest common divisor (GCD)
1619+
// of all candidate distances to the same frame line. This tends to match
1620+
// the true repeating block size and minimizes fallback iterations.
1621+
let gcdRange = 0;
1622+
for (let j = current; j < positions.length; j++) {
1623+
let distance = positions[j] - i;
1624+
while (distance !== 0) {
1625+
const remainder = gcdRange % distance;
1626+
if (gcdRange !== 0) {
1627+
// Add other possible ranges as fallback
1628+
extraSteps ??= new SafeSet();
1629+
extraSteps.add(gcdRange);
1630+
}
1631+
gcdRange = distance;
1632+
distance = remainder;
1633+
}
1634+
if (gcdRange === 1) break;
1635+
}
1636+
range = gcdRange;
1637+
if (extraSteps) {
1638+
extraSteps.delete(range);
1639+
extraSteps = [...extraSteps];
1640+
}
1641+
}
1642+
let maxRange = range;
1643+
let maxDuplicates = 0;
1644+
1645+
let duplicateRanges = 0;
1646+
1647+
for (let nextStart = i + range; /* ignored */ ; nextStart += range) {
1648+
let equalFrames = 0;
1649+
for (let j = 0; j < range; j++) {
1650+
if (frames[i + j] !== frames[nextStart + j]) {
1651+
break;
1652+
}
1653+
equalFrames++;
1654+
}
1655+
// Adjust the range to match different type of ranges.
1656+
if (equalFrames !== range) {
1657+
if (!extraSteps?.length) {
1658+
break;
1659+
}
1660+
// Memorize former range in case the smaller one would hide less.
1661+
if (duplicateRanges !== 0 && maxRange * maxDuplicates < range * duplicateRanges) {
1662+
maxRange = range;
1663+
maxDuplicates = duplicateRanges;
1664+
}
1665+
range = extraSteps.pop();
1666+
nextStart = i;
1667+
duplicateRanges = 0;
1668+
continue;
1669+
}
1670+
duplicateRanges++;
1671+
}
1672+
1673+
if (maxDuplicates !== 0 && maxRange * maxDuplicates >= range * duplicateRanges) {
1674+
range = maxRange;
1675+
duplicateRanges = maxDuplicates;
1676+
}
1677+
1678+
if (duplicateRanges * range >= 3) {
1679+
result.push(i + range, range, duplicateRanges);
1680+
// Skip over the collapsed portion to avoid overlapping matches.
1681+
i += range * (duplicateRanges + 1) - 1;
1682+
}
1683+
}
1684+
1685+
return result;
15771686
}
15781687

15791688
function getStackString(ctx, error) {
@@ -1607,14 +1716,33 @@ function getStackFrames(ctx, err, stack) {
16071716
const causeStackStart = StringPrototypeIndexOf(causeStack, '\n at');
16081717
if (causeStackStart !== -1) {
16091718
const causeFrames = StringPrototypeSplit(StringPrototypeSlice(causeStack, causeStackStart + 1), '\n');
1610-
const { len, offset } = identicalSequenceRange(frames, causeFrames);
1719+
const { 0: len, 1: offset } = identicalSequenceRange(frames, causeFrames);
16111720
if (len > 0) {
16121721
const skipped = len - 2;
16131722
const msg = ` ... ${skipped} lines matching cause stack trace ...`;
16141723
frames.splice(offset + 1, skipped, ctx.stylize(msg, 'undefined'));
16151724
}
16161725
}
16171726
}
1727+
1728+
// Remove recursive repetitive stack frames in long stacks
1729+
if (frames.length > 10) {
1730+
const ranges = getDuplicateErrorFrameRanges(frames);
1731+
1732+
for (let i = ranges.length - 3; i >= 0; i -= 3) {
1733+
const offset = ranges[i];
1734+
const length = ranges[i + 1];
1735+
const duplicateRanges = ranges[i + 2];
1736+
1737+
const msg = ` ... collapsed ${length * duplicateRanges} duplicate lines ` +
1738+
'matching above ' +
1739+
(duplicateRanges > 1 ?
1740+
`${length} lines ${duplicateRanges} times...` :
1741+
'lines ...');
1742+
frames.splice(offset, length * duplicateRanges, ctx.stylize(msg, 'undefined'));
1743+
}
1744+
}
1745+
16181746
return frames;
16191747
}
16201748

test/parallel/test-util-inspect.js

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2920,6 +2920,175 @@ assert.strictEqual(
29202920
process.cwd = originalCWD;
29212921
}
29222922

2923+
{
2924+
// Use a fake stack to verify the expected colored outcome.
2925+
const err = new Error('Hide duplicate frames in long stack');
2926+
err.stack = [
2927+
'Error: Hide duplicate frames in long stack',
2928+
' at A.<anonymous> (/foo/node_modules/bar/baz.js:2:7)',
2929+
' at A.<anonymous> (/foo/node_modules/bar/baz.js:2:7)',
2930+
' at Module._compile (node:internal/modules/cjs/loader:827:30)',
2931+
' at Fancy (node:vm:697:32)',
2932+
' at tryModuleLoad (node:internal/modules/cjs/foo:629:12)',
2933+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
2934+
' at Fancy (node:vm:697:32)',
2935+
' at tryModuleLoad (node:internal/modules/cjs/foo:629:12)',
2936+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
2937+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
2938+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
2939+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
2940+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
2941+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
2942+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
2943+
' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)',
2944+
' at require (node:internal/modules/helpers:14:16)',
2945+
' at Array.forEach (<anonymous>)',
2946+
' at require (node:internal/modules/helpers:14:16)',
2947+
' at Array.forEach (<anonymous>)',
2948+
` at foobar/test/parallel/test-util-inspect.js:2760:12`,
2949+
` at Object.<anonymous> (foobar/node_modules/m/folder/file.js:2753:10)`,
2950+
' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)',
2951+
' at require (node:internal/modules/helpers:14:16)',
2952+
' at Array.forEach (<anonymous>)',
2953+
` at foobar/test/parallel/test-util-inspect.js:2760:12`,
2954+
` at Object.<anonymous> (foobar/node_modules/m/folder/file.js:2753:10)`,
2955+
' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)',
2956+
' at require (node:internal/modules/helpers:14:16)',
2957+
' at Array.forEach (<anonymous>)',
2958+
` at foobar/test/parallel/test-util-inspect.js:2760:12`,
2959+
` at Object.<anonymous> (foobar/node_modules/m/folder/file.js:2753:10)`,
2960+
' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)',
2961+
' at require (node:internal/modules/helpers:14:16)',
2962+
' at Array.forEach (<anonymous>)',
2963+
` at foobar/test/parallel/test-util-inspect.js:2760:12`,
2964+
` at Object.<anonymous> (foobar/node_modules/m/folder/file.js:2753:10)`,
2965+
' at /test/test-util-inspect.js:2239:9',
2966+
' at getActual (node:assert:592:5)',
2967+
' at /test/test-util-inspect.js:2239:9',
2968+
' at getActual (node:assert:592:5)',
2969+
' at /test/test-util-inspect.js:2239:9',
2970+
' at getActual (node:assert:592:5)',
2971+
].join('\n');
2972+
2973+
assert.strictEqual(
2974+
util.inspect(err, { colors: true }),
2975+
'Error: Hide duplicate frames in long stack\n' +
2976+
' at A.<anonymous> (/foo/node_modules/\x1B[4mbar\x1B[24m/baz.js:2:7)\n' +
2977+
' at A.<anonymous> (/foo/node_modules/\x1B[4mbar\x1B[24m/baz.js:2:7)\n' +
2978+
'\x1B[90m at Module._compile (node:internal/modules/cjs/loader:827:30)\x1B[39m\n' +
2979+
'\x1B[90m at Fancy (node:vm:697:32)\x1B[39m\n' +
2980+
' at tryModuleLoad (node:internal/modules/cjs/foo:629:12)\n' +
2981+
'\x1B[90m at Function.Module._load (node:internal/modules/cjs/loader:621:3)\x1B[39m\n' +
2982+
'\x1B[90m ... collapsed 3 duplicate lines matching above lines ...\x1B[39m\n' +
2983+
2984+
'\x1B[90m at Function.Module._load (node:internal/modules/cjs/loader:621:3)\x1B[39m\n' +
2985+
'\x1B[90m ... collapsed 5 duplicate lines matching above 1 lines 5 times...\x1B[39m\n' +
2986+
2987+
' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)\n' +
2988+
'\x1B[90m at require (node:internal/modules/helpers:14:16)\x1B[39m\n' +
2989+
' at Array.forEach (<anonymous>)\n' +
2990+
'\x1B[90m at require (node:internal/modules/helpers:14:16)\x1B[39m\n' +
2991+
' at Array.forEach (<anonymous>)\n' +
2992+
' at foobar/test/parallel/test-util-inspect.js:2760:12\n' +
2993+
' at Object.<anonymous> (foobar/node_modules/\x1B[4mm\x1B[24m/folder/file.js:2753:10)\n' +
2994+
' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)\n' +
2995+
'\x1B[90m ... collapsed 10 duplicate lines matching above 5 lines 2 times...\x1B[39m\n' +
2996+
2997+
'\x1B[90m at require (node:internal/modules/helpers:14:16)\x1B[39m\n' +
2998+
' at Array.forEach (<anonymous>)\n' +
2999+
' at foobar/test/parallel/test-util-inspect.js:2760:12\n' +
3000+
' at Object.<anonymous> (foobar/node_modules/\x1B[4mm\x1B[24m/folder/file.js:2753:10)\n' +
3001+
' at /test/test-util-inspect.js:2239:9\n' +
3002+
'\x1B[90m at getActual (node:assert:592:5)\x1B[39m\n' +
3003+
'\x1B[90m ... collapsed 4 duplicate lines matching above 2 lines 2 times...\x1B[39m',
3004+
);
3005+
3006+
// Use a fake stack to verify the expected colored outcome.
3007+
const err2 = new Error('Hide duplicate frames in long stack');
3008+
err2.stack = [
3009+
'Error: Hide duplicate frames in long stack',
3010+
' at A.<anonymous> (/foo/node_modules/bar/baz.js:2:7)',
3011+
' at A.<anonymous> (/foo/node_modules/bar/baz.js:2:7)',
3012+
' at Module._compile (node:internal/modules/cjs/loader:827:30)',
3013+
3014+
// 3
3015+
' at Fancy (node:vm:697:32)',
3016+
' at tryModuleLoad (node:internal/modules/cjs/foo:629:12)',
3017+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
3018+
' at Fancy (node:vm:697:32)',
3019+
' at tryModuleLoad (node:internal/modules/cjs/foo:629:12)',
3020+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
3021+
3022+
// 6 * 1
3023+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
3024+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
3025+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
3026+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
3027+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
3028+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
3029+
' at Function.Module._load (node:internal/modules/cjs/loader:621:3)',
3030+
3031+
// 10
3032+
' at require (node:internal/modules/helpers:14:16)',
3033+
' at Array.forEach (<anonymous>)',
3034+
` at foobar/test/parallel/test-util-inspect.js:2760:12`,
3035+
` at Object.<anonymous> (foobar/node_modules/m/folder/file.js:2753:10)`,
3036+
' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)',
3037+
' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)',
3038+
' at require (node:internal/modules/helpers:14:16)',
3039+
' at Array.forEach (<anonymous>)',
3040+
` at foobar/test/parallel/test-util-inspect.js:2760:12`,
3041+
` at Object.<anonymous> (foobar/node_modules/m/folder/file.js:2753:10)`,
3042+
3043+
' at require (node:internal/modules/helpers:14:16)',
3044+
' at Array.forEach (<anonymous>)',
3045+
` at foobar/test/parallel/test-util-inspect.js:2760:12`,
3046+
` at Object.<anonymous> (foobar/node_modules/m/folder/file.js:2753:10)`,
3047+
' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)',
3048+
' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)',
3049+
' at require (node:internal/modules/helpers:14:16)',
3050+
' at Array.forEach (<anonymous>)',
3051+
` at foobar/test/parallel/test-util-inspect.js:2760:12`,
3052+
` at Object.<anonymous> (foobar/node_modules/m/folder/file.js:2753:10)`,
3053+
3054+
// 2 * 2
3055+
' at /test/test-util-inspect.js:2239:9',
3056+
' at getActual (node:assert:592:5)',
3057+
' at /test/test-util-inspect.js:2239:9',
3058+
' at getActual (node:assert:592:5)',
3059+
' at /test/test-util-inspect.js:2239:9',
3060+
' at getActual (node:assert:592:5)',
3061+
].join('\n');
3062+
3063+
assert.strictEqual(
3064+
util.inspect(err2, { colors: true }),
3065+
'Error: Hide duplicate frames in long stack\n' +
3066+
' at A.<anonymous> (/foo/node_modules/\x1B[4mbar\x1B[24m/baz.js:2:7)\n' +
3067+
' at A.<anonymous> (/foo/node_modules/\x1B[4mbar\x1B[24m/baz.js:2:7)\n' +
3068+
'\x1B[90m at Module._compile (node:internal/modules/cjs/loader:827:30)\x1B[39m\n' +
3069+
'\x1B[90m at Fancy (node:vm:697:32)\x1B[39m\n' +
3070+
' at tryModuleLoad (node:internal/modules/cjs/foo:629:12)\n' +
3071+
'\x1B[90m at Function.Module._load (node:internal/modules/cjs/loader:621:3)\x1B[39m\n' +
3072+
'\x1B[90m ... collapsed 3 duplicate lines matching above lines ...\x1B[39m\n' +
3073+
'\x1B[90m at Function.Module._load (node:internal/modules/cjs/loader:621:3)\x1B[39m\n' +
3074+
'\x1B[90m ... collapsed 6 duplicate lines matching above 1 lines 6 times...\x1B[39m\n' +
3075+
'\x1B[90m at require (node:internal/modules/helpers:14:16)\x1B[39m\n' +
3076+
' at Array.forEach (<anonymous>)\n' +
3077+
' at foobar/test/parallel/test-util-inspect.js:2760:12\n' +
3078+
' at Object.<anonymous> (foobar/node_modules/\x1B[4mm\x1B[24m/folder/file.js:2753:10)\n' +
3079+
' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)\n' +
3080+
' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)\n' +
3081+
'\x1B[90m at require (node:internal/modules/helpers:14:16)\x1B[39m\n' +
3082+
' at Array.forEach (<anonymous>)\n' +
3083+
' at foobar/test/parallel/test-util-inspect.js:2760:12\n' +
3084+
' at Object.<anonymous> (foobar/node_modules/\x1B[4mm\x1B[24m/folder/file.js:2753:10)\n' +
3085+
'\x1B[90m ... collapsed 10 duplicate lines matching above lines ...\x1B[39m\n' +
3086+
' at /test/test-util-inspect.js:2239:9\n' +
3087+
'\x1B[90m at getActual (node:assert:592:5)\x1B[39m\n' +
3088+
'\x1B[90m ... collapsed 4 duplicate lines matching above 2 lines 2 times...\x1B[39m',
3089+
);
3090+
}
3091+
29233092
{
29243093
// Cross platform checks.
29253094
const err = new Error('foo');

0 commit comments

Comments
 (0)