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
13 changes: 13 additions & 0 deletions assert/equals_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,16 @@ Deno.test({
assertEquals(new Set(data), new Set(data));
},
});

Deno.test({
name: "assertEquals() throws AssertionError for large unequal arrays",
fn() {
const n = 2 ** 16;
const actual = Array.from({ length: n }, (_, i) => i);
const expected = Array.from({ length: n }, () => -1);
assertThrows(
() => assertEquals(actual, expected),
AssertionError,
);
},
});
8 changes: 6 additions & 2 deletions internal/build_message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ export function buildMessage(

return color(`${createSign(result.type)}${line}`);
});
messages.push(...(stringDiff ? [diffMessages.join("")] : diffMessages), "");
return messages;
// Do not `push(...diffMessages)`: spreading tens of thousands of lines
// overflows V8's argument limit with RangeError (#5942).
if (stringDiff) {
return messages.concat(diffMessages.join(""), "");
}
return messages.concat(diffMessages, "");
}
13 changes: 13 additions & 0 deletions internal/build_message_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ Deno.test("buildMessage()", async (t) => {
],
);
});

await t.step("large diff does not throw RangeError", () => {
const n = 2 ** 16;
const diffResult: DiffResult<string>[] = Array.from(
{ length: n },
(_, i) => ({ type: "removed", value: `l${i}` }),
);
const message = buildMessage(diffResult);
assertEquals(message.length, prelude.length + n + 1);
assertEquals(message[prelude.length], red(bold("- l0")));
assertEquals(message[message.length - 2], red(bold(`- l${n - 1}`)));
assertEquals(message[message.length - 1], "");
});
});

Deno.test("createColor()", () => {
Expand Down
61 changes: 60 additions & 1 deletion internal/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,54 @@ export function createFp(
throw new Error("Unexpected missing FarthestPoint");
}

/**
* Myers stores an `(M * N)`-sized `Uint32Array`. Cap the length so comparing
* large arrays cannot throw `RangeError` or OOM the isolate (#5942).
* 2^26 elements is 256 MiB; typical assertion diffs stay well under this.
*/
const MAX_ROUTES_LENGTH = 2 ** 26;

function myersRoutesLength(M: number, N: number): number {
const knuth = M + N + 1;
if (M > 0 && N > (Number.MAX_SAFE_INTEGER - knuth - 1) / M) {
return Infinity;
}
const size = (M * N + knuth + 1) * 2;
return Number.isSafeInteger(size) ? size : Infinity;
}

/**
* Linear fallback used when the Myers table would not fit in memory.
* Common prefix is already stripped; a common suffix is recovered so a single
* contiguous change still renders as one hunk.
*/
function fallbackDiff<T>(
A: T[],
B: T[],
prefixCommon: T[],
swapped: boolean,
): DiffResult<T>[] {
const origA = swapped ? B : A;
const origB = swapped ? A : B;
let suffixLen = 0;
const maxSuffix = Math.min(origA.length, origB.length);
while (
suffixLen < maxSuffix &&
origA[origA.length - 1 - suffixLen] === origB[origB.length - 1 - suffixLen]
) {
suffixLen++;
}
const aMid = origA.slice(0, origA.length - suffixLen);
const bMid = origB.slice(0, origB.length - suffixLen);
const suffix = origA.slice(origA.length - suffixLen);
return [
...prefixCommon.map((value) => ({ type: "common" as const, value })),
...aMid.map((value) => ({ type: "removed" as const, value })),
...bMid.map((value) => ({ type: "added" as const, value })),
...suffix.map((value) => ({ type: "common" as const, value })),
];
}

/**
* Renders the differences between the actual and expected values.
*
Expand Down Expand Up @@ -258,6 +306,9 @@ export function diff<T>(A: T[], B: T[]): DiffResult<T>[] {
...A.map((value) => ({ type: swapped ? "added" : "removed", value })),
] as DiffResult<T>[];
}
if (myersRoutesLength(M, N) > MAX_ROUTES_LENGTH) {
return fallbackDiff(A, B, prefixCommon, swapped);
}
const offset = N;
const delta = M - N;
const length = M + N + 1;
Expand All @@ -267,7 +318,15 @@ export function diff<T>(A: T[], B: T[]): DiffResult<T>[] {
* Note: this buffer is used to save memory and improve performance. The first
* half is used to save route and the last half is used to save diff type.
*/
const routes = new Uint32Array((M * N + length + 1) * 2);
let routes: Uint32Array;
try {
routes = new Uint32Array((M * N + length + 1) * 2);
} catch (error) {
if (error instanceof RangeError) {
return fallbackDiff(A, B, prefixCommon, swapped);
}
throw error;
}
const diffTypesPtrOffset = routes.length / 2;
let ptr = 0;

Expand Down
33 changes: 33 additions & 0 deletions internal/diff_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,39 @@ Deno.test({
},
});

Deno.test({
name: "diff() does not throw RangeError for large disjoint arrays",
fn() {
const n = 2 ** 18;
const a = Array.from({ length: n }, (_, i) => `a${i}`);
const b = Array.from({ length: n }, (_, i) => `b${i}`);
const result = diff(a, b);
assertEquals(result.length, n * 2);
assertEquals(result[0], { type: "removed", value: "a0" });
assertEquals(result[n - 1], { type: "removed", value: `a${n - 1}` });
assertEquals(result[n], { type: "added", value: "b0" });
assertEquals(result[n * 2 - 1], { type: "added", value: `b${n - 1}` });
},
});

Deno.test({
name:
"diff() keeps a single inner change as one hunk when the Myers table would overflow",
fn() {
const side = 10_000;
const prefix = Array.from({ length: side }, (_, i) => `p${i}`);
const suffix = Array.from({ length: side }, (_, i) => `s${i}`);
const a = [...prefix, "old", ...suffix];
const b = [...prefix, "new", ...suffix];
assertEquals(diff(a, b), [
...prefix.map((value) => ({ type: "common" as const, value })),
{ type: "removed", value: "old" },
{ type: "added", value: "new" },
...suffix.map((value) => ({ type: "common" as const, value })),
]);
},
});

Deno.test({
name: "assertFp()",
fn() {
Expand Down
13 changes: 9 additions & 4 deletions path/is_glob_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { assert, assertEquals } from "@std/assert";
import { isGlob } from "./is_glob.ts";
import { disposableStack } from "../internal/_testing.ts";
import { Worker } from "node:worker_threads";
import process from "node:process";

Deno.test({
name: "isGlob()",
Expand Down Expand Up @@ -115,9 +116,13 @@ Deno.test({
});

// ref https://github.com/denoland/std/pull/6764
Deno.test(
"isGlob works with the input that includes large number of open brackets",
async () => {
// bun 1.4+ throws in `new Worker()` (node:worker_threads EventEmitter).
const isBun = "bun" in process.versions;
Deno.test({
name:
"isGlob works with the input that includes large number of open brackets",
ignore: isBun,
async fn() {
const { promise, resolve, reject } = Promise.withResolvers<void>();
const timer = setTimeout(() => {
reject(new Error("isGlob() did not finish in time"));
Expand Down Expand Up @@ -149,4 +154,4 @@ Deno.test(

await promise;
},
);
});
Loading