diff --git a/packages/engine/src/config.test.ts b/packages/engine/src/config.test.ts index 383b7f4b4..4e39137f4 100644 --- a/packages/engine/src/config.test.ts +++ b/packages/engine/src/config.test.ts @@ -7,6 +7,7 @@ import { scaleProtocolTimeoutForComposition, shouldClampToScreenshotForConcreteGpu, applyConcreteGpuScreenshotClamp, + shouldAutoDisableStreamingEncodeOnWin32Compound, } from "./config.js"; import type { EngineConfig } from "./config.js"; import { isLowMemorySystem } from "./services/systemMemory.js"; @@ -518,6 +519,216 @@ describe("resolveConfig", () => { }); }); + describe("shouldAutoDisableStreamingEncodeOnWin32Compound (helper)", () => { + // Baseline: field-signal compound — win32 + software-GPU forced + workers=1, + // duration unknown, user hasn't touched the env / overrides. + const compound = { + platform: "win32" as NodeJS.Platform, + softwareGpuForced: true, + workers: 1, + compositionDurationSec: undefined as number | undefined, + userExplicitlySet: false, + }; + + it("triggers on the field-signal compound (win32 + software-GPU + workers=1)", () => { + expect(shouldAutoDisableStreamingEncodeOnWin32Compound(compound)).toBe(true); + }); + + it("does NOT trigger on linux or darwin (platform gate)", () => { + expect( + shouldAutoDisableStreamingEncodeOnWin32Compound({ ...compound, platform: "linux" }), + ).toBe(false); + expect( + shouldAutoDisableStreamingEncodeOnWin32Compound({ ...compound, platform: "darwin" }), + ).toBe(false); + }); + + it("does NOT trigger without software-GPU forced (bypass on hardware paths)", () => { + expect( + shouldAutoDisableStreamingEncodeOnWin32Compound({ ...compound, softwareGpuForced: false }), + ).toBe(false); + }); + + it("does NOT trigger with parallel workers (workers > 1 has a different failure surface)", () => { + expect(shouldAutoDisableStreamingEncodeOnWin32Compound({ ...compound, workers: 2 })).toBe( + false, + ); + expect(shouldAutoDisableStreamingEncodeOnWin32Compound({ ...compound, workers: 4 })).toBe( + false, + ); + }); + + it("does NOT trigger when the user explicitly set enableStreamingEncode (escape hatch)", () => { + expect( + shouldAutoDisableStreamingEncodeOnWin32Compound({ ...compound, userExplicitlySet: true }), + ).toBe(false); + }); + + it("does NOT trigger for short (<=120s) compositions when duration is known", () => { + // 120s boundary is off (edge) + expect( + shouldAutoDisableStreamingEncodeOnWin32Compound({ + ...compound, + compositionDurationSec: 120, + }), + ).toBe(false); + // 60s — clearly short, off + expect( + shouldAutoDisableStreamingEncodeOnWin32Compound({ + ...compound, + compositionDurationSec: 60, + }), + ).toBe(false); + }); + + it("triggers when duration is known and exceeds 120s (heavy Windows composition)", () => { + // 121s — just past the boundary, on + expect( + shouldAutoDisableStreamingEncodeOnWin32Compound({ + ...compound, + compositionDurationSec: 121, + }), + ).toBe(true); + // 156s — matches the field signal, on + expect( + shouldAutoDisableStreamingEncodeOnWin32Compound({ + ...compound, + compositionDurationSec: 156, + }), + ).toBe(true); + }); + + it("triggers when duration is undefined (config-layer wire-up reduces to 3-cond)", () => { + // resolveConfig can't see composition duration at config time; the + // three-condition compound is conservative on its own. + expect( + shouldAutoDisableStreamingEncodeOnWin32Compound({ + ...compound, + compositionDurationSec: undefined, + }), + ).toBe(true); + }); + }); + + describe("enableStreamingEncode (Windows compound auto-disable wire-up)", () => { + const originalPlatform = process.platform; + + function setPlatform(platform: NodeJS.Platform) { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + } + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + }); + + it("auto-disables on win32 + software-GPU + workers=1 + no user opt-in", () => { + setPlatform("win32"); + setEnv("PRODUCER_BROWSER_GPU_MODE", "software"); + setEnv("PRODUCER_MAX_WORKERS", "1"); + setEnv("PRODUCER_LOW_MEMORY_MODE", "true"); + unsetEnv("PRODUCER_ENABLE_STREAMING_ENCODE"); + + const config = resolveConfig(); + expect(config.enableStreamingEncode).toBe(false); + expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBe(true); + }); + + it("leaves streaming-encode on when platform is linux", () => { + setPlatform("linux"); + setEnv("PRODUCER_BROWSER_GPU_MODE", "software"); + setEnv("PRODUCER_MAX_WORKERS", "1"); + setEnv("PRODUCER_LOW_MEMORY_MODE", "true"); + unsetEnv("PRODUCER_ENABLE_STREAMING_ENCODE"); + + const config = resolveConfig(); + expect(config.enableStreamingEncode).toBe(true); + expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBeUndefined(); + }); + + it("leaves streaming-encode on when workers > 1", () => { + setPlatform("win32"); + setEnv("PRODUCER_BROWSER_GPU_MODE", "software"); + setEnv("PRODUCER_MAX_WORKERS", "4"); + setEnv("PRODUCER_LOW_MEMORY_MODE", "true"); + unsetEnv("PRODUCER_ENABLE_STREAMING_ENCODE"); + + const config = resolveConfig(); + expect(config.enableStreamingEncode).toBe(true); + expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBeUndefined(); + }); + + it("respects explicit env opt-in (PRODUCER_ENABLE_STREAMING_ENCODE=true) on the compound", () => { + setPlatform("win32"); + setEnv("PRODUCER_BROWSER_GPU_MODE", "software"); + setEnv("PRODUCER_MAX_WORKERS", "1"); + setEnv("PRODUCER_LOW_MEMORY_MODE", "true"); + setEnv("PRODUCER_ENABLE_STREAMING_ENCODE", "true"); + + const config = resolveConfig(); + expect(config.enableStreamingEncode).toBe(true); + expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBeUndefined(); + }); + + it("respects programmatic override enableStreamingEncode=true on the compound", () => { + setPlatform("win32"); + setEnv("PRODUCER_BROWSER_GPU_MODE", "software"); + setEnv("PRODUCER_MAX_WORKERS", "1"); + setEnv("PRODUCER_LOW_MEMORY_MODE", "true"); + unsetEnv("PRODUCER_ENABLE_STREAMING_ENCODE"); + + const config = resolveConfig({ enableStreamingEncode: true }); + expect(config.enableStreamingEncode).toBe(true); + expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBeUndefined(); + }); + + it("triggers via disableGpu on win32 + workers=1 (browserGpuMode may still be 'auto')", () => { + // --disable-gpu path: browserGpuMode may not be literal "software" but + // Chrome is still routed to CPU raster. Field-signal compound applies. + setPlatform("win32"); + unsetEnv("PRODUCER_BROWSER_GPU_MODE"); + setEnv("PRODUCER_DISABLE_GPU", "true"); + setEnv("PRODUCER_MAX_WORKERS", "1"); + unsetEnv("PRODUCER_LOW_MEMORY_MODE"); + unsetEnv("PRODUCER_ENABLE_STREAMING_ENCODE"); + + const config = resolveConfig(); + expect(config.enableStreamingEncode).toBe(false); + expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBe(true); + }); + + it("triggers via lowMemoryMode alone on win32 + workers=1 (screenshot capture implied)", () => { + // --low-memory-mode implies screenshot capture. Compound applies even + // if browserGpuMode is not literal "software" (defense-in-depth: matches + // the OR semantics in `softwareGpuForced`). + setPlatform("win32"); + unsetEnv("PRODUCER_BROWSER_GPU_MODE"); + unsetEnv("PRODUCER_DISABLE_GPU"); + setEnv("PRODUCER_MAX_WORKERS", "1"); + setEnv("PRODUCER_LOW_MEMORY_MODE", "true"); + unsetEnv("PRODUCER_ENABLE_STREAMING_ENCODE"); + + const config = resolveConfig(); + expect(config.enableStreamingEncode).toBe(false); + expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBe(true); + }); + + it("does not trigger when concurrency is 'auto' (workers not explicitly pinned)", () => { + // Config-layer sees `concurrency === "auto"`, not a number — the + // helper's numeric workers check treats NaN as "unknown, don't trigger". + // Downstream workers may still resolve to 1 via lowMemoryMode, but the + // config-time clamp is deliberately conservative. + setPlatform("win32"); + setEnv("PRODUCER_BROWSER_GPU_MODE", "software"); + unsetEnv("PRODUCER_MAX_WORKERS"); + setEnv("PRODUCER_LOW_MEMORY_MODE", "true"); + unsetEnv("PRODUCER_ENABLE_STREAMING_ENCODE"); + + const config = resolveConfig(); + expect(config.enableStreamingEncode).toBe(true); + expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBeUndefined(); + }); + }); + describe("lowMemoryMode", () => { it("forces on for truthy PRODUCER_LOW_MEMORY_MODE values", () => { setEnv("PRODUCER_LOW_MEMORY_MODE", "true"); diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index bb12af59a..b4e069175 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -150,6 +150,15 @@ export interface EngineConfig { enableChunkedEncode: boolean; chunkSizeFrames: number; enableStreamingEncode: boolean; + /** + * INTERNAL. Set by `resolveConfig` when the Windows software-GPU compound + * heuristic (`shouldAutoDisableStreamingEncodeOnWin32Compound`) turned + * `enableStreamingEncode` off on the caller's behalf. Not intended to be + * set by callers; surfaces the auto-decision for downstream observability + * (log lines, telemetry) so operators can tell an auto-disable apart from + * an explicit user opt-out. + */ + streamingEncodeAutoDisabledOnWin32Compound?: boolean; /** * Max composition duration eligible for streaming encode (seconds). * Mirrors GSAP rendering's 4-minute streaming guard: production has seen @@ -355,6 +364,62 @@ export function scaleProtocolTimeoutForComposition( return Math.min(ceiling, Math.max(baseTimeoutMs, scaled)); } +/** + * Auto-disable `enableStreamingEncode` on Windows software-GPU compound. + * + * Field signal (`ts=1784131903`, win32/x64, CLI 0.7.58, 156s UI-heavy + * composition): the render was stable ONLY with FOUR flags together — + * `--workers 1 --no-browser-gpu --low-memory-mode` + explicit + * `PRODUCER_ENABLE_STREAMING_ENCODE=false`. Every recent Windows-related + * fix (#2359, #2245, #2298, #2331) already shipped in 0.7.58; the residual + * failure is screenshot streaming-encode via CDP `Page.captureScreenshot` + * on Windows even after software fallback. Since `--low-memory-mode` and + * `--no-browser-gpu` already imply screenshot capture, three of the four + * flags are structurally coupled — auto-detect the compound and disable + * streaming-encode automatically so callers don't have to memorize the + * four-flag combination. + * + * Conservative gates (all must hold): + * 1. `platform === "win32"` — the failure is Windows-specific to CDP's + * screenshot streaming path. + * 2. `softwareGpuForced` — the render is already on the SwiftShader / + * forced-screenshot path (from `--no-browser-gpu`, `disableGpu`, or + * `--low-memory-mode` implying screenshot capture). + * 3. `workers === 1` — the field signal reproduces on single-worker + * captures; parallel workers have a different failure surface + * (missing media frames) already handled by the worker-count route. + * 4. Composition duration >120s WHEN KNOWN. When unknown at the config + * layer (composition duration is parsed downstream), the guard + * reduces to the three-condition compound. Trade-off documented in + * the PR body: false positives possible for short (~<120s) Windows + * software-GPU single-worker renders. Mitigation: the explicit + * opt-in escape hatch (`PRODUCER_ENABLE_STREAMING_ENCODE=true` or + * `overrides.enableStreamingEncode !== undefined`) always wins. + * + * Pure function; exported for tests. + */ +export function shouldAutoDisableStreamingEncodeOnWin32Compound(opts: { + platform: NodeJS.Platform; + softwareGpuForced: boolean; + workers: number; + compositionDurationSec: number | undefined; + userExplicitlySet: boolean; +}): boolean { + if (opts.userExplicitlySet) return false; + if (opts.platform !== "win32") return false; + if (!opts.softwareGpuForced) return false; + // Strict equality: NaN (concurrency: "auto") and fractional / zero worker + // counts do NOT match. The field-signal compound is `--workers 1`. + if (opts.workers !== 1) return false; + // Duration boundary: when known, only auto-disable if >120s (avoid + // over-triggering on short renders). When unknown, skip this check — + // the three conditions above are already conservative on their own. + if (opts.compositionDurationSec !== undefined && opts.compositionDurationSec <= 120) { + return false; + } + return true; +} + function memoryAdaptiveCacheLimit(): number { const total = getSystemTotalMb(); if (total < 4096) return 32; @@ -608,6 +673,52 @@ export function resolveConfig(overrides?: Partial): EngineConfig { merged.forceScreenshot = true; } + // Windows software-GPU compound auto-disable for streaming-encode. + // + // Field signal ts=1784131903 (win32/x64, CLI 0.7.58, 156s UI-heavy): + // stable ONLY with FOUR flags — `--workers 1 --no-browser-gpu + // --low-memory-mode` + `PRODUCER_ENABLE_STREAMING_ENCODE=false`. Since + // `--no-browser-gpu` and `--low-memory-mode` already imply screenshot + // capture, three of the four flags are structurally coupled: auto-detect + // the compound and disable streaming-encode on the caller's behalf. + // + // Explicit user intent wins: if `PRODUCER_ENABLE_STREAMING_ENCODE` env + // is set to any value OR the caller passed `overrides.enableStreamingEncode`, + // this clamp is a no-op (the user's explicit choice — including + // `PRODUCER_ENABLE_STREAMING_ENCODE=true` — is preserved). + // + // Composition duration is not known at the config-resolution layer + // (the composition is parsed downstream of `resolveConfig`), so this + // wire-up passes `compositionDurationSec: undefined` and the helper + // reduces to the three-condition compound. Trade-off documented in the + // helper's JSDoc and the PR body: false positives possible for short + // Windows software-GPU single-worker renders; the explicit opt-in + // escape hatch is the mitigation. + const streamingEncodeUserExplicitlySet = + env("PRODUCER_ENABLE_STREAMING_ENCODE") !== undefined || + overrides?.enableStreamingEncode !== undefined; + const softwareGpuForced = + merged.browserGpuMode === "software" || merged.disableGpu || merged.lowMemoryMode; + const resolvedWorkers = typeof merged.concurrency === "number" ? merged.concurrency : NaN; + if ( + merged.enableStreamingEncode && + shouldAutoDisableStreamingEncodeOnWin32Compound({ + platform: process.platform, + softwareGpuForced, + workers: resolvedWorkers, + compositionDurationSec: undefined, + userExplicitlySet: streamingEncodeUserExplicitlySet, + }) + ) { + merged.enableStreamingEncode = false; + merged.streamingEncodeAutoDisabledOnWin32Compound = true; + console.error( + "[hyperframes] Windows compound-workaround auto-detected — disabling streaming-encode " + + "(platform=win32, software-GPU forced, workers=1). Field signal ts=1784131903. " + + "Override: PRODUCER_ENABLE_STREAMING_ENCODE=true.", + ); + } + // drawElement capture and page-side shader compositing are mutually // incompatible capture strategies (drawElement reads paint records directly // and bypasses the page-side prepare→composite→resolve protocol). When