feat(producer): plan-time validator — reject GPU encode

Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.3 (Banned in distributed mode) and
§9.3 (typed non-retryable failures).

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

  - PlanValidationError — typed plan-time error carrying a `code` field
    matching plan §9.3, so Phase 3 adapter retry policies (Temporal /
    Step Functions) can mark these as non-retryable.
  - validateNoGpuEncode(config) — throws with code BROWSER_GPU_NOT_SOFTWARE
    when:
      * config.useGpu === true  — distributed retries must be byte-
        identical, but NVENC/QSV/VAAPI produce different output across
        machines.
      * config.browserGpuMode !== "software" — hardware GL is bitwise
        unstable across drivers; pairs with the runtime
        assertSwiftShader check from PR 2.2.

The BROWSER_GPU_NOT_SOFTWARE constant is re-exported from
@hyperframes/engine (where PR 2.2 declared it) and re-exported again from
this module, so the Phase 3 distributed adapter can match the typed code
without a cross-package import.

No caller invokes the validator yet. Phase 3's `plan()` will run it
before freezing the plan, so banned configs fail fast with a typed
non-retryable error instead of leaking into a planDir.

In-process behavior is unchanged — the in-process renderer continues to
accept useGpu=true and browserGpuMode="auto".

9 unit tests at packages/producer/src/services/render/
planValidation.test.ts pin both gates and the precedence (useGpu checked
before browserGpuMode).

This is part of a stack of 10 PRs; this is PR 8 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
James
2026-05-13 04:36:12 +00:00
co-authored by Claude Opus 4.7
parent 62317f7f3a
commit 146ff0f3da
2 changed files with 165 additions and 0 deletions
@@ -0,0 +1,89 @@
/**
* Tests for plan-time validators. Each validator pins both branches:
*
* - PASS — the config is acceptable; no throw.
* - FAIL — the config trips a banned-in-distributed-mode rule; throws
* PlanValidationError with the expected typed `code`.
*/
import { describe, expect, it } from "bun:test";
import {
BROWSER_GPU_NOT_SOFTWARE,
PlanValidationError,
validateNoGpuEncode,
} from "./planValidation.js";
describe("PlanValidationError", () => {
it("preserves the typed `code` field", () => {
const err = new PlanValidationError("EXAMPLE_CODE", "msg");
expect(err.code).toBe("EXAMPLE_CODE");
expect(err.message).toBe("msg");
expect(err.name).toBe("PlanValidationError");
expect(err).toBeInstanceOf(Error);
});
});
describe("validateNoGpuEncode", () => {
it("accepts a software-only config (no fields set)", () => {
expect(() => validateNoGpuEncode({})).not.toThrow();
});
it("accepts useGpu=false + browserGpuMode='software'", () => {
expect(() => validateNoGpuEncode({ useGpu: false, browserGpuMode: "software" })).not.toThrow();
});
it("accepts useGpu=undefined (in-process default) + browserGpuMode='software'", () => {
expect(() => validateNoGpuEncode({ browserGpuMode: "software" })).not.toThrow();
});
it("throws BROWSER_GPU_NOT_SOFTWARE when useGpu === true", () => {
let caught: unknown;
try {
validateNoGpuEncode({ useGpu: true });
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(PlanValidationError);
expect((caught as PlanValidationError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
expect((caught as PlanValidationError).code).toBe("BROWSER_GPU_NOT_SOFTWARE");
expect((caught as Error).message).toContain("GPU encode is banned");
expect((caught as Error).message).toContain("useGpu === true");
});
it("throws BROWSER_GPU_NOT_SOFTWARE when browserGpuMode === 'auto'", () => {
let caught: unknown;
try {
validateNoGpuEncode({ browserGpuMode: "auto" });
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(PlanValidationError);
expect((caught as PlanValidationError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
expect((caught as Error).message).toContain("Hardware browser GPU is banned");
expect((caught as Error).message).toContain(`"auto"`);
});
it("throws BROWSER_GPU_NOT_SOFTWARE for any non-'software' browserGpuMode value", () => {
for (const mode of ["hardware", "discrete", "any", "swiftshader-fallback"]) {
let caught: unknown;
try {
validateNoGpuEncode({ browserGpuMode: mode });
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(PlanValidationError);
expect((caught as PlanValidationError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
}
});
it("checks useGpu BEFORE browserGpuMode so the useGpu message wins when both trip", () => {
let caught: unknown;
try {
validateNoGpuEncode({ useGpu: true, browserGpuMode: "auto" });
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(PlanValidationError);
expect((caught as Error).message).toContain("GPU encode is banned");
});
});
@@ -0,0 +1,76 @@
/**
* Plan-time validators for the distributed render pipeline. Each validator
* is invoked before freezing the plan, so banned configurations fail fast
* with a typed non-retryable error instead of being baked into a planDir
* and only surfacing on the chunk worker.
*/
import { BROWSER_GPU_NOT_SOFTWARE } from "@hyperframes/engine";
/**
* Re-export the BROWSER_GPU_NOT_SOFTWARE code so distributed adapters and
* Step Functions / Temporal retry policies can match it without a
* cross-package import.
*/
export { BROWSER_GPU_NOT_SOFTWARE } from "@hyperframes/engine";
/**
* Typed plan-validation error. Workflow adapters key retry policies off the
* `code` field to mark errors as non-retryable.
*/
export class PlanValidationError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "PlanValidationError";
this.code = code;
}
}
/**
* Subset of the merged plan / engine / render config that the GPU validator
* inspects. Both `useGpu` (RenderConfig) and `browserGpuMode` (EngineConfig)
* are optional so callers can pass any of the surrounding config shapes
* without an adapter layer.
*
* - `useGpu === true` → encoder GPU acceleration (NVENC/QSV/VAAPI). Banned
* because GPU encoders produce non-byte-identical output across machines.
* - `browserGpuMode !== "software"` → headless Chrome's WebGL is allowed
* to use hardware GL. Banned because hardware GL is bitwise unstable
* across drivers. Pairs with the runtime `assertSwiftShader` check that
* catches workers whose environment ignores Chrome's `--use-gl=swiftshader`.
*/
export interface ValidateNoGpuEncodeInput {
useGpu?: boolean;
browserGpuMode?: string;
}
/**
* Reject any config that would let GPU encode or hardware-GL slip into a
* distributed render. Throws {@link PlanValidationError} with
* `code === BROWSER_GPU_NOT_SOFTWARE` when either gate trips. The message
* names the offending field so the caller can surface a clean error.
*/
export function validateNoGpuEncode(config: ValidateNoGpuEncodeInput): void {
if (config.useGpu === true) {
throw new PlanValidationError(
BROWSER_GPU_NOT_SOFTWARE,
"[planValidation] GPU encode is banned in distributed mode: " +
"config.useGpu === true. " +
"Distributed retries must be byte-identical, but NVENC/QSV/VAAPI " +
"produce different output across machines. Set useGpu=false (the " +
"default) — software libx264/libx265 is the only supported encoder " +
"in distributed mode.",
);
}
if (config.browserGpuMode !== undefined && config.browserGpuMode !== "software") {
throw new PlanValidationError(
BROWSER_GPU_NOT_SOFTWARE,
`[planValidation] Hardware browser GPU is banned in distributed mode: ` +
`config.browserGpuMode === ${JSON.stringify(config.browserGpuMode)}. ` +
`Hardware GL is bitwise unstable across drivers. Set browserGpuMode="software" ` +
`so Chrome launches with --use-gl=swiftshader.`,
);
}
}