From b0429971781faed17f1b4d3ababfd47e989bfedf Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 14 Oct 2026 09:40:12 +0000 Subject: [PATCH] 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 --- packages/durable/CHANGELOG.md | 4 +++ packages/durable/src/tools/image-processor.ts | 27 ++++++------------- packages/durable/test/tools-images.test.ts | 17 +++++++++++- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index a4a086e63..766334fcc 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -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 diff --git a/packages/durable/src/tools/image-processor.ts b/packages/durable/src/tools/image-processor.ts index 9781dffe7..442cd677f 100644 --- a/packages/durable/src/tools/image-processor.ts +++ b/packages/durable/src/tools/image-processor.ts @@ -39,28 +39,17 @@ export type ImageProcessor = { /** Formats every provider takes inline; others, such as BMP, need converting. */ export const INLINE_IMAGE_TYPES: ReadonlySet = 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. */ diff --git a/packages/durable/test/tools-images.test.ts b/packages/durable/test/tools-images.test.ts index a495a5926..f893c7dbe 100644 --- a/packages/durable/test/tools-images.test.ts +++ b/packages/durable/test/tools-images.test.ts @@ -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", () => {