mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
fix(engine): distinguish probe failure from a genuinely absent GPU
A probe that could not run is no evidence about the GPU, so pointing the operator at GPU passthrough hid broken Chrome installs behind a phantom problem. Carry a cause off the probe and emit the matching remediation. Also un-exports buildUnverifiedHardwareGpuWarning (Fallow: engine test files are not audit entry points, so a test-only import would not have counted as a consumer) and covers the non-linux branch via the spy.
This commit is contained in:
@@ -275,6 +275,34 @@ describe("resolveBrowserGpuMode", () => {
|
|||||||
expect(warn).not.toHaveBeenCalled();
|
expect(warn).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives non-linux hosts the generic remediation, not the Docker one", async () => {
|
||||||
|
setMockWebGlProbe({
|
||||||
|
hasWebGL: true,
|
||||||
|
vendor: "Google Inc. (Google)",
|
||||||
|
renderer: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))",
|
||||||
|
});
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
expect(await resolveBrowserGpuMode("hardware", { platform: "darwin" })).toBe("hardware");
|
||||||
|
const warning = String(warn.mock.calls[0]?.[0]);
|
||||||
|
expect(warning).toContain("host exposes a GPU");
|
||||||
|
expect(warning).not.toContain("--gpus all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not blame the GPU when the probe itself failed to launch", async () => {
|
||||||
|
// A probe that could not run is NO evidence about the GPU. Sending this
|
||||||
|
// operator to `--gpus all` would hide a broken Chrome install behind a
|
||||||
|
// phantom passthrough problem.
|
||||||
|
_setPuppeteerForTests({
|
||||||
|
launch: vi.fn().mockRejectedValue(new Error("spawn ENOENT /bad/chrome")),
|
||||||
|
} as unknown as PuppeteerNode);
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
expect(await resolveBrowserGpuMode("hardware", { platform: "linux" })).toBe("hardware");
|
||||||
|
const warning = String(warn.mock.calls[0]?.[0]);
|
||||||
|
expect(warning).toContain("GPU probe could not run");
|
||||||
|
expect(warning).toContain("hyperframes doctor");
|
||||||
|
expect(warning).not.toContain("--gpus all");
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to 'software' when the probe browser cannot launch", async () => {
|
it("falls back to 'software' when the probe browser cannot launch", async () => {
|
||||||
// No chromePath, env unset, and (in the test env) no system Chrome to find
|
// No chromePath, env unset, and (in the test env) no system Chrome to find
|
||||||
// → puppeteer.launch will throw → caller catches → software fallback.
|
// → puppeteer.launch will throw → caller catches → software fallback.
|
||||||
@@ -306,27 +334,40 @@ describe("resolveBrowserGpuMode", () => {
|
|||||||
expect(third).toBe("hardware");
|
expect(third).toBe("hardware");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deduplicates concurrent auto-mode probes by caching the in-flight Promise", async () => {
|
it("deduplicates concurrent probes so only one Chrome launches", async () => {
|
||||||
// Parallel coordinator fires N workers via Promise.all — without Promise-
|
// Parallel coordinator fires N workers via Promise.all — without Promise-
|
||||||
// level caching, a `--workers 4` render against a no-GPU host would launch
|
// level caching, a `--workers 4` render against a no-GPU host would launch
|
||||||
// 4 simultaneous probe Chromes. Verify all concurrent callers get the
|
// 4 simultaneous probe Chromes. Assert the launch count directly rather
|
||||||
// exact same Promise reference (proving the probe runs once, not N times).
|
// than Promise identity: `"auto"` and `"hardware"` now each adapt the
|
||||||
const p1 = resolveBrowserGpuMode("auto", {
|
// shared cached Promise via `.then`, so identity is no longer the
|
||||||
chromePath: "/definitely/not/a/real/chrome/binary",
|
// invariant — "the probe browser starts exactly once" is.
|
||||||
browserTimeout: 2000,
|
const { launch } = setMockWebGlProbe({
|
||||||
|
hasWebGL: true,
|
||||||
|
vendor: "Google Inc. (Google)",
|
||||||
|
renderer: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))",
|
||||||
});
|
});
|
||||||
const p2 = resolveBrowserGpuMode("auto", {
|
const results = await Promise.all([
|
||||||
chromePath: "/definitely/not/a/real/chrome/binary",
|
resolveBrowserGpuMode("auto", { browserTimeout: 2000 }),
|
||||||
browserTimeout: 2000,
|
resolveBrowserGpuMode("auto", { browserTimeout: 2000 }),
|
||||||
});
|
resolveBrowserGpuMode("auto", { browserTimeout: 2000 }),
|
||||||
const p3 = resolveBrowserGpuMode("auto", {
|
]);
|
||||||
chromePath: "/definitely/not/a/real/chrome/binary",
|
|
||||||
browserTimeout: 2000,
|
|
||||||
});
|
|
||||||
expect(p1).toBe(p2);
|
|
||||||
expect(p2).toBe(p3);
|
|
||||||
const results = await Promise.all([p1, p2, p3]);
|
|
||||||
expect(results).toEqual(["software", "software", "software"]);
|
expect(results).toEqual(["software", "software", "software"]);
|
||||||
|
expect(launch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares the one probe across mixed 'auto' and 'hardware' callers", async () => {
|
||||||
|
const { launch } = setMockWebGlProbe({
|
||||||
|
hasWebGL: true,
|
||||||
|
vendor: "NVIDIA",
|
||||||
|
renderer: "NVIDIA GeForce RTX 3070",
|
||||||
|
});
|
||||||
|
const results = await Promise.all([
|
||||||
|
resolveBrowserGpuMode("auto"),
|
||||||
|
resolveBrowserGpuMode("hardware"),
|
||||||
|
resolveBrowserGpuMode("auto"),
|
||||||
|
]);
|
||||||
|
expect(results).toEqual(["hardware", "hardware", "hardware"]);
|
||||||
|
expect(launch).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -422,7 +422,25 @@ export const _probeBeginFrameSupportForTests = probeBeginFrameSupport;
|
|||||||
export const _closeBrowserAfterFailedProbeForTests = closeBrowserAfterFailedProbe;
|
export const _closeBrowserAfterFailedProbeForTests = closeBrowserAfterFailedProbe;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cached *in-flight or resolved* probe Promise for `resolveBrowserGpuMode("auto", ...)`.
|
* Outcome of the one-shot WebGL probe.
|
||||||
|
*
|
||||||
|
* `cause` distinguishes the two ways a probe lands on `"software"`, because
|
||||||
|
* they need OPPOSITE remediation:
|
||||||
|
* - `"no-gpu"` — the probe ran and Chrome reported a software renderer
|
||||||
|
* (SwiftShader / llvmpipe). Remediation: GPU passthrough.
|
||||||
|
* - `"probe-error"` — the probe itself failed (Chrome couldn't launch, bad
|
||||||
|
* executable path, sandbox denied). We have NO evidence
|
||||||
|
* about the GPU either way; telling the operator to fix GPU
|
||||||
|
* passthrough would send them chasing the wrong problem.
|
||||||
|
*/
|
||||||
|
interface GpuProbeOutcome {
|
||||||
|
mode: "software" | "hardware";
|
||||||
|
cause?: "no-gpu" | "probe-error";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached *in-flight or resolved* probe Promise, shared by BOTH the `"auto"`
|
||||||
|
* and explicit `"hardware"` entry points of `resolveBrowserGpuMode`.
|
||||||
*
|
*
|
||||||
* Caching the Promise (rather than the resolved value) deduplicates concurrent
|
* Caching the Promise (rather than the resolved value) deduplicates concurrent
|
||||||
* callers — the parallel coordinator runs N workers via `Promise.all`, so a
|
* callers — the parallel coordinator runs N workers via `Promise.all`, so a
|
||||||
@@ -430,10 +448,8 @@ export const _closeBrowserAfterFailedProbeForTests = closeBrowserAfterFailedProb
|
|||||||
* simultaneous probe Chromes. The first call assigns the Promise and every
|
* simultaneous probe Chromes. The first call assigns the Promise and every
|
||||||
* other concurrent caller awaits the same one, paying the ~240 ms probe cost
|
* other concurrent caller awaits the same one, paying the ~240 ms probe cost
|
||||||
* exactly once per process lifetime.
|
* exactly once per process lifetime.
|
||||||
*
|
|
||||||
* Exported for tests; production callers go through `resolveBrowserGpuMode`.
|
|
||||||
*/
|
*/
|
||||||
let _autoBrowserGpuModeCache: Promise<"software" | "hardware"> | undefined;
|
let _autoBrowserGpuModeCache: Promise<GpuProbeOutcome> | undefined;
|
||||||
|
|
||||||
/** Test-only: reset the cached probe result. */
|
/** Test-only: reset the cached probe result. */
|
||||||
export function _resetAutoBrowserGpuModeCacheForTests(): void {
|
export function _resetAutoBrowserGpuModeCacheForTests(): void {
|
||||||
@@ -479,7 +495,7 @@ async function probeAutoBrowserGpuMode(options: {
|
|||||||
chromePath?: string;
|
chromePath?: string;
|
||||||
browserTimeout?: number;
|
browserTimeout?: number;
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
}): Promise<"software" | "hardware"> {
|
}): Promise<GpuProbeOutcome> {
|
||||||
const platform = options.platform ?? process.platform;
|
const platform = options.platform ?? process.platform;
|
||||||
const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout;
|
const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout;
|
||||||
const executablePath = options.chromePath ?? resolveHeadlessShellPath({});
|
const executablePath = options.chromePath ?? resolveHeadlessShellPath({});
|
||||||
@@ -487,7 +503,7 @@ async function probeAutoBrowserGpuMode(options: {
|
|||||||
|
|
||||||
if (ppt === null) {
|
if (ppt === null) {
|
||||||
logResolvedBrowserGpuMode("software", "puppeteer unavailable");
|
logResolvedBrowserGpuMode("software", "puppeteer unavailable");
|
||||||
return "software";
|
return { mode: "software", cause: "probe-error" };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -498,10 +514,10 @@ async function probeAutoBrowserGpuMode(options: {
|
|||||||
});
|
});
|
||||||
const resolved = resolveWebGlProbeMode(info);
|
const resolved = resolveWebGlProbeMode(info);
|
||||||
logResolvedBrowserGpuMode(resolved, describeWebGlProbe(info));
|
logResolvedBrowserGpuMode(resolved, describeWebGlProbe(info));
|
||||||
return resolved;
|
return resolved === "hardware" ? { mode: "hardware" } : { mode: "software", cause: "no-gpu" };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logResolvedBrowserGpuMode("software", formatProbeFailure(err));
|
logResolvedBrowserGpuMode("software", formatProbeFailure(err));
|
||||||
return "software";
|
return { mode: "software", cause: "probe-error" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,32 +558,53 @@ export function resolveBrowserGpuMode(
|
|||||||
if (mode === "software") return Promise.resolve(mode);
|
if (mode === "software") return Promise.resolve(mode);
|
||||||
|
|
||||||
_autoBrowserGpuModeCache ??= probeAutoBrowserGpuMode(options);
|
_autoBrowserGpuModeCache ??= probeAutoBrowserGpuMode(options);
|
||||||
if (mode === "auto") return _autoBrowserGpuModeCache;
|
if (mode === "auto") return _autoBrowserGpuModeCache.then((probed) => probed.mode);
|
||||||
|
|
||||||
return _autoBrowserGpuModeCache.then((probed) => {
|
return _autoBrowserGpuModeCache.then((probed) => {
|
||||||
// Warn once per process, not once per caller: `createCaptureSession`
|
// Warn once per cache lifetime, not once per caller: `createCaptureSession`
|
||||||
// resolves the mode for the probe browser AND every parallel worker, so
|
// resolves the mode for the probe browser AND every parallel worker, so
|
||||||
// an un-deduplicated warning prints N+1 times and buries itself.
|
// an un-deduplicated warning prints N+1 times and buries itself.
|
||||||
if (probed === "software" && !_unverifiedHardwareGpuWarned) {
|
if (probed.mode === "software" && !_unverifiedHardwareGpuWarned) {
|
||||||
_unverifiedHardwareGpuWarned = true;
|
_unverifiedHardwareGpuWarned = true;
|
||||||
console.warn(buildUnverifiedHardwareGpuWarning(options.platform ?? process.platform));
|
console.warn(
|
||||||
|
buildUnverifiedHardwareGpuWarning(options.platform ?? process.platform, probed.cause),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return "hardware";
|
return "hardware";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One-shot latch for the explicit-hardware-probed-to-software warning. */
|
/**
|
||||||
|
* Latch for the explicit-hardware-probed-to-software warning: fires once per
|
||||||
|
* cache lifetime (re-armed by `_resetAutoBrowserGpuModeCacheForTests`).
|
||||||
|
*/
|
||||||
let _unverifiedHardwareGpuWarned = false;
|
let _unverifiedHardwareGpuWarned = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Warning text for "you asked for hardware GPU, the probe found none".
|
* Warning text for "you asked for hardware GPU and we could not confirm it".
|
||||||
*
|
*
|
||||||
* Names the observable symptom (the render still completes, just on CPU) and
|
* Splits on `cause` because the two failure shapes need opposite remediation.
|
||||||
* the platform's actual remediation, so the operator doesn't have to infer it
|
* A probe that RAN and saw SwiftShader is a GPU-passthrough problem. A probe
|
||||||
* from Chrome's `Automatic fallback to software WebGL` warning. Exported for
|
* that could not run tells us nothing about the GPU — pointing that operator
|
||||||
* tests.
|
* at `--gpus all` would send them chasing a phantom while their Chrome
|
||||||
|
* install is the actual fault.
|
||||||
*/
|
*/
|
||||||
export function buildUnverifiedHardwareGpuWarning(platform: NodeJS.Platform | string): string {
|
function buildUnverifiedHardwareGpuWarning(
|
||||||
|
platform: NodeJS.Platform | string,
|
||||||
|
cause: GpuProbeOutcome["cause"],
|
||||||
|
): string {
|
||||||
|
if (cause === "probe-error") {
|
||||||
|
return (
|
||||||
|
"[hyperframes] browserGpuMode=hardware was requested, but the GPU probe could not run, " +
|
||||||
|
"so hardware acceleration is UNVERIFIED — if Chrome falls back to software WebGL the " +
|
||||||
|
"capture will run at CPU speed. Honouring the explicit request anyway.\n" +
|
||||||
|
" This is a probe failure, not evidence of a missing GPU: see the " +
|
||||||
|
"`browserGpuMode probe → software (probe failed ...)` line above for the underlying " +
|
||||||
|
"error, which usually means Chrome could not launch (bad HYPERFRAMES_BROWSER_PATH, " +
|
||||||
|
"missing shared libraries, or a denied sandbox) rather than a GPU problem.\n" +
|
||||||
|
" Run `hyperframes doctor` to check the Chrome install."
|
||||||
|
);
|
||||||
|
}
|
||||||
const remediation =
|
const remediation =
|
||||||
platform === "linux"
|
platform === "linux"
|
||||||
? "Inside Docker, the container needs GPU passthrough: `--gpus all` with the NVIDIA " +
|
? "Inside Docker, the container needs GPU passthrough: `--gpus all` with the NVIDIA " +
|
||||||
|
|||||||
Reference in New Issue
Block a user