diff --git a/lib/internal/util/inspect.js b/lib/internal/util/inspect.js index 78318243e65eca..354832379c9823 100644 --- a/lib/internal/util/inspect.js +++ b/lib/internal/util/inspect.js @@ -1305,13 +1305,122 @@ function identicalSequenceRange(a, b) { len++; } if (len > 3) { - return { len, offset: i }; + return [len, i]; } } } } - return { len: 0, offset: 0 }; + return [0, 0]; +} + +function getDuplicateErrorFrameRanges(frames) { + // Build a map: frame line -> sorted list of indices where it occurs + const result = []; + const lineToPositions = new SafeMap(); + + for (let i = 0; i < frames.length; i++) { + const positions = lineToPositions.get(frames[i]); + if (positions === undefined) { + lineToPositions.set(frames[i], [i]); + } else { + positions[positions.length] = i; + } + } + + const minimumDuplicateRange = 3; + // Not enough duplicate lines to consider collapsing + if (frames.length - lineToPositions.size <= minimumDuplicateRange) { + return result; + } + + for (let i = 0; i < frames.length - minimumDuplicateRange; i++) { + const positions = lineToPositions.get(frames[i]); + // Find the next occurrence of the same line after i, if any + if (positions.length === 1 || positions[positions.length - 1] === i) { + continue; + } + + const current = positions.indexOf(i) + 1; + if (current === positions.length) { + continue; + } + + // Theoretical maximum range, adjusted while iterating + let range = positions[positions.length - 1] - i; + if (range < minimumDuplicateRange) { + continue; + } + let extraSteps; + if (current + 1 < positions.length) { + // Optimize initial step size by choosing the greatest common divisor (GCD) + // of all candidate distances to the same frame line. This tends to match + // the true repeating block size and minimizes fallback iterations. + let gcdRange = 0; + for (let j = current; j < positions.length; j++) { + let distance = positions[j] - i; + while (distance !== 0) { + const remainder = gcdRange % distance; + if (gcdRange !== 0) { + // Add other possible ranges as fallback + extraSteps ??= new SafeSet(); + extraSteps.add(gcdRange); + } + gcdRange = distance; + distance = remainder; + } + if (gcdRange === 1) break; + } + range = gcdRange; + if (extraSteps) { + extraSteps.delete(range); + extraSteps = [...extraSteps]; + } + } + let maxRange = range; + let maxDuplicates = 0; + + let duplicateRanges = 0; + + for (let nextStart = i + range; /* ignored */ ; nextStart += range) { + let equalFrames = 0; + for (let j = 0; j < range; j++) { + if (frames[i + j] !== frames[nextStart + j]) { + break; + } + equalFrames++; + } + // Adjust the range to match different type of ranges. + if (equalFrames !== range) { + if (!extraSteps?.length) { + break; + } + // Memorize former range in case the smaller one would hide less. + if (duplicateRanges !== 0 && maxRange * maxDuplicates < range * duplicateRanges) { + maxRange = range; + maxDuplicates = duplicateRanges; + } + range = extraSteps.pop(); + nextStart = i; + duplicateRanges = 0; + continue; + } + duplicateRanges++; + } + + if (maxDuplicates !== 0 && maxRange * maxDuplicates >= range * duplicateRanges) { + range = maxRange; + duplicateRanges = maxDuplicates; + } + + if (duplicateRanges * range >= 3) { + result.push(i + range, range, duplicateRanges); + // Skip over the collapsed portion to avoid overlapping matches. + i += range * (duplicateRanges + 1) - 1; + } + } + + return result; } function getStackString(ctx, error) { @@ -1345,7 +1454,7 @@ function getStackFrames(ctx, err, stack) { const causeStackStart = StringPrototypeIndexOf(causeStack, '\n at'); if (causeStackStart !== -1) { const causeFrames = StringPrototypeSplit(StringPrototypeSlice(causeStack, causeStackStart + 1), '\n'); - const { len, offset } = identicalSequenceRange(frames, causeFrames); + const { 0: len, 1: offset } = identicalSequenceRange(frames, causeFrames); if (len > 0) { const skipped = len - 2; const msg = ` ... ${skipped} lines matching cause stack trace ...`; @@ -1353,6 +1462,25 @@ function getStackFrames(ctx, err, stack) { } } } + + // Remove recursive repetitive stack frames in long stacks + if (frames.length > 10) { + const ranges = getDuplicateErrorFrameRanges(frames); + + for (let i = ranges.length - 3; i >= 0; i -= 3) { + const offset = ranges[i]; + const length = ranges[i + 1]; + const duplicateRanges = ranges[i + 2]; + + const msg = ` ... collapsed ${length * duplicateRanges} duplicate lines ` + + 'matching above ' + + (duplicateRanges > 1 ? + `${length} lines ${duplicateRanges} times...` : + 'lines ...'); + frames.splice(offset, length * duplicateRanges, ctx.stylize(msg, 'undefined')); + } + } + return frames; } diff --git a/test/parallel/test-util-inspect.js b/test/parallel/test-util-inspect.js index b06f6814e4985a..cc38199127dfd5 100644 --- a/test/parallel/test-util-inspect.js +++ b/test/parallel/test-util-inspect.js @@ -2920,6 +2920,175 @@ assert.strictEqual( process.cwd = originalCWD; } +{ + // Use a fake stack to verify the expected colored outcome. + const err = new Error('Hide duplicate frames in long stack'); + err.stack = [ + 'Error: Hide duplicate frames in long stack', + ' at A. (/foo/node_modules/bar/baz.js:2:7)', + ' at A. (/foo/node_modules/bar/baz.js:2:7)', + ' at Module._compile (node:internal/modules/cjs/loader:827:30)', + ' at Fancy (node:vm:697:32)', + ' at tryModuleLoad (node:internal/modules/cjs/foo:629:12)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Fancy (node:vm:697:32)', + ' at tryModuleLoad (node:internal/modules/cjs/foo:629:12)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)', + ' at require (node:internal/modules/helpers:14:16)', + ' at Array.forEach ()', + ' at require (node:internal/modules/helpers:14:16)', + ' at Array.forEach ()', + ` at foobar/test/parallel/test-util-inspect.js:2760:12`, + ` at Object. (foobar/node_modules/m/folder/file.js:2753:10)`, + ' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)', + ' at require (node:internal/modules/helpers:14:16)', + ' at Array.forEach ()', + ` at foobar/test/parallel/test-util-inspect.js:2760:12`, + ` at Object. (foobar/node_modules/m/folder/file.js:2753:10)`, + ' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)', + ' at require (node:internal/modules/helpers:14:16)', + ' at Array.forEach ()', + ` at foobar/test/parallel/test-util-inspect.js:2760:12`, + ` at Object. (foobar/node_modules/m/folder/file.js:2753:10)`, + ' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)', + ' at require (node:internal/modules/helpers:14:16)', + ' at Array.forEach ()', + ` at foobar/test/parallel/test-util-inspect.js:2760:12`, + ` at Object. (foobar/node_modules/m/folder/file.js:2753:10)`, + ' at /test/test-util-inspect.js:2239:9', + ' at getActual (node:assert:592:5)', + ' at /test/test-util-inspect.js:2239:9', + ' at getActual (node:assert:592:5)', + ' at /test/test-util-inspect.js:2239:9', + ' at getActual (node:assert:592:5)', + ].join('\n'); + + assert.strictEqual( + util.inspect(err, { colors: true }), + 'Error: Hide duplicate frames in long stack\n' + + ' at A. (/foo/node_modules/\x1B[4mbar\x1B[24m/baz.js:2:7)\n' + + ' at A. (/foo/node_modules/\x1B[4mbar\x1B[24m/baz.js:2:7)\n' + + '\x1B[90m at Module._compile (node:internal/modules/cjs/loader:827:30)\x1B[39m\n' + + '\x1B[90m at Fancy (node:vm:697:32)\x1B[39m\n' + + ' at tryModuleLoad (node:internal/modules/cjs/foo:629:12)\n' + + '\x1B[90m at Function.Module._load (node:internal/modules/cjs/loader:621:3)\x1B[39m\n' + + '\x1B[90m ... collapsed 3 duplicate lines matching above lines ...\x1B[39m\n' + + + '\x1B[90m at Function.Module._load (node:internal/modules/cjs/loader:621:3)\x1B[39m\n' + + '\x1B[90m ... collapsed 5 duplicate lines matching above 1 lines 5 times...\x1B[39m\n' + + + ' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)\n' + + '\x1B[90m at require (node:internal/modules/helpers:14:16)\x1B[39m\n' + + ' at Array.forEach ()\n' + + '\x1B[90m at require (node:internal/modules/helpers:14:16)\x1B[39m\n' + + ' at Array.forEach ()\n' + + ' at foobar/test/parallel/test-util-inspect.js:2760:12\n' + + ' at Object. (foobar/node_modules/\x1B[4mm\x1B[24m/folder/file.js:2753:10)\n' + + ' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)\n' + + '\x1B[90m ... collapsed 10 duplicate lines matching above 5 lines 2 times...\x1B[39m\n' + + + '\x1B[90m at require (node:internal/modules/helpers:14:16)\x1B[39m\n' + + ' at Array.forEach ()\n' + + ' at foobar/test/parallel/test-util-inspect.js:2760:12\n' + + ' at Object. (foobar/node_modules/\x1B[4mm\x1B[24m/folder/file.js:2753:10)\n' + + ' at /test/test-util-inspect.js:2239:9\n' + + '\x1B[90m at getActual (node:assert:592:5)\x1B[39m\n' + + '\x1B[90m ... collapsed 4 duplicate lines matching above 2 lines 2 times...\x1B[39m', + ); + + // Use a fake stack to verify the expected colored outcome. + const err2 = new Error('Hide duplicate frames in long stack'); + err2.stack = [ + 'Error: Hide duplicate frames in long stack', + ' at A. (/foo/node_modules/bar/baz.js:2:7)', + ' at A. (/foo/node_modules/bar/baz.js:2:7)', + ' at Module._compile (node:internal/modules/cjs/loader:827:30)', + + // 3 + ' at Fancy (node:vm:697:32)', + ' at tryModuleLoad (node:internal/modules/cjs/foo:629:12)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Fancy (node:vm:697:32)', + ' at tryModuleLoad (node:internal/modules/cjs/foo:629:12)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + + // 6 * 1 + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + ' at Function.Module._load (node:internal/modules/cjs/loader:621:3)', + + // 10 + ' at require (node:internal/modules/helpers:14:16)', + ' at Array.forEach ()', + ` at foobar/test/parallel/test-util-inspect.js:2760:12`, + ` at Object. (foobar/node_modules/m/folder/file.js:2753:10)`, + ' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)', + ' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)', + ' at require (node:internal/modules/helpers:14:16)', + ' at Array.forEach ()', + ` at foobar/test/parallel/test-util-inspect.js:2760:12`, + ` at Object. (foobar/node_modules/m/folder/file.js:2753:10)`, + + ' at require (node:internal/modules/helpers:14:16)', + ' at Array.forEach ()', + ` at foobar/test/parallel/test-util-inspect.js:2760:12`, + ` at Object. (foobar/node_modules/m/folder/file.js:2753:10)`, + ' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)', + ' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)', + ' at require (node:internal/modules/helpers:14:16)', + ' at Array.forEach ()', + ` at foobar/test/parallel/test-util-inspect.js:2760:12`, + ` at Object. (foobar/node_modules/m/folder/file.js:2753:10)`, + + // 2 * 2 + ' at /test/test-util-inspect.js:2239:9', + ' at getActual (node:assert:592:5)', + ' at /test/test-util-inspect.js:2239:9', + ' at getActual (node:assert:592:5)', + ' at /test/test-util-inspect.js:2239:9', + ' at getActual (node:assert:592:5)', + ].join('\n'); + + assert.strictEqual( + util.inspect(err2, { colors: true }), + 'Error: Hide duplicate frames in long stack\n' + + ' at A. (/foo/node_modules/\x1B[4mbar\x1B[24m/baz.js:2:7)\n' + + ' at A. (/foo/node_modules/\x1B[4mbar\x1B[24m/baz.js:2:7)\n' + + '\x1B[90m at Module._compile (node:internal/modules/cjs/loader:827:30)\x1B[39m\n' + + '\x1B[90m at Fancy (node:vm:697:32)\x1B[39m\n' + + ' at tryModuleLoad (node:internal/modules/cjs/foo:629:12)\n' + + '\x1B[90m at Function.Module._load (node:internal/modules/cjs/loader:621:3)\x1B[39m\n' + + '\x1B[90m ... collapsed 3 duplicate lines matching above lines ...\x1B[39m\n' + + '\x1B[90m at Function.Module._load (node:internal/modules/cjs/loader:621:3)\x1B[39m\n' + + '\x1B[90m ... collapsed 6 duplicate lines matching above 1 lines 6 times...\x1B[39m\n' + + '\x1B[90m at require (node:internal/modules/helpers:14:16)\x1B[39m\n' + + ' at Array.forEach ()\n' + + ' at foobar/test/parallel/test-util-inspect.js:2760:12\n' + + ' at Object. (foobar/node_modules/\x1B[4mm\x1B[24m/folder/file.js:2753:10)\n' + + ' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)\n' + + ' at Module.require [as weird/name] (node:internal/aaaaa/loader:735:19)\n' + + '\x1B[90m at require (node:internal/modules/helpers:14:16)\x1B[39m\n' + + ' at Array.forEach ()\n' + + ' at foobar/test/parallel/test-util-inspect.js:2760:12\n' + + ' at Object. (foobar/node_modules/\x1B[4mm\x1B[24m/folder/file.js:2753:10)\n' + + '\x1B[90m ... collapsed 10 duplicate lines matching above lines ...\x1B[39m\n' + + ' at /test/test-util-inspect.js:2239:9\n' + + '\x1B[90m at getActual (node:assert:592:5)\x1B[39m\n' + + '\x1B[90m ... collapsed 4 duplicate lines matching above 2 lines 2 times...\x1B[39m', + ); +} + { // Cross platform checks. const err = new Error('foo');