From fbbd41a7974f3251e39402de1a2072b4d61d5361 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 13 May 2026 22:26:26 +0000 Subject: [PATCH] feat(producer): enforce planDir size cap with PLAN_TOO_LARGE --- .../producer/src/services/distributed/plan.ts | 101 +++++++++++- .../services/distributed/planSizeCap.test.ts | 146 ++++++++++++++++++ 2 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 packages/producer/src/services/distributed/planSizeCap.test.ts diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index 089bc35d6..102e30fe6 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -24,7 +24,7 @@ * never have to handle them. */ -import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs"; import { join } from "node:path"; import { type CanvasResolution } from "@hyperframes/core"; import { type EngineConfig, resolveConfig } from "@hyperframes/engine"; @@ -102,6 +102,16 @@ export interface DistributedRenderConfig { entryFile?: string; /** Caller-supplied AbortSignal. Threaded through compile / probe / extract / audio stages. */ abortSignal?: AbortSignal; + /** + * Hard ceiling on `/` size in bytes; trips a non-retryable + * `PLAN_TOO_LARGE` error after freeze. Defaults to + * {@link PLAN_DIR_SIZE_LIMIT_BYTES} (2 GB — fits inside AWS Lambda's + * 10 GB `/tmp` budget alongside the chunk worker's frame buffer + + * ffmpeg working set). Adapters that deploy onto storage with + * tighter ceilings can pass a smaller cap; tests pass a tiny cap to + * exercise the throw path. + */ + planDirSizeLimitBytes?: number; } /** @@ -125,6 +135,81 @@ export interface PlanResult { export const DEFAULT_CHUNK_SIZE = 240; /** Default cap on parallel chunks for operational fairness across renders. */ export const DEFAULT_MAX_PARALLEL_CHUNKS = 16; +/** + * Default hard ceiling on `/` size in bytes. 2 GB fits inside + * AWS Lambda's 10 GB `/tmp` alongside the chunk worker's captured frames + * and ffmpeg's temporary files. Compositions that exceed this have to + * fall back to the in-process renderer until per-chunk video-frame + * slicing lands. + */ +export const PLAN_DIR_SIZE_LIMIT_BYTES = 2 * 1024 * 1024 * 1024; + +/** + * Non-retryable error code raised when `plan()` produces a planDir whose + * total size exceeds the configured limit. Workflow adapters key retry + * policies off `code` — the planDir would fail the same way on every + * retry, so the failure must not auto-retry. + */ +export const PLAN_TOO_LARGE = "PLAN_TOO_LARGE"; + +/** Typed error raised when the produced planDir exceeds {@link PLAN_DIR_SIZE_LIMIT_BYTES}. */ +export class PlanTooLargeError extends Error { + readonly code: typeof PLAN_TOO_LARGE = PLAN_TOO_LARGE; + readonly sizeBytes: number; + readonly limitBytes: number; + constructor(sizeBytes: number, limitBytes: number) { + super( + `[plan] planDir size ${formatBytes(sizeBytes)} exceeds the configured ceiling ` + + `${formatBytes(limitBytes)} (PLAN_TOO_LARGE). The default 2 GB cap fits inside AWS ` + + `Lambda's 10 GB /tmp budget alongside the chunk worker's frame buffer and ffmpeg's ` + + `working set. To unblock: shorten the composition, lower the framerate, or use the ` + + `in-process renderer (\`executeRenderJob\`) — it has no planDir size cap.`, + ); + this.name = "PlanTooLargeError"; + this.sizeBytes = sizeBytes; + this.limitBytes = limitBytes; + } +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`; +} + +/** + * Walk `/` depth-first and sum all regular file sizes. Symlinks + * are not traversed — they shouldn't appear inside a planDir to begin with + * (the extract stage materializes them), and following them could push the + * walker outside the planDir. + */ +export function measurePlanDirBytes(planDir: string): number { + let total = 0; + function walk(dir: string): void { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile()) { + try { + total += statSync(full).size; + } catch { + // Ignore — a file disappearing during the walk shouldn't crash + // the measurement. + } + } + } + } + walk(planDir); + return total; +} /** * Compute `(chunkCount, effectiveChunkSize)` from total frames and the @@ -521,8 +606,8 @@ export async function plan( // Clean up the temp work tree. `.plan-work/` holds intermediate // compileStage artifacts that are now promoted into `planDir/`; leaving - // it would inflate the planDir-size check and confuse chunk workers' file - // walks. + // it would inflate the planDir-size check below and confuse chunk + // workers' file walks. try { rmSync(workDir, { recursive: true, force: true }); } catch (err) { @@ -532,6 +617,16 @@ export async function plan( }); } + // 2 GB hard cap so the planDir fits inside Lambda's 10 GB /tmp budget + // alongside the chunk worker's frame buffer + ffmpeg working set. The + // check runs AFTER cleanup so the workDir tree doesn't double-count. + // Non-retryable: the same planDir would trip the cap on every retry. + const sizeLimitBytes = config.planDirSizeLimitBytes ?? PLAN_DIR_SIZE_LIMIT_BYTES; + const planDirBytes = measurePlanDirBytes(planDir); + if (planDirBytes > sizeLimitBytes) { + throw new PlanTooLargeError(planDirBytes, sizeLimitBytes); + } + return { planDir, planHash, diff --git a/packages/producer/src/services/distributed/planSizeCap.test.ts b/packages/producer/src/services/distributed/planSizeCap.test.ts new file mode 100644 index 000000000..865c70e4a --- /dev/null +++ b/packages/producer/src/services/distributed/planSizeCap.test.ts @@ -0,0 +1,146 @@ +/** + * Unit tests for the `PLAN_TOO_LARGE` size cap on `plan()`. + * + * `plan()` measures the produced planDir before returning, and throws a + * non-retryable `PlanTooLargeError` if it exceeds the configured ceiling. + * Defaults to {@link PLAN_DIR_SIZE_LIMIT_BYTES} (2 GB); a smaller ceiling + * can be passed via `DistributedRenderConfig.planDirSizeLimitBytes` so + * tests can exercise the throw path without filling 2 GB of /tmp. + * + * Two cases: + * 1. The standalone `measurePlanDirBytes` helper walks the tree and + * sums regular files. + * 2. `plan()` throws `PlanTooLargeError` with `code === PLAN_TOO_LARGE` + * when the produced planDir exceeds a tiny configured cap. + */ + +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + measurePlanDirBytes, + PLAN_DIR_SIZE_LIMIT_BYTES, + PLAN_TOO_LARGE, + PlanTooLargeError, + plan, +} from "./plan.js"; + +const FIXTURE_HTML = ` + +
hi
+`; + +let runRoot: string; + +beforeAll(() => { + runRoot = mkdtempSync(join(tmpdir(), "hf-plan-size-cap-")); +}); + +afterAll(() => { + rmSync(runRoot, { recursive: true, force: true }); +}); + +describe("measurePlanDirBytes", () => { + it("returns 0 for an empty directory", () => { + const dir = mkdtempSync(join(runRoot, "empty-")); + expect(measurePlanDirBytes(dir)).toBe(0); + }); + + it("sums file sizes recursively", () => { + const dir = mkdtempSync(join(runRoot, "fixture-")); + mkdirSync(join(dir, "nested", "deeper"), { recursive: true }); + writeFileSync(join(dir, "a.bin"), Buffer.alloc(100)); + writeFileSync(join(dir, "nested", "b.bin"), Buffer.alloc(250)); + writeFileSync(join(dir, "nested", "deeper", "c.bin"), Buffer.alloc(50)); + expect(measurePlanDirBytes(dir)).toBe(400); + }); + + it("ignores symlinks (not traversed into)", () => { + const dir = mkdtempSync(join(runRoot, "symlinks-")); + writeFileSync(join(dir, "real.bin"), Buffer.alloc(128)); + // We don't actually create a symlink here because the planDir + // materialization path strips them — but the function should still + // gracefully ignore broken entries if any slipped in. Confirm the + // baseline is correct (the real file's bytes). + expect(measurePlanDirBytes(dir)).toBe(128); + }); +}); + +describe("PLAN_DIR_SIZE_LIMIT_BYTES constant", () => { + it("is the documented 2 GB ceiling", () => { + expect(PLAN_DIR_SIZE_LIMIT_BYTES).toBe(2 * 1024 * 1024 * 1024); + }); +}); + +describe("PlanTooLargeError", () => { + it("carries the typed PLAN_TOO_LARGE code", () => { + const err = new PlanTooLargeError(3 * 1024 * 1024 * 1024, 2 * 1024 * 1024 * 1024); + expect(err.code).toBe(PLAN_TOO_LARGE); + expect(err.name).toBe("PlanTooLargeError"); + expect(err.sizeBytes).toBe(3 * 1024 * 1024 * 1024); + expect(err.limitBytes).toBe(2 * 1024 * 1024 * 1024); + // Message should point callers at the in-process renderer as the + // escape hatch. + expect(err.message).toMatch(/PLAN_TOO_LARGE/); + expect(err.message).toMatch(/in-process/i); + }); +}); + +describe("plan() PLAN_TOO_LARGE throw path", () => { + // Generous timeout — the actual plan() pass on a tiny fixture is ~250ms, + // but cold cache + font snapshot read can spike on slower CI hosts. + const TIMEOUT_MS = 30_000; + + it( + "throws PlanTooLargeError when planDir exceeds the configured ceiling", + async () => { + const projectDir = mkdtempSync(join(runRoot, "project-")); + writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8"); + const planDir = mkdtempSync(join(runRoot, "plandir-too-large-")); + + // 1024-byte ceiling — even an empty planDir's meta/{composition, + // encoder,chunks}.json + compiled/index.html easily exceeds this. + let caught: unknown; + try { + await plan( + projectDir, + { + fps: 30, + width: 320, + height: 240, + format: "mp4", + planDirSizeLimitBytes: 1024, + }, + planDir, + ); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(PlanTooLargeError); + expect((caught as PlanTooLargeError).code).toBe(PLAN_TOO_LARGE); + expect((caught as PlanTooLargeError).sizeBytes).toBeGreaterThan(1024); + expect((caught as PlanTooLargeError).limitBytes).toBe(1024); + }, + TIMEOUT_MS, + ); + + it( + "succeeds when the default ceiling is well above the produced planDir size", + async () => { + // No `planDirSizeLimitBytes` override → uses 2 GB default. The fixture + // produces a planDir well under that, so plan() must complete. + const projectDir = mkdtempSync(join(runRoot, "project-ok-")); + writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8"); + const planDir = mkdtempSync(join(runRoot, "plandir-ok-")); + const result = await plan( + projectDir, + { fps: 30, width: 320, height: 240, format: "mp4" }, + planDir, + ); + expect(result.planHash).toMatch(/^[0-9a-f]{64}$/); + }, + TIMEOUT_MS, + ); +});