Repository: nodejs/node
Pull request: 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.

I believe the wording might still be improved, it would be great to get some input about that.

<img width="692" height="472" alt="image" src="https://github.com/user-attachments/assets/668085bf-3408-4932-8340-0c6a8b355efc" />



Changed files:
filename: lib/internal/util/inspect.js
patch: @@ -1567,13 +1567,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) {
@@ -1607,14 +1716,33 @@ 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 ...`;
         frames.splice(offset + 1, skipped, ctx.stylize(msg, 'undefined'));
       }
     }
   }
+
+  // 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;
 }
 

filename: test/parallel/test-util-inspect.js
patch: @@ -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.<anonymous> (/foo/node_modules/bar/baz.js:2:7)',
+    '    at A.<anonymous> (/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 (<anonymous>)',
+    '    at require (node:internal/modules/helpers:14:16)',
+    '    at Array.forEach (<anonymous>)',
+    `    at foobar/test/parallel/test-util-inspect.js:2760:12`,
+    `    at Object.<anonymous> (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 (<anonymous>)',
+    `    at foobar/test/parallel/test-util-inspect.js:2760:12`,
+    `    at Object.<anonymous> (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 (<anonymous>)',
+    `    at foobar/test/parallel/test-util-inspect.js:2760:12`,
+    `    at Object.<anonymous> (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 (<anonymous>)',
+    `    at foobar/test/parallel/test-util-inspect.js:2760:12`,
+    `    at Object.<anonymous> (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.<anonymous> (/foo/node_modules/\x1B[4mbar\x1B[24m/baz.js:2:7)\n' +
+      '    at A.<anonymous> (/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 (<anonymous>)\n' +
+      '\x1B[90m    at require (node:internal/modules/helpers:14:16)\x1B[39m\n' +
+      '    at Array.forEach (<anonymous>)\n' +
+      '    at foobar/test/parallel/test-util-inspect.js:2760:12\n' +
+      '    at Object.<anonymous> (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 (<anonymous>)\n' +
+      '    at foobar/test/parallel/test-util-inspect.js:2760:12\n' +
+      '    at Object.<anonymous> (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.<anonymous> (/foo/node_modules/bar/baz.js:2:7)',
+    '    at A.<anonymous> (/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 (<anonymous>)',
+    `    at foobar/test/parallel/test-util-inspect.js:2760:12`,
+    `    at Object.<anonymous> (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 (<anonymous>)',
+    `    at foobar/test/parallel/test-util-inspect.js:2760:12`,
+    `    at Object.<anonymous> (foobar/node_modules/m/folder/file.js:2753:10)`,
+
+    '    at require (node:internal/modules/helpers:14:16)',
+    '    at Array.forEach (<anonymous>)',
+    `    at foobar/test/parallel/test-util-inspect.js:2760:12`,
+    `    at Object.<anonymous> (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 (<anonymous>)',
+    `    at foobar/test/parallel/test-util-inspect.js:2760:12`,
+    `    at Object.<anonymous> (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.<anonymous> (/foo/node_modules/\x1B[4mbar\x1B[24m/baz.js:2:7)\n' +
+      '    at A.<anonymous> (/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 (<anonymous>)\n' +
+      '    at foobar/test/parallel/test-util-inspect.js:2760:12\n' +
+      '    at Object.<anonymous> (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 (<anonymous>)\n' +
+      '    at foobar/test/parallel/test-util-inspect.js:2760:12\n' +
+      '    at Object.<anonymous> (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');