fix(core): bound ASCII folding allocations for large HTML (#5098)

* fix(core): bound ASCII folding allocations for large HTML

* chore(core): consolidate ASCII folding rationale

* fix(core): preserve unchanged spans during ASCII folding
This commit is contained in:
Miguel Ángel
2026-10-06 04:10:52 +00:00
committed by GitHub
parent 1db44c6595
commit 00342fc2be
2 changed files with 75 additions and 2 deletions
@@ -1,3 +1,6 @@
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import {
findStartTags,
@@ -60,6 +63,65 @@ const workGrowth = (scan: (n: number) => void) =>
scannedChars(() => scan(20_000)) / scannedChars(() => scan(10_000));
describe("htmlDocument helpers", () => {
it("strips runtimes from large mixed-case base64 HTML within a bounded heap", () => {
const moduleUrl = pathToFileURL(resolve(__dirname, "htmlDocument.ts")).href;
const result = spawnSync(
process.execPath,
[
"--max-old-space-size=256",
"--import=tsx",
"--input-type=module",
"--eval",
`
import { strict as assert } from "node:assert";
import { stripEmbeddedRuntimeScripts } from ${JSON.stringify(moduleUrl)};
const media = '<img src="data:image/png;base64,' + 'Aa0/'.repeat(8 * 1024 * 1024) + '">';
const runtime = '<SCRIPT src="HYPERFRAME.RUNTIME.IIFE.JS"></SCRIPT>';
assert.equal(stripEmbeddedRuntimeScripts(media + runtime), media);
console.log("large HTML preserved; runtime removed");
`,
],
{ encoding: "utf8", timeout: 60_000 },
);
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("large HTML preserved; runtime removed");
}, 65_000);
it.each([
{ first: "a", oldSpace: 96 },
{ first: "A", oldSpace: 128 },
])(
"preserves a large authored script starting with $first within $oldSpace MiB old-space",
({ first, oldSpace }) => {
const moduleUrl = pathToFileURL(resolve(__dirname, "htmlDocument.ts")).href;
const result = spawnSync(
process.execPath,
[
`--max-old-space-size=${oldSpace}`,
"--max-semi-space-size=4",
"--import=tsx",
"--input-type=module",
"--eval",
`
import { strict as assert } from "node:assert";
import { stripEmbeddedRuntimeScripts } from ${JSON.stringify(moduleUrl)};
const data = ${JSON.stringify(first)} + "a".repeat(48 * 1024 * 1024 - 1);
const html = '<!doctype html><html><head></head><body><script>const embeddeddata="' + data + '";</script></body></html>';
assert.ok(stripEmbeddedRuntimeScripts(html) === html);
assert.equal(data.length, 48 * 1024 * 1024);
console.log("authored script preserved");
`,
],
{ encoding: "utf8", timeout: 60_000 },
);
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("authored script preserved");
},
65_000,
);
it("keeps a document's <html> attributes when a comment comes before the doctype", () => {
const doc = parseHTMLContent(
'<!-- hyperframes-registry-item: blk -->\n<!doctype html>\n<html lang="en" data-composition-variables="[]"><body></body></html>',
@@ -301,6 +363,15 @@ describe("htmlDocument helpers", () => {
});
describe("findStartTags", () => {
it("preserves Unicode offsets and mixed-case tags across chunk boundaries", () => {
const prefix = "Aİ" + "a".repeat(65_533) + "😀İ";
const html = prefix + "<ImG src=x>" + "A".repeat(65_524) + "<IMG src=y>";
expect(findStartTags(html, "iMg")).toEqual([65_538, 131_073]);
expect(
stripEmbeddedRuntimeScripts(prefix + '<SCRIPT src="HYPERFRAME.RUNTIME.IIFE.JS"></SCRIPT>'),
).toBe(prefix);
});
const at = (html: string, name: string) =>
findStartTags(html, name).map((i) => html.slice(i, html.indexOf(">", i) + 1));
+4 -2
View File
@@ -30,9 +30,11 @@ export function parseHTMLContent(html: string): Document {
return parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`).document;
}
/** Lowercases A-Z only, so indexes found in the result are valid in the input ("İ" lowercases to two chars). */
/** ASCII-only chunks bound match arrays; starting at uppercase preserves unchanged spans. */
function lowerAscii(text: string): string {
return text.replace(/[A-Z]+/g, (letters) => letters.toLowerCase());
return text.replace(/[A-Z][\s\S]{0,65535}/g, (chunk) =>
chunk.replace(/[A-Z]+/g, (letters) => letters.toLowerCase()),
);
}
export function stripEmbeddedRuntimeScripts(html: string): string {