fix(durable): encode base64 with btoa, without retaining concatenation trees

toBase64 grew each 8 KiB chunk a character at a time; V8 kept every
concatenation node until the final join, about 138 MB of heap for a
3 MiB image, beyond a Durable Object's 128 MB limit.

closes #10923
This commit is contained in:
Mario Zechner 2026-10-14 09:40:12 +00:00
parent 7efebff189
commit b042997178
3 changed files with 28 additions and 20 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- `toBase64`, used by `read` and the Photon image processor, no longer takes about 140 MB of heap to encode a 3 MiB image, which exceeded the 128 MB memory limit of a Cloudflare Durable Object; it now builds a binary string 8 KiB at a time and encodes it with `btoa` ([#10923](https://github.com/earendil-works/pi/issues/10923)).
## [1.2.0] - 2026-10-12
### Breaking Changes

View file

@ -39,28 +39,17 @@ export type ImageProcessor = {
/** Formats every provider takes inline; others, such as BMP, need converting. */
export const INLINE_IMAGE_TYPES: ReadonlySet<string> = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/** Standard base64 of `bytes`, without platform APIs such as `Buffer` or `btoa`. */
/**
* Standard base64 of `bytes`. `btoa` takes a binary string, built 8 KiB at a time: a single `String.fromCharCode`
* call with megabytes of arguments overflows the stack.
*/
export function toBase64(bytes: Uint8Array): string {
const parts: string[] = [];
let chunk = "";
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i]!;
const b = bytes[i + 1];
const c = bytes[i + 2];
chunk += BASE64[a >> 2]!;
chunk += BASE64[((a & 3) << 4) | ((b ?? 0) >> 4)]!;
chunk += b === undefined ? "=" : BASE64[((b & 15) << 2) | ((c ?? 0) >> 6)]!;
chunk += c === undefined ? "=" : BASE64[c & 63]!;
// Join in pieces: one string grown a character at a time is slow for megabytes.
if (chunk.length >= 8192) {
parts.push(chunk);
chunk = "";
}
for (let i = 0; i < bytes.length; i += 8192) {
// `apply` takes any array-like, though its type asks for an array.
parts.push(String.fromCharCode.apply(null, bytes.subarray(i, i + 8192) as unknown as number[]));
}
parts.push(chunk);
return parts.join("");
return btoa(parts.join(""));
}
/** The length of the base64 of `byteLength` bytes. */

View file

@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
@ -486,11 +487,25 @@ describe("imageDimensions", () => {
describe("toBase64", () => {
it("matches Buffer for every remainder length", () => {
for (const length of [0, 1, 2, 3, 4, 5, 10_000]) {
for (const length of [0, 1, 2, 3, 4, 5, 8191, 8192, 8193, 10_000, 16_385]) {
const bytes = Uint8Array.from({ length }, (_, index) => (index * 37) % 256);
expect(toBase64(bytes)).toBe(Buffer.from(bytes).toString("base64"));
}
});
// #10923: building the output a character at a time took 138 MB of heap for a 3 MiB image.
it("encodes a 3 MiB image within a 128 MB heap", () => {
const source = new URL("../src/tools/image-processor.ts", import.meta.url).href;
const script = `
const { toBase64 } = await import(${JSON.stringify(source)});
const bytes = Uint8Array.from({ length: 3147295 }, (_, index) => (index * 37) % 256);
if (toBase64(bytes) !== Buffer.from(bytes).toString("base64")) throw new Error("mismatch");
`;
const args = ["--experimental-strip-types", "--max-heap-size=128", "--input-type=module", "-e", script];
const result = spawnSync(process.execPath, args, { encoding: "utf8" });
expect(result.stderr).not.toMatch(/heap out of memory|mismatch/);
expect(result.status).toBe(0);
});
});
describe("createNodePhotonImages", () => {