fix(producer): reject impossible distributed durations (#1732)

This commit is contained in:
Miguel Ángel
2026-06-26 00:47:27 -04:00
committed by GitHub
parent 7db84fc0ad
commit dca5fa90cb
6 changed files with 173 additions and 2 deletions
+10
View File
@@ -95,3 +95,13 @@ export type {
CompositionMetadataJson,
LockedRenderConfig,
} from "./services/render/stages/freezePlan.js";
// ── Plan-time validation errors ────────────────────────────────────────────
// Export typed deterministic validation codes so orchestration adapters can
// mark authoring/configuration failures as terminal while still retrying real
// infrastructure faults.
export {
DISTRIBUTED_DURATION_OUT_OF_RANGE,
MAX_DISTRIBUTED_DURATION_SECONDS,
PlanValidationError,
} from "./services/render/planValidation.js";
@@ -35,7 +35,7 @@ import {
writeFileSync,
} from "node:fs";
import { join, relative, sep } from "node:path";
import { type CanvasResolution } from "@hyperframes/core";
import { type CanvasResolution, fpsToNumber } from "@hyperframes/core";
import {
type EngineConfig,
type VideoFrameFormat,
@@ -60,7 +60,11 @@ import {
type PlanDimensions,
sha256Hex,
} from "../render/stages/planHash.js";
import { validateNoGpuEncode, validateNoSystemFonts } from "../render/planValidation.js";
import {
validateDistributedDuration,
validateNoGpuEncode,
validateNoSystemFonts,
} from "../render/planValidation.js";
import { snapshotRuntimeEnv } from "../render/runtimeEnvSnapshot.js";
import {
buildSyntheticRenderJob,
@@ -853,6 +857,11 @@ export async function plan(
job.duration = probeResult.duration;
job.totalFrames = probeResult.totalFrames;
const totalFrames = probeResult.totalFrames;
validateDistributedDuration({
duration: probeResult.duration,
totalFrames,
fps: fpsToNumber(job.config.fps),
});
if (probeResult.fileServer) closeFileServerSafely(probeResult.fileServer, "plan", log);
if (probeResult.probeSession) {
// Close inside a try/catch — leaking a Chrome process here would mask
@@ -144,3 +144,58 @@ describe("plan() PLAN_TOO_LARGE throw path", () => {
TIMEOUT_MS,
);
});
describe("plan() duration guard", () => {
const TIMEOUT_MS = 30_000;
it(
"rejects probe durations that would create impossible distributed frame counts",
async () => {
const projectDir = mkdtempSync(join(runRoot, "project-impossible-duration-"));
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html>
<html><body>
<div data-composition-id="root" data-width="320" data-height="240" data-start="0">
<div class="caption">hello</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines.root = {
duration() { return 10000000000; },
pause() {},
time() { return 0; },
seek() {},
totalTime() {},
add() {}
};
</script>
</body></html>`,
"utf-8",
);
const planDir = mkdtempSync(join(runRoot, "plandir-impossible-duration-"));
let caught: unknown;
try {
await plan(
projectDir,
{
fps: 30,
width: 320,
height: 240,
format: "mp4",
},
planDir,
);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(Error);
expect(String((caught as Error).message)).toMatch(/duration/i);
expect(String((caught as Error).message)).toMatch(/distributed/i);
expect(String((caught as Error).message)).toContain("300000000000");
},
TIMEOUT_MS,
);
});
@@ -43,6 +43,10 @@ describe("@hyperframes/producer/distributed (subpath)", () => {
it("exports the non-retryable error codes + classes", () => {
expect(distributedSubpath.PLAN_TOO_LARGE).toBe("PLAN_TOO_LARGE");
expect(distributedSubpath.DISTRIBUTED_DURATION_OUT_OF_RANGE).toBe(
"DISTRIBUTED_DURATION_OUT_OF_RANGE",
);
expect(distributedSubpath.MAX_DISTRIBUTED_DURATION_SECONDS).toBe(24 * 60 * 60);
expect(distributedSubpath.FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED).toBe(
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
);
@@ -51,6 +55,7 @@ describe("@hyperframes/producer/distributed (subpath)", () => {
expect(typeof distributedSubpath.PlanTooLargeError).toBe("function");
expect(typeof distributedSubpath.FormatNotSupportedInDistributedError).toBe("function");
expect(typeof distributedSubpath.PlanValidationError).toBe("function");
expect(typeof distributedSubpath.RenderChunkValidationError).toBe("function");
});
@@ -9,9 +9,12 @@
import { describe, expect, it } from "bun:test";
import {
BROWSER_GPU_NOT_SOFTWARE,
DISTRIBUTED_DURATION_OUT_OF_RANGE,
MAX_DISTRIBUTED_DURATION_SECONDS,
PlanValidationError,
SYSTEM_FONT_USED,
parseFontFamilyValue,
validateDistributedDuration,
validateNoGpuEncode,
validateNoSystemFonts,
} from "./planValidation.js";
@@ -91,6 +94,53 @@ describe("validateNoGpuEncode", () => {
});
});
describe("validateDistributedDuration", () => {
it("accepts a finite duration within the distributed ceiling", () => {
expect(() =>
validateDistributedDuration({
duration: MAX_DISTRIBUTED_DURATION_SECONDS,
totalFrames: MAX_DISTRIBUTED_DURATION_SECONDS * 30,
fps: 30,
}),
).not.toThrow();
});
it("throws DISTRIBUTED_DURATION_OUT_OF_RANGE for the engine's infinite-timeline sentinel", () => {
let caught: unknown;
try {
validateDistributedDuration({
duration: 10_000_000_000,
totalFrames: 300_000_000_000,
fps: 30,
});
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(PlanValidationError);
expect((caught as PlanValidationError).code).toBe(DISTRIBUTED_DURATION_OUT_OF_RANGE);
expect((caught as Error).message).toContain("300000000000");
expect((caught as Error).message).toContain("GSAP repeat:-1");
});
it("throws DISTRIBUTED_DURATION_OUT_OF_RANGE for non-finite or zero values", () => {
for (const input of [
{ duration: Number.POSITIVE_INFINITY, totalFrames: 1, fps: 30 },
{ duration: 0, totalFrames: 1, fps: 30 },
{ duration: 1, totalFrames: 0, fps: 30 },
{ duration: 1, totalFrames: 1, fps: Number.NaN },
]) {
let caught: unknown;
try {
validateDistributedDuration(input);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(PlanValidationError);
expect((caught as PlanValidationError).code).toBe(DISTRIBUTED_DURATION_OUT_OF_RANGE);
}
});
});
describe("parseFontFamilyValue", () => {
it("splits a comma-separated list and strips whitespace + quotes", () => {
expect(parseFontFamilyValue(`"Inter", -apple-system, sans-serif`)).toEqual([
@@ -62,6 +62,18 @@ export interface ValidateNoGpuEncodeInput {
*/
export const SYSTEM_FONT_USED = "SYSTEM_FONT_USED";
/**
* Typed code for {@link validateDistributedDuration}. A duration this large
* almost always means an unbounded runtime timeline escaped into plan(),
* e.g. GSAP `repeat: -1` reporting its internal sentinel duration. Letting
* that reach chunk planning creates billions of frames and turns an authoring
* error into worker churn.
*/
export const DISTRIBUTED_DURATION_OUT_OF_RANGE = "DISTRIBUTED_DURATION_OUT_OF_RANGE";
/** Distributed renders are operationally bounded to one day of output. */
export const MAX_DISTRIBUTED_DURATION_SECONDS = 24 * 60 * 60;
/**
* Reject any config that would let GPU encode or hardware-GL slip into a
* distributed render. Throws {@link PlanValidationError} with
@@ -124,3 +136,33 @@ export function validateNoSystemFonts(compiledHtml: string): void {
);
}
}
export function validateDistributedDuration(input: {
duration: number;
totalFrames: number;
fps: number;
}): void {
const { duration, totalFrames, fps } = input;
const maxFrames = Math.ceil(MAX_DISTRIBUTED_DURATION_SECONDS * fps);
if (
Number.isFinite(duration) &&
duration > 0 &&
Number.isFinite(fps) &&
fps > 0 &&
Number.isSafeInteger(totalFrames) &&
totalFrames > 0 &&
totalFrames <= maxFrames
) {
return;
}
throw new PlanValidationError(
DISTRIBUTED_DURATION_OUT_OF_RANGE,
`[planValidation] Distributed render duration is out of range: ` +
`duration=${String(duration)}s totalFrames=${String(totalFrames)} fps=${String(fps)} ` +
`(maxDuration=${String(MAX_DISTRIBUTED_DURATION_SECONDS)}s, maxFrames=${String(maxFrames)}). ` +
`This usually means an unbounded timeline escaped into render planning, such as ` +
`GSAP repeat:-1 / yoyo loops without an explicit finite root duration. Add a finite ` +
`data-duration or replace infinite repeats with a finite repeat count before rendering.`,
);
}