diff --git a/lib/internal/assert/assertion_error.js b/lib/internal/assert/assertion_error.js index 5dbf1e7a341380..aa29aceec35ef1 100644 --- a/lib/internal/assert/assertion_error.js +++ b/lib/internal/assert/assertion_error.js @@ -12,12 +12,14 @@ const { ObjectPrototypeHasOwnProperty, SafeSet, String, + StringPrototypeEndsWith, StringPrototypeRepeat, StringPrototypeSlice, StringPrototypeSplit, } = primordials; const { isError } = require('internal/util'); +const { totalmem } = require('os'); const { inspect } = require('internal/util/inspect'); const colors = require('internal/util/colors'); @@ -42,6 +44,16 @@ const kReadableOperator = { const kMaxShortStringLength = 12; const kMaxLongStringLength = 512; +// Truncation limit for inspect output to prevent OOM during diff generation. +// Scaled to system memory: 512KB under 1GB, 1MB under 2GB, 2MB otherwise. +const kGB = 1024 ** 3; +const kMB = 1024 ** 2; +const totalMem = totalmem(); +const kMaxInspectOutputLength = + totalMem < kGB ? kMB / 2 : + totalMem < 2 * kGB ? kMB : + 2 * kMB; +const kTruncatedByteMarker = '\n... [truncated]'; const kMethodsWithCustomMessageDiff = new SafeSet() .add('deepStrictEqual') @@ -72,7 +84,7 @@ function copyError(source) { function inspectValue(val) { // The util.inspect default values could be changed. This makes sure the // error messages contain the necessary information nevertheless. - return inspect(val, { + const result = inspect(val, { compact: false, customInspect: false, depth: 1000, @@ -85,6 +97,17 @@ function inspectValue(val) { // Inspect getters as we also check them when comparing entries. getters: true, }); + + // Truncate if the output is too large to prevent OOM during diff generation. + // Objects with deeply nested structures can produce exponentially large + // inspect output that causes memory exhaustion when passed to the diff + // algorithm. + if (result.length > kMaxInspectOutputLength) { + return StringPrototypeSlice(result, 0, kMaxInspectOutputLength) + + kTruncatedByteMarker; + } + + return result; } function getErrorMessage(operator, message) { @@ -189,6 +212,12 @@ function createErrDiff(actual, expected, operator, customMessage, diffType = 'si let message = ''; const inspectedActual = inspectValue(actual); const inspectedExpected = inspectValue(expected); + + // Check if either value was truncated due to size limits + if (StringPrototypeEndsWith(inspectedActual, kTruncatedByteMarker) || + StringPrototypeEndsWith(inspectedExpected, kTruncatedByteMarker)) { + skipped = true; + } const inspectedSplitActual = StringPrototypeSplit(inspectedActual, '\n'); const inspectedSplitExpected = StringPrototypeSplit(inspectedExpected, '\n'); const showSimpleDiff = isSimpleDiff(actual, inspectedSplitActual, expected, inspectedSplitExpected); diff --git a/lib/internal/assert/myers_diff.js b/lib/internal/assert/myers_diff.js index ee6359042e31b8..9117a7d3e37c76 100644 --- a/lib/internal/assert/myers_diff.js +++ b/lib/internal/assert/myers_diff.js @@ -2,7 +2,13 @@ const { ArrayPrototypePush, + ArrayPrototypeSlice, Int32Array, + MathFloor, + MathMax, + MathMin, + MathRound, + RegExpPrototypeExec, StringPrototypeEndsWith, } = primordials; @@ -14,7 +20,11 @@ const { const colors = require('internal/util/colors'); +const kChunkSize = 512; const kNopLinesToCollapse = 5; +// Lines that are just structural characters make poor alignment anchors +// because they appear many times and don't uniquely identify a position. +const kTrivialLinePattern = /^\s*[{}[\],]+\s*$/; const kOperations = { DELETE: -1, NOP: 0, @@ -31,19 +41,11 @@ function areLinesEqual(actual, expected, checkCommaDisparity) { return false; } -function myersDiff(actual, expected, checkCommaDisparity = false) { +function myersDiffInternal(actual, expected, checkCommaDisparity) { const actualLength = actual.length; const expectedLength = expected.length; const max = actualLength + expectedLength; - if (max > 2 ** 31 - 1) { - throw new ERR_OUT_OF_RANGE( - 'myersDiff input size', - '< 2^31', - max, - ); - } - const v = new Int32Array(2 * max + 1); const trace = []; @@ -124,6 +126,170 @@ function backtrack(trace, actual, expected, checkCommaDisparity) { return result; } +function myersDiff(actual, expected, checkCommaDisparity = false) { + const actualLength = actual.length; + const expectedLength = expected.length; + const max = actualLength + expectedLength; + + if (max > 2 ** 31 - 1) { + throw new ERR_OUT_OF_RANGE( + 'myersDiff input size', + '< 2^31', + max, + ); + } + + // For small inputs, run the algorithm directly + if (actualLength <= kChunkSize && expectedLength <= kChunkSize) { + return myersDiffInternal(actual, expected, checkCommaDisparity); + } + + const boundaries = findAlignedBoundaries( + actual, expected, checkCommaDisparity, + ); + + // Process chunks and concatenate results (last chunk first for reversed order) + const result = []; + for (let i = boundaries.length - 2; i >= 0; i--) { + const actualStart = boundaries[i].actualIdx; + const actualEnd = boundaries[i + 1].actualIdx; + const expectedStart = boundaries[i].expectedIdx; + const expectedEnd = boundaries[i + 1].expectedIdx; + + const actualChunk = ArrayPrototypeSlice(actual, actualStart, actualEnd); + const expectedChunk = ArrayPrototypeSlice(expected, expectedStart, expectedEnd); + + if (actualChunk.length === 0 && expectedChunk.length === 0) continue; + + if (actualChunk.length === 0) { + for (let j = expectedChunk.length - 1; j >= 0; j--) { + ArrayPrototypePush(result, [kOperations.DELETE, expectedChunk[j]]); + } + continue; + } + + if (expectedChunk.length === 0) { + for (let j = actualChunk.length - 1; j >= 0; j--) { + ArrayPrototypePush(result, [kOperations.INSERT, actualChunk[j]]); + } + continue; + } + + const chunkDiff = myersDiffInternal(actualChunk, expectedChunk, checkCommaDisparity); + for (let j = 0; j < chunkDiff.length; j++) { + ArrayPrototypePush(result, chunkDiff[j]); + } + } + + return result; +} + +function findAlignedBoundaries(actual, expected, checkCommaDisparity) { + const actualLen = actual.length; + const expectedLen = expected.length; + const boundaries = [{ actualIdx: 0, expectedIdx: 0 }]; + const searchRadius = kChunkSize / 2; + + const numTargets = MathMax( + MathFloor((actualLen - 1) / kChunkSize), + 1, + ); + + for (let i = 1; i <= numTargets; i++) { + const targetActual = MathMin(i * kChunkSize, actualLen); + if (targetActual >= actualLen) { + break; + } + + const targetExpected = MathMin( + MathRound(targetActual * expectedLen / actualLen), + expectedLen - 1, + ); + const prevBoundary = boundaries[boundaries.length - 1]; + + const anchor = findAnchorNear( + actual, expected, targetActual, targetExpected, + prevBoundary, searchRadius, checkCommaDisparity, + ); + + if (anchor !== undefined) { + ArrayPrototypePush(boundaries, anchor); + } else { + // Fallback: use proportional position, ensuring strictly increasing + const fallbackActual = MathMax(targetActual, prevBoundary.actualIdx + 1); + const fallbackExpected = MathMax(targetExpected, prevBoundary.expectedIdx + 1); + if (fallbackActual < actualLen && fallbackExpected < expectedLen) { + ArrayPrototypePush(boundaries, { actualIdx: fallbackActual, expectedIdx: fallbackExpected }); + } + } + } + + ArrayPrototypePush(boundaries, { actualIdx: actualLen, expectedIdx: expectedLen }); + return boundaries; +} + +// Search outward from targetActual and targetExpected for a non-trivial +// line that matches in both arrays, with adjacent context verification. +function findAnchorNear(actual, expected, targetActual, targetExpected, + prevBoundary, searchRadius, checkCommaDisparity) { + const actualLen = actual.length; + const expectedLen = expected.length; + + for (let offset = 0; offset <= searchRadius; offset++) { + const candidates = offset === 0 ? [targetActual] : [targetActual + offset, targetActual - offset]; + + for (let i = 0; i < candidates.length; i++) { + const actualIdx = candidates[i]; + if (actualIdx <= prevBoundary.actualIdx || actualIdx >= actualLen) { + continue; + } + + const line = actual[actualIdx]; + if (isTrivialLine(line)) { + continue; + } + + const searchStart = MathMax(prevBoundary.expectedIdx + 1, targetExpected - searchRadius); + const searchEnd = MathMin(expectedLen - 1, targetExpected + searchRadius); + + for (let j = 0; j <= searchRadius; j++) { + const offsets = j === 0 ? [0] : [j, -j]; + for (let k = 0; k < offsets.length; k++) { + const expectedIdx = targetExpected + offsets[k]; + if (expectedIdx < searchStart || expectedIdx > searchEnd || expectedIdx <= prevBoundary.expectedIdx) { + continue; + } + + if ( + areLinesEqual(line, expected[expectedIdx], checkCommaDisparity) && + hasAdjacentMatch(actual, expected, actualIdx, expectedIdx, checkCommaDisparity) + ) { + return { actualIdx, expectedIdx }; + } + } + } + } + } + + return undefined; +} + +function hasAdjacentMatch(actual, expected, actualIdx, expectedIdx, checkCommaDisparity) { + if (actualIdx > 0 && expectedIdx > 0 && + areLinesEqual(actual[actualIdx - 1], expected[expectedIdx - 1], checkCommaDisparity)) { + return true; + } + if (actualIdx < actual.length - 1 && expectedIdx < expected.length - 1 && + areLinesEqual(actual[actualIdx + 1], expected[expectedIdx + 1], checkCommaDisparity)) { + return true; + } + return false; +} + +function isTrivialLine(line) { + return RegExpPrototypeExec(kTrivialLinePattern, line) !== null; +} + function printSimpleMyersDiff(diff) { let message = ''; diff --git a/test/parallel/test-assert-large-object-diff-oom.js b/test/parallel/test-assert-large-object-diff-oom.js new file mode 100644 index 00000000000000..3095513303791c --- /dev/null +++ b/test/parallel/test-assert-large-object-diff-oom.js @@ -0,0 +1,99 @@ +// Flags: --max-old-space-size=512 +'use strict'; + +// Regression test: assert.strictEqual should not OOM when comparing objects +// with many converging paths to shared objects. Such objects cause exponential +// growth in util.inspect output, which previously led to OOM during error +// message generation. + +const common = require('../common'); +const os = require('os'); +const assert = require('assert'); + +// This test creates objects with exponential inspect output that requires +// significant memory. Skip on systems with less than 1GB total memory. +const totalMemMB = os.totalmem() / 1024 / 1024; +if (totalMemMB < 1024) { + common.skip(`insufficient system memory (${Math.round(totalMemMB)}MB, need 1024MB)`); +} + +// Test: should throw AssertionError, not OOM +{ + const { doc1, doc2 } = createTestObjects(); + + assert.throws( + () => assert.strictEqual(doc1, doc2), + (err) => { + assert.ok(err instanceof assert.AssertionError); + // Message should be bounded (fix truncates inspect output at 2MB) + assert.ok(err.message.length < 5 * 1024 * 1024); + return true; + } + ); +} + +// Creates objects where many paths converge on shared objects, causing +// exponential growth in util.inspect output at high depths. +function createTestObjects() { + const base = createBase(); + + const s1 = createSchema(base, 's1'); + const s2 = createSchema(base, 's2'); + base.schemas.s1 = s1; + base.schemas.s2 = s2; + + const doc1 = createDoc(s1, base); + const doc2 = createDoc(s2, base); + + // Populated refs create additional converging paths + for (let i = 0; i < 2; i++) { + const ps = createSchema(base, 'p' + i); + base.schemas['p' + i] = ps; + doc1.$__.pop['r' + i] = { value: createDoc(ps, base), opts: { base, schema: ps } }; + } + + // Cross-link creates more converging paths + doc1.$__.pop.r0.value.$__parent = doc2; + + return { doc1, doc2 }; +} + +function createBase() { + const base = { types: {}, schemas: {} }; + for (let i = 0; i < 4; i++) { + base.types['t' + i] = { + base, + caster: { base }, + opts: { base, validators: [{ base }, { base }] } + }; + } + return base; +} + +function createSchema(base, name) { + const schema = { name, base, paths: {}, children: [] }; + for (let i = 0; i < 6; i++) { + schema.paths['f' + i] = { + schema, base, + type: base.types['t' + (i % 4)], + caster: base.types['t' + (i % 4)].caster, + opts: { schema, base, validators: [{ schema, base }] } + }; + } + for (let i = 0; i < 2; i++) { + const child = { name: name + '_c' + i, base, parent: schema, paths: {} }; + for (let j = 0; j < 3; j++) { + child.paths['cf' + j] = { schema: child, base, type: base.types['t' + (j % 4)] }; + } + schema.children.push(child); + } + return schema; +} + +function createDoc(schema, base) { + const doc = { schema, base, $__: { scopes: {}, pop: {} } }; + for (let i = 0; i < 6; i++) { + doc.$__.scopes['p' + i] = { schema, base, type: base.types['t' + (i % 4)] }; + } + return doc; +} diff --git a/test/parallel/test-assert-myers-diff.js b/test/parallel/test-assert-myers-diff.js index 31db3cd704ae06..25d8a72c8c634a 100644 --- a/test/parallel/test-assert-myers-diff.js +++ b/test/parallel/test-assert-myers-diff.js @@ -6,6 +6,7 @@ const assert = require('assert'); const { myersDiff } = require('internal/assert/myers_diff'); +// Test: myersDiff input size limit { const arr1 = { length: 2 ** 31 - 1 }; const arr2 = { length: 2 }; @@ -23,3 +24,184 @@ const { myersDiff } = require('internal/assert/myers_diff'); }) ); } + +// Test: small input correctness +{ + const actual = ['a', 'b', 'X', 'c', 'd']; + const expected = ['a', 'b', 'c', 'd']; + const ops = diffToForwardOps(myersDiff(actual, expected)); + + const inserts = ops.filter((o) => o.op === 1); + const deletes = ops.filter((o) => o.op === -1); + const nops = ops.filter((o) => o.op === 0); + + assert.strictEqual(inserts.length, 1); + assert.strictEqual(inserts[0].value, 'X'); + assert.strictEqual(deletes.length, 0); + assert.strictEqual(nops.length, 4); +} + +// Test: aligned boundary correctness - extra lines in the middle +// When expected has extra lines, aligned boundaries should produce +// only real INSERT/DELETE/NOP operations with no phantom diffs. +{ + const { actual, expected } = createAlignedTestArrays({ lineCount: 600, extraLineAt: 100 }); + + const result = myersDiff(actual, expected); + const ops = diffToForwardOps(result); + + const inserts = ops.filter((o) => o.op === 1); + const deletes = ops.filter((o) => o.op === -1); + const nops = ops.filter((o) => o.op === 0); + + assert.strictEqual(inserts.length, 1); + assert.strictEqual(inserts[0].value, 'EXTRA_100'); + assert.strictEqual(deletes.length, 0, 'should produce no phantom DELETEs'); + assert.strictEqual(nops.length, 600); +} + +// Test: multiple extra lines across chunk boundaries +{ + const expected = []; + for (let i = 0; i < 1200; i++) expected.push('line_' + i); + + const actual = []; + for (let i = 0; i < 1200; i++) { + if (i === 100) { + actual.push('EXTRA_A'); + actual.push('EXTRA_B'); + } + actual.push('line_' + i); + } + + const result = myersDiff(actual, expected); + const ops = diffToForwardOps(result); + + const inserts = ops.filter((o) => o.op === 1); + const deletes = ops.filter((o) => o.op === -1); + + assert.strictEqual(inserts.length, 2); + assert.strictEqual(inserts[0].value, 'EXTRA_A'); + assert.strictEqual(inserts[1].value, 'EXTRA_B'); + assert.strictEqual(deletes.length, 0, 'should produce no phantom DELETEs'); +} + +// Test: large identical inputs produce all NOPs +{ + const lines = []; + for (let i = 0; i < 600; i++) lines.push('line_' + i); + + const result = myersDiff(lines, lines); + const ops = diffToForwardOps(result); + + assert.strictEqual(ops.length, 600); + assert.ok(ops.every((o) => o.op === 0)); +} + +// Test: one side much longer than the other +// Diff should be correct (rebuild both sides from ops matches originals) +{ + const actual = []; + for (let i = 0; i < 700; i++) actual.push('line_' + i); + + const expected = []; + for (let i = 0; i < 200; i++) expected.push('line_' + i); + + const result = myersDiff(actual, expected); + const ops = diffToForwardOps(result); + + // Verify correctness: rebuild both sides from diff ops + const { rebuiltActual, rebuiltExpected } = rebuildFromOps(ops); + assert.deepStrictEqual(rebuiltActual, actual); + assert.deepStrictEqual(rebuiltExpected, expected); + + // Chunked diff with fallback boundaries may not find all 200 shared lines, + // but the diff must still be correct (rebuilds match originals). + const nops = ops.filter((o) => o.op === 0); + const inserts = ops.filter((o) => o.op === 1); + const deletes = ops.filter((o) => o.op === -1); + assert.ok(nops.length >= 146, `expected at least 146 NOPs, got ${nops.length}`); + assert.strictEqual(nops.length + inserts.length, actual.length); + assert.strictEqual(nops.length + deletes.length, expected.length); +} + +// Test: expected side longer (deletions) +{ + const actual = []; + for (let i = 0; i < 200; i++) actual.push('line_' + i); + + const expected = []; + for (let i = 0; i < 700; i++) expected.push('line_' + i); + + const result = myersDiff(actual, expected); + const ops = diffToForwardOps(result); + + const { rebuiltActual, rebuiltExpected } = rebuildFromOps(ops); + assert.deepStrictEqual(rebuiltActual, actual); + assert.deepStrictEqual(rebuiltExpected, expected); + + const nops = ops.filter((o) => o.op === 0); + const inserts = ops.filter((o) => o.op === 1); + const deletes = ops.filter((o) => o.op === -1); + assert.ok(nops.length >= 146, `expected at least 146 NOPs, got ${nops.length}`); + assert.strictEqual(nops.length + inserts.length, actual.length); + assert.strictEqual(nops.length + deletes.length, expected.length); +} + +// Test: no matching anchor available (all unique lines) +// Should gracefully fall back and still produce a valid diff +{ + const actual = []; + const expected = []; + for (let i = 0; i < 600; i++) { + actual.push('actual_unique_' + i); + expected.push('expected_unique_' + i); + } + + const result = myersDiff(actual, expected); + const ops = diffToForwardOps(result); + + const inserts = ops.filter((o) => o.op === 1); + const deletes = ops.filter((o) => o.op === -1); + + // All lines are different, so we should get 600 inserts and 600 deletes + assert.strictEqual(inserts.length, 600); + assert.strictEqual(deletes.length, 600); +} + +function diffToForwardOps(diff) { + const ops = []; + for (let i = diff.length - 1; i >= 0; i--) { + ops.push({ op: diff[i][0], value: diff[i][1] }); + } + return ops; +} + +function rebuildFromOps(ops) { + const rebuiltActual = []; + const rebuiltExpected = []; + for (let i = 0; i < ops.length; i++) { + if (ops[i].op === 0) { + rebuiltActual.push(ops[i].value); + rebuiltExpected.push(ops[i].value); + } else if (ops[i].op === 1) { + rebuiltActual.push(ops[i].value); + } else { + rebuiltExpected.push(ops[i].value); + } + } + return { rebuiltActual, rebuiltExpected }; +} + +function createAlignedTestArrays({ lineCount, extraLineAt } = {}) { + const expected = []; + for (let i = 0; i < lineCount; i++) expected.push('line_' + i); + + const actual = []; + for (let i = 0; i < lineCount; i++) { + if (i === extraLineAt) actual.push('EXTRA_' + extraLineAt); + actual.push('line_' + i); + } + + return { actual, expected }; +}