Skip to content

Commit dca5fa9

Browse files
fix(producer): reject impossible distributed durations (#1732)
1 parent 7db84fc commit dca5fa9

6 files changed

Lines changed: 173 additions & 2 deletions

File tree

packages/producer/src/distributed.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,13 @@ export type {
9595
CompositionMetadataJson,
9696
LockedRenderConfig,
9797
} from "./services/render/stages/freezePlan.js";
98+
99+
// ── Plan-time validation errors ────────────────────────────────────────────
100+
// Export typed deterministic validation codes so orchestration adapters can
101+
// mark authoring/configuration failures as terminal while still retrying real
102+
// infrastructure faults.
103+
export {
104+
DISTRIBUTED_DURATION_OUT_OF_RANGE,
105+
MAX_DISTRIBUTED_DURATION_SECONDS,
106+
PlanValidationError,
107+
} from "./services/render/planValidation.js";

packages/producer/src/services/distributed/plan.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
writeFileSync,
3636
} from "node:fs";
3737
import { join, relative, sep } from "node:path";
38-
import { type CanvasResolution } from "@hyperframes/core";
38+
import { type CanvasResolution, fpsToNumber } from "@hyperframes/core";
3939
import {
4040
type EngineConfig,
4141
type VideoFrameFormat,
@@ -60,7 +60,11 @@ import {
6060
type PlanDimensions,
6161
sha256Hex,
6262
} from "../render/stages/planHash.js";
63-
import { validateNoGpuEncode, validateNoSystemFonts } from "../render/planValidation.js";
63+
import {
64+
validateDistributedDuration,
65+
validateNoGpuEncode,
66+
validateNoSystemFonts,
67+
} from "../render/planValidation.js";
6468
import { snapshotRuntimeEnv } from "../render/runtimeEnvSnapshot.js";
6569
import {
6670
buildSyntheticRenderJob,
@@ -853,6 +857,11 @@ export async function plan(
853857
job.duration = probeResult.duration;
854858
job.totalFrames = probeResult.totalFrames;
855859
const totalFrames = probeResult.totalFrames;
860+
validateDistributedDuration({
861+
duration: probeResult.duration,
862+
totalFrames,
863+
fps: fpsToNumber(job.config.fps),
864+
});
856865
if (probeResult.fileServer) closeFileServerSafely(probeResult.fileServer, "plan", log);
857866
if (probeResult.probeSession) {
858867
// Close inside a try/catch — leaking a Chrome process here would mask

packages/producer/src/services/distributed/planSizeCap.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,58 @@ describe("plan() PLAN_TOO_LARGE throw path", () => {
144144
TIMEOUT_MS,
145145
);
146146
});
147+
148+
describe("plan() duration guard", () => {
149+
const TIMEOUT_MS = 30_000;
150+
151+
it(
152+
"rejects probe durations that would create impossible distributed frame counts",
153+
async () => {
154+
const projectDir = mkdtempSync(join(runRoot, "project-impossible-duration-"));
155+
writeFileSync(
156+
join(projectDir, "index.html"),
157+
`<!doctype html>
158+
<html><body>
159+
<div data-composition-id="root" data-width="320" data-height="240" data-start="0">
160+
<div class="caption">hello</div>
161+
</div>
162+
<script>
163+
window.__timelines = window.__timelines || {};
164+
window.__timelines.root = {
165+
duration() { return 10000000000; },
166+
pause() {},
167+
time() { return 0; },
168+
seek() {},
169+
totalTime() {},
170+
add() {}
171+
};
172+
</script>
173+
</body></html>`,
174+
"utf-8",
175+
);
176+
const planDir = mkdtempSync(join(runRoot, "plandir-impossible-duration-"));
177+
178+
let caught: unknown;
179+
try {
180+
await plan(
181+
projectDir,
182+
{
183+
fps: 30,
184+
width: 320,
185+
height: 240,
186+
format: "mp4",
187+
},
188+
planDir,
189+
);
190+
} catch (err) {
191+
caught = err;
192+
}
193+
194+
expect(caught).toBeInstanceOf(Error);
195+
expect(String((caught as Error).message)).toMatch(/duration/i);
196+
expect(String((caught as Error).message)).toMatch(/distributed/i);
197+
expect(String((caught as Error).message)).toContain("300000000000");
198+
},
199+
TIMEOUT_MS,
200+
);
201+
});

packages/producer/src/services/distributed/publicExports.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ describe("@hyperframes/producer/distributed (subpath)", () => {
4343

4444
it("exports the non-retryable error codes + classes", () => {
4545
expect(distributedSubpath.PLAN_TOO_LARGE).toBe("PLAN_TOO_LARGE");
46+
expect(distributedSubpath.DISTRIBUTED_DURATION_OUT_OF_RANGE).toBe(
47+
"DISTRIBUTED_DURATION_OUT_OF_RANGE",
48+
);
49+
expect(distributedSubpath.MAX_DISTRIBUTED_DURATION_SECONDS).toBe(24 * 60 * 60);
4650
expect(distributedSubpath.FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED).toBe(
4751
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
4852
);
@@ -51,6 +55,7 @@ describe("@hyperframes/producer/distributed (subpath)", () => {
5155

5256
expect(typeof distributedSubpath.PlanTooLargeError).toBe("function");
5357
expect(typeof distributedSubpath.FormatNotSupportedInDistributedError).toBe("function");
58+
expect(typeof distributedSubpath.PlanValidationError).toBe("function");
5459
expect(typeof distributedSubpath.RenderChunkValidationError).toBe("function");
5560
});
5661

packages/producer/src/services/render/planValidation.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99
import { describe, expect, it } from "bun:test";
1010
import {
1111
BROWSER_GPU_NOT_SOFTWARE,
12+
DISTRIBUTED_DURATION_OUT_OF_RANGE,
13+
MAX_DISTRIBUTED_DURATION_SECONDS,
1214
PlanValidationError,
1315
SYSTEM_FONT_USED,
1416
parseFontFamilyValue,
17+
validateDistributedDuration,
1518
validateNoGpuEncode,
1619
validateNoSystemFonts,
1720
} from "./planValidation.js";
@@ -91,6 +94,53 @@ describe("validateNoGpuEncode", () => {
9194
});
9295
});
9396

97+
describe("validateDistributedDuration", () => {
98+
it("accepts a finite duration within the distributed ceiling", () => {
99+
expect(() =>
100+
validateDistributedDuration({
101+
duration: MAX_DISTRIBUTED_DURATION_SECONDS,
102+
totalFrames: MAX_DISTRIBUTED_DURATION_SECONDS * 30,
103+
fps: 30,
104+
}),
105+
).not.toThrow();
106+
});
107+
108+
it("throws DISTRIBUTED_DURATION_OUT_OF_RANGE for the engine's infinite-timeline sentinel", () => {
109+
let caught: unknown;
110+
try {
111+
validateDistributedDuration({
112+
duration: 10_000_000_000,
113+
totalFrames: 300_000_000_000,
114+
fps: 30,
115+
});
116+
} catch (err) {
117+
caught = err;
118+
}
119+
expect(caught).toBeInstanceOf(PlanValidationError);
120+
expect((caught as PlanValidationError).code).toBe(DISTRIBUTED_DURATION_OUT_OF_RANGE);
121+
expect((caught as Error).message).toContain("300000000000");
122+
expect((caught as Error).message).toContain("GSAP repeat:-1");
123+
});
124+
125+
it("throws DISTRIBUTED_DURATION_OUT_OF_RANGE for non-finite or zero values", () => {
126+
for (const input of [
127+
{ duration: Number.POSITIVE_INFINITY, totalFrames: 1, fps: 30 },
128+
{ duration: 0, totalFrames: 1, fps: 30 },
129+
{ duration: 1, totalFrames: 0, fps: 30 },
130+
{ duration: 1, totalFrames: 1, fps: Number.NaN },
131+
]) {
132+
let caught: unknown;
133+
try {
134+
validateDistributedDuration(input);
135+
} catch (err) {
136+
caught = err;
137+
}
138+
expect(caught).toBeInstanceOf(PlanValidationError);
139+
expect((caught as PlanValidationError).code).toBe(DISTRIBUTED_DURATION_OUT_OF_RANGE);
140+
}
141+
});
142+
});
143+
94144
describe("parseFontFamilyValue", () => {
95145
it("splits a comma-separated list and strips whitespace + quotes", () => {
96146
expect(parseFontFamilyValue(`"Inter", -apple-system, sans-serif`)).toEqual([

packages/producer/src/services/render/planValidation.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ export interface ValidateNoGpuEncodeInput {
6262
*/
6363
export const SYSTEM_FONT_USED = "SYSTEM_FONT_USED";
6464

65+
/**
66+
* Typed code for {@link validateDistributedDuration}. A duration this large
67+
* almost always means an unbounded runtime timeline escaped into plan(),
68+
* e.g. GSAP `repeat: -1` reporting its internal sentinel duration. Letting
69+
* that reach chunk planning creates billions of frames and turns an authoring
70+
* error into worker churn.
71+
*/
72+
export const DISTRIBUTED_DURATION_OUT_OF_RANGE = "DISTRIBUTED_DURATION_OUT_OF_RANGE";
73+
74+
/** Distributed renders are operationally bounded to one day of output. */
75+
export const MAX_DISTRIBUTED_DURATION_SECONDS = 24 * 60 * 60;
76+
6577
/**
6678
* Reject any config that would let GPU encode or hardware-GL slip into a
6779
* distributed render. Throws {@link PlanValidationError} with
@@ -124,3 +136,33 @@ export function validateNoSystemFonts(compiledHtml: string): void {
124136
);
125137
}
126138
}
139+
140+
export function validateDistributedDuration(input: {
141+
duration: number;
142+
totalFrames: number;
143+
fps: number;
144+
}): void {
145+
const { duration, totalFrames, fps } = input;
146+
const maxFrames = Math.ceil(MAX_DISTRIBUTED_DURATION_SECONDS * fps);
147+
if (
148+
Number.isFinite(duration) &&
149+
duration > 0 &&
150+
Number.isFinite(fps) &&
151+
fps > 0 &&
152+
Number.isSafeInteger(totalFrames) &&
153+
totalFrames > 0 &&
154+
totalFrames <= maxFrames
155+
) {
156+
return;
157+
}
158+
159+
throw new PlanValidationError(
160+
DISTRIBUTED_DURATION_OUT_OF_RANGE,
161+
`[planValidation] Distributed render duration is out of range: ` +
162+
`duration=${String(duration)}s totalFrames=${String(totalFrames)} fps=${String(fps)} ` +
163+
`(maxDuration=${String(MAX_DISTRIBUTED_DURATION_SECONDS)}s, maxFrames=${String(maxFrames)}). ` +
164+
`This usually means an unbounded timeline escaped into render planning, such as ` +
165+
`GSAP repeat:-1 / yoyo loops without an explicit finite root duration. Add a finite ` +
166+
`data-duration or replace infinite repeats with a finite repeat count before rendering.`,
167+
);
168+
}

0 commit comments

Comments
 (0)