From fc4e9a6c0ed8aa0eee054edce87e521c7ff3cafe Mon Sep 17 00:00:00 2001 From: James Date: Fri, 17 Jul 2026 15:34:00 -0400 Subject: [PATCH] fix(producer): validate render request engine snapshots --- packages/engine/src/config.ts | 200 ++++++++++++++++++ packages/engine/src/index.ts | 1 + packages/producer/src/renderRequest.test.ts | 29 +++ packages/producer/src/renderRequest.ts | 5 +- .../distributed/renderConfigValidation.ts | 8 +- 5 files changed, 238 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index ce250aebf..969bbd611 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -317,6 +317,206 @@ export const DEFAULT_CONFIG: EngineConfig = { debug: false, }; +const OPTIONAL_ENGINE_CONFIG_FIELDS = [ + "chromePath", + "expectedChromiumMajor", + "pageSideCompositingAutoDisabled", + "forceScreenshotExplicitlyOptedOut", + "streamingEncodeAutoDisabledOnWin32Compound", + "runtimeManifestPath", + "extractCacheDir", +] as const; + +const BOOLEAN_ENGINE_CONFIG_FIELDS = [ + "disableGpu", + "enableBrowserPool", + "forceScreenshot", + "staticFrameDedup", + "useDrawElement", + "enableDrawElementWorkerEncode", + "lowMemoryMode", + "enablePageSideCompositing", + "enableChunkedEncode", + "enableStreamingEncode", + "hdrAutoDetect", + "verifyRuntime", + "debug", +] as const; + +const POSITIVE_NUMBER_ENGINE_CONFIG_FIELDS = [ + "coresPerWorker", + "browserTimeout", + "protocolTimeout", + "chunkSizeFrames", + "ffmpegEncodeTimeout", + "ffmpegProcessTimeout", + "ffmpegStreamingTimeout", + "frameDataUriCacheLimit", + "frameDataUriCacheBytesLimitMb", + "playerReadyTimeout", + "renderReadyTimeout", + "pageNavigationTimeout", + "extractCacheMaxBytes", +] as const; + +const ENUM_ENGINE_CONFIG_FIELDS = { + fps: [24, 30, 60], + quality: ["draft", "standard", "high"], + format: ["jpeg", "png"], + browserGpuMode: ["software", "hardware", "auto"], +} as const; + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + [Object.prototype, null].includes(Object.getPrototypeOf(value)) + ); +} + +function assertEngineConfigNumber( + config: Record, + field: string, + min: number, + integer = false, +): void { + const value = config[field]; + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < min || + (integer && !Number.isInteger(value)) + ) { + throw new Error( + `Engine config ${field} must be a ${integer ? "finite integer" : "finite number"} >= ${min}`, + ); + } +} + +function assertEngineConfigEnum( + config: Record, + field: string, + values: readonly unknown[], +): void { + if (!values.includes(config[field])) throw new Error(`Engine config ${field} is invalid`); +} + +function validateRequiredEngineConfigFields(config: Record): void { + const requiredFields = Object.keys(DEFAULT_CONFIG); + for (const field of requiredFields) { + if (!Object.hasOwn(config, field)) { + throw new Error(`Engine config snapshot is missing required field ${field}`); + } + } + const allowedFields = new Set([...requiredFields, ...OPTIONAL_ENGINE_CONFIG_FIELDS]); + for (const field of Object.keys(config)) { + if (!allowedFields.has(field)) + throw new Error(`Engine config snapshot has unknown field ${field}`); + } +} + +function validateEngineConfigScalars(config: Record): void { + for (const [field, values] of Object.entries(ENUM_ENGINE_CONFIG_FIELDS)) { + assertEngineConfigEnum(config, field, values); + } + assertEngineConfigNumber(config, "jpegQuality", 0); + if (typeof config.jpegQuality === "number" && config.jpegQuality > 100) { + throw new Error("Engine config jpegQuality must be <= 100"); + } +} + +function validateEngineConfigParallelism(config: Record): void { + if ( + config.concurrency !== "auto" && + (typeof config.concurrency !== "number" || + !Number.isInteger(config.concurrency) || + config.concurrency < 1) + ) { + throw new Error("Engine config concurrency must be a positive integer or auto"); + } + assertEngineConfigNumber(config, "coresPerWorker", 0); + for (const field of ["minParallelFrames", "largeRenderThreshold"] as const) { + assertEngineConfigNumber(config, field, 0, true); + } +} + +function validateEngineConfigVp9(config: Record): void { + if ( + typeof config.vp9CpuUsed !== "number" || + !Number.isInteger(config.vp9CpuUsed) || + config.vp9CpuUsed < -8 || + config.vp9CpuUsed > 8 + ) { + throw new Error("Engine config vp9CpuUsed must be an integer in [-8, 8]"); + } +} + +function validateEngineConfigRuntime(config: Record): void { + for (const field of BOOLEAN_ENGINE_CONFIG_FIELDS) { + if (typeof config[field] !== "boolean") + throw new Error(`Engine config ${field} must be a boolean`); + } + for (const field of POSITIVE_NUMBER_ENGINE_CONFIG_FIELDS) { + assertEngineConfigNumber(config, field, 1, field === "frameDataUriCacheLimit"); + } + assertEngineConfigNumber(config, "streamingEncodeMaxDurationSeconds", 0); + assertEngineConfigNumber(config, "audioGain", 0); +} + +function validateEngineConfigHdr(config: Record): void { + const { hdr } = config; + if ( + hdr !== false && + (!isPlainObject(hdr) || + Object.keys(hdr).length !== 1 || + (hdr.transfer !== "hlg" && hdr.transfer !== "pq")) + ) { + throw new Error("Engine config hdr must be false or an hlg/pq transfer object"); + } +} + +function validateOptionalEngineConfigFields(config: Record): void { + for (const field of ["chromePath", "runtimeManifestPath", "extractCacheDir"] as const) { + if ( + config[field] !== undefined && + (typeof config[field] !== "string" || config[field].length === 0) + ) { + throw new Error(`Engine config ${field} must be a non-empty string`); + } + } + if (config.expectedChromiumMajor !== undefined) { + assertEngineConfigNumber(config, "expectedChromiumMajor", 1, true); + } + for (const field of [ + "pageSideCompositingAutoDisabled", + "forceScreenshotExplicitlyOptedOut", + "streamingEncodeAutoDisabledOnWin32Compound", + ] as const) { + if (config[field] !== undefined && typeof config[field] !== "boolean") { + throw new Error(`Engine config ${field} must be a boolean`); + } + } +} + +/** + * Validate a complete EngineConfig crossing a JSON wire boundary. + * + * `resolveConfig()` intentionally accepts partial programmatic overrides, but + * a serialized render request stores a resolved snapshot. Accepting a partial + * snapshot would skip the orchestrator's `resolveConfig()` fallback entirely. + */ +export function validateEngineConfigSnapshot(value: unknown): asserts value is EngineConfig { + if (!isPlainObject(value)) throw new Error("Engine config snapshot must be a plain object"); + validateRequiredEngineConfigFields(value); + validateEngineConfigScalars(value); + validateEngineConfigParallelism(value); + validateEngineConfigVp9(value); + validateEngineConfigRuntime(value); + validateEngineConfigHdr(value); + validateOptionalEngineConfigFields(value); +} + /** * Reference canvas area for the baseline `protocolTimeout`: 1080p. A single CDP * call (`Runtime.callFunctionOn` seek+paint, or `Page.captureScreenshot`) diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index fdf585758..c90bab6f6 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -48,6 +48,7 @@ export type { // ── Configuration ────────────────────────────────────────────────────────────── export { resolveConfig, + validateEngineConfigSnapshot, DEFAULT_CONFIG, scaleProtocolTimeoutForComposition, shouldClampToScreenshotForConcreteGpu, diff --git a/packages/producer/src/renderRequest.test.ts b/packages/producer/src/renderRequest.test.ts index 865d7fa70..728ccdf59 100644 --- a/packages/producer/src/renderRequest.test.ts +++ b/packages/producer/src/renderRequest.test.ts @@ -174,6 +174,35 @@ describe("RenderRequest", () => { ).toThrow("distributed.width"); }); + it("requires complete and valid engine config snapshots on both wire paths", () => { + const value = request(); + for (const engineConfig of [ + {}, + { ...value.options.engineConfig, protocolTimeout: "forever" }, + { ...value.options.engineConfig, browserGpuMode: "turbo" }, + ]) { + expect(() => + parseRenderRequest({ ...value, options: { ...value.options, engineConfig } }), + ).toThrow("Engine config"); + } + + for (const engineConfig of [ + {}, + { ...value.options.engineConfig, protocolTimeout: "forever" }, + { ...value.options.engineConfig, browserGpuMode: "turbo" }, + ]) { + const distributed = distributedConfigFromRequest(value); + (distributed as { engineConfig: unknown }).engineConfig = engineConfig; + expect(() => + renderRequestFromDistributedConfig({ + projectDir: value.projectDir, + outputPath: value.outputPath, + config: distributed, + }), + ).toThrow("Engine config"); + } + }); + it("validates the reverse distributed adapter at the wire boundary", () => { const distributed = distributedConfigFromRequest(request()); distributed.width = 1921; diff --git a/packages/producer/src/renderRequest.ts b/packages/producer/src/renderRequest.ts index b51897c68..e4e373aff 100644 --- a/packages/producer/src/renderRequest.ts +++ b/packages/producer/src/renderRequest.ts @@ -1,6 +1,7 @@ import { isVideoFrameFormat, resolveConfig, + validateEngineConfigSnapshot, type EngineConfig, type VideoFrameFormat, } from "@hyperframes/engine"; @@ -176,9 +177,7 @@ function assertRequestOptionScalars(options: Record): void { } function assertRequestOptionObjects(options: Record): void { - if (!isPlainObject(options.engineConfig)) { - throw new Error("Render request must contain a resolved engineConfig snapshot"); - } + validateEngineConfigSnapshot(options.engineConfig); if (options.variables !== undefined && !isPlainObject(options.variables)) { throw new Error("Render request variables must be a JSON object"); } diff --git a/packages/producer/src/services/distributed/renderConfigValidation.ts b/packages/producer/src/services/distributed/renderConfigValidation.ts index fbbf63b4c..812e725d1 100644 --- a/packages/producer/src/services/distributed/renderConfigValidation.ts +++ b/packages/producer/src/services/distributed/renderConfigValidation.ts @@ -17,7 +17,11 @@ * needs the actual planner. */ -import { VIDEO_FRAME_FORMATS, isVideoFrameFormat } from "@hyperframes/engine"; +import { + VIDEO_FRAME_FORMATS, + isVideoFrameFormat, + validateEngineConfigSnapshot, +} from "@hyperframes/engine"; import { type DistributedFormat } from "./shared.js"; import { type DistributedRenderConfig } from "./plan.js"; @@ -204,7 +208,7 @@ export function validateDistributedRenderConfig( validateVariablesPayload(config.variables); } if (config.engineConfig !== undefined) { - walkVariables(config.engineConfig, "config.engineConfig", new WeakSet()); + validateEngineConfigSnapshot(config.engineConfig); } return config;