diff --git a/assert/equals_test.ts b/assert/equals_test.ts index 8f23661d6b2f..b5166625aed6 100644 --- a/assert/equals_test.ts +++ b/assert/equals_test.ts @@ -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, + ); + }, +}); diff --git a/internal/build_message.ts b/internal/build_message.ts index 13391ca227c1..2dadf250dc89 100644 --- a/internal/build_message.ts +++ b/internal/build_message.ts @@ -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, ""); } diff --git a/internal/build_message_test.ts b/internal/build_message_test.ts index 4102883a29a5..5adf70faaa69 100644 --- a/internal/build_message_test.ts +++ b/internal/build_message_test.ts @@ -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[] = 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()", () => { diff --git a/internal/diff.ts b/internal/diff.ts index 104b908c4e80..d5e54d05fc65 100644 --- a/internal/diff.ts +++ b/internal/diff.ts @@ -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( + A: T[], + B: T[], + prefixCommon: T[], + swapped: boolean, +): DiffResult[] { + 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. * @@ -258,6 +306,9 @@ export function diff(A: T[], B: T[]): DiffResult[] { ...A.map((value) => ({ type: swapped ? "added" : "removed", value })), ] as DiffResult[]; } + 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; @@ -267,7 +318,15 @@ export function diff(A: T[], B: T[]): DiffResult[] { * 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; diff --git a/internal/diff_test.ts b/internal/diff_test.ts index d9baa45c40c5..db18d84cf9eb 100644 --- a/internal/diff_test.ts +++ b/internal/diff_test.ts @@ -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() { diff --git a/path/is_glob_test.ts b/path/is_glob_test.ts index 406bf9c5965c..e781215a077d 100644 --- a/path/is_glob_test.ts +++ b/path/is_glob_test.ts @@ -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()", @@ -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(); const timer = setTimeout(() => { reject(new Error("isGlob() did not finish in time")); @@ -149,4 +154,4 @@ Deno.test( await promise; }, -); +});