From 927504af879f7f3ce8bbcc5fe83474450deef4e4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 08:52:43 -0500 Subject: [PATCH] Read cached LLVM in parts on warm builds A warm build that finds the program's LLVM in the cache read the file into one string before emitting the program object. A program whose LLVM is past the engine's string limit (three.js nodes, 816 MB) failed there with `RangeError: Invalid string length`, though its cold build, which keeps the LLVM in parts, succeeds. The cache hit now reads the file with readTextParts: parts of at most 64 Mi UTF-16 units, decoded across reads, none ending inside a surrogate pair, which the emitter takes as a cold build hands them over. --- packages/compiler/src/index.ts | 6 ++- packages/compiler/src/text-parts.test.ts | 49 ++++++++++++++++++++++++ packages/compiler/src/text-parts.ts | 46 ++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/src/text-parts.test.ts diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index e9b8514af..66658fd45 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -112,7 +112,7 @@ import { import { type IrModule, type SrcLoc } from "./ir/ir.js"; import { moduleRuntimeFeatures } from "./ir/runtime-features.js"; import { serializeModuleParts } from "./ir/serialize.js"; -import { writeTextParts } from "./text-parts.js"; +import { readTextParts, writeTextParts } from "./text-parts.js"; import { validateModule } from "./ir/validate.js"; import { loadProgram } from "./frontend/program-node.js"; import { runFrontend, type FrontendFactory } from "./frontend/pipeline.js"; @@ -1197,7 +1197,9 @@ async function compileTracked( nativeProgramObject = await emitNativeProgramObject( entryPath, opts, - await readFile(earlyHit.llvmPath, "utf8"), + // A large program's LLVM exceeds the engine's string limit as one + // string; the emitter takes it in parts, as on a cold build. + await readTextParts(earlyHit.llvmPath), earlyHit.native, profile, ); diff --git a/packages/compiler/src/text-parts.test.ts b/packages/compiler/src/text-parts.test.ts new file mode 100644 index 000000000..4a09e13d5 --- /dev/null +++ b/packages/compiler/src/text-parts.test.ts @@ -0,0 +1,49 @@ +import { constants } from "node:buffer"; +import { mkdtempSync, rmSync, truncateSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, expect, test } from "vitest"; +import { readTextParts, writeTextParts } from "./text-parts.js"; + +const scratch = mkdtempSync(join(tmpdir(), "scriptc-text-parts-")); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); + +test("parts join to the file's text, each within the part length", async () => { + // Multi-byte sequences and surrogate pairs straddle the 1 MiB reads and + // the 8-unit parts. + const unit = "abéc中\u{1f600}d"; + const text = unit.repeat(90_000); + const path = join(scratch, "mixed.ll"); + writeFileSync(path, text); + const parts = await readTextParts(path, 8); + expect(parts.join("")).toBe(text); + for (const part of parts) { + expect(part.length).toBeLessThanOrEqual(8); + const last = part.charCodeAt(part.length - 1); + expect(last >= 0xd800 && last <= 0xdbff).toBe(false); + } + // Each part encodes on its own, so writing them back reproduces the file. + const copy = join(scratch, "copy.ll"); + await writeTextParts(copy, parts); + expect(await readFile(copy, "utf8")).toBe(text); +}); + +test("an empty file has no parts", async () => { + const path = join(scratch, "empty.ll"); + writeFileSync(path, ""); + expect(await readTextParts(path)).toEqual([]); +}); + +test("a file past the engine's string limit reads in parts", async () => { + // A sparse file: its bytes are NUL characters, valid UTF-8, and cost no + // disk space. + const path = join(scratch, "huge.ll"); + const size = constants.MAX_STRING_LENGTH + 1; + writeFileSync(path, ""); + truncateSync(path, size); + await expect(readFile(path, "utf8")).rejects.toThrow(RangeError); + const parts = await readTextParts(path); + expect(parts.length).toBeGreaterThan(1); + expect(parts.reduce((length, part) => length + part.length, 0)).toBe(size); +}, 120_000); diff --git a/packages/compiler/src/text-parts.ts b/packages/compiler/src/text-parts.ts index a4519b0ba..1b48e72fc 100644 --- a/packages/compiler/src/text-parts.ts +++ b/packages/compiler/src/text-parts.ts @@ -1,4 +1,10 @@ +import { createReadStream } from "node:fs"; import { open } from "node:fs/promises"; +import { StringDecoder } from "node:string_decoder"; + +/** The longest part readTextParts returns, far below the engine's string + * limit. */ +const TEXT_PART_LENGTH = 64 * 1024 * 1024; /** Writes compiler text artifacts (LLVM, serialized IR) in large batches. * Programs past the engine's string limit arrive as many parts, and writing @@ -35,3 +41,43 @@ export async function writeTextParts( await file.close(); } } + +/** Reads a text artifact as parts of at most `partLength` UTF-16 units: + * cached LLVM and serialized IR of large programs exceed the engine's + * string limit as one string. The decoder carries a multi-byte sequence + * split across reads into the next part, and no part ends inside a + * surrogate pair, so the parts join to the file's text and each encodes on + * its own. */ +export async function readTextParts( + path: string, + partLength = TEXT_PART_LENGTH, +): Promise { + const decoder = new StringDecoder("utf8"); + const parts: string[] = []; + let pending: string[] = []; + let pendingLength = 0; + const flush = (): void => { + if (pendingLength === 0) return; + parts.push(pending.join("")); + pending = []; + pendingLength = 0; + }; + const add = (text: string): void => { + for (let offset = 0; offset < text.length;) { + let take = Math.min(text.length - offset, partLength - pendingLength); + // A part never ends inside a surrogate pair: writeTextParts encodes + // its batches separately. + const last = text.charCodeAt(offset + take - 1); + if (take > 1 && last >= 0xd800 && last <= 0xdbff && offset + take < text.length) take--; + pending.push(text.slice(offset, offset + take)); + pendingLength += take; + offset += take; + if (pendingLength >= partLength - 1) flush(); + } + }; + for await (const chunk of createReadStream(path, { highWaterMark: 1 << 20 })) + add(decoder.write(chunk as Buffer)); + add(decoder.end()); + flush(); + return parts; +}