Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion lib/internal/assert/assertion_error.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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')
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
184 changes: 175 additions & 9 deletions lib/internal/assert/myers_diff.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

const {
ArrayPrototypePush,
ArrayPrototypeSlice,
Int32Array,
MathFloor,
MathMax,
MathMin,
MathRound,
RegExpPrototypeExec,
StringPrototypeEndsWith,
} = primordials;

Expand All @@ -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,
Expand All @@ -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 = [];

Expand Down Expand Up @@ -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 = '';

Expand Down
99 changes: 99 additions & 0 deletions test/parallel/test-assert-large-object-diff-oom.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading