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.
This commit is contained in:
Chris Tate
2026-10-21 08:52:43 -05:00
parent 0237d70df0
commit 927504af87
3 changed files with 99 additions and 2 deletions
+4 -2
View File
@@ -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,
);
+49
View File
@@ -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);
+46
View File
@@ -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<string[]> {
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;
}