mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(engine): verify WebGPU device creation before capture (#6343)
This commit is contained in:
@@ -68,6 +68,19 @@ try {
|
||||
Use `captureFrameToBuffer()` when the next stage needs an in-memory buffer
|
||||
instead of a file.
|
||||
|
||||
## WebGPU requirements
|
||||
|
||||
A composition marked `data-requires-webgpu` must be able to create a WebGPU
|
||||
device before capture starts. Finding an adapter alone is insufficient: device
|
||||
creation can still fail because of the browser or graphics driver. The adapter
|
||||
and device check share a ten-second timeout, and failure stops capture with
|
||||
`WebGpuUnavailableError`.
|
||||
|
||||
Hardware adapters are required by default. A launch explicitly configured for
|
||||
software WebGPU may use SwiftShader, but it must also create a device. The check
|
||||
releases its temporary device; the composition still creates its own device
|
||||
with any features or limits its renderer needs.
|
||||
|
||||
## What else the package exposes
|
||||
|
||||
| Surface | Use it for |
|
||||
|
||||
@@ -380,12 +380,13 @@ describe("assertWebGpuAdapterAvailable", () => {
|
||||
const pageWithAdapter = (hasAdapter: boolean) =>
|
||||
({ evaluate: vi.fn().mockResolvedValue(hasAdapter) }) as unknown as Page;
|
||||
|
||||
it("no-ops for a composition that does not require WebGPU, regardless of adapter", () => {
|
||||
it("no-ops for a composition that does not require WebGPU, regardless of adapter", async () => {
|
||||
const page = pageWithAdapter(false);
|
||||
return expect(assertWebGpuAdapterAvailable(page, false)).resolves.toBeUndefined();
|
||||
await expect(assertWebGpuAdapterAvailable(page, false)).resolves.toBeUndefined();
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves when a WebGPU adapter is obtainable", () => {
|
||||
it("resolves when the page confirms a usable WebGPU device", () => {
|
||||
const page = pageWithAdapter(true);
|
||||
return expect(assertWebGpuAdapterAvailable(page, true)).resolves.toBeUndefined();
|
||||
});
|
||||
@@ -395,32 +396,54 @@ describe("assertWebGpuAdapterAvailable", () => {
|
||||
await expect(assertWebGpuAdapterAvailable(page, true)).rejects.toThrow("data-requires-webgpu");
|
||||
});
|
||||
|
||||
it("preserves page evaluation failures separately from device unavailability", async () => {
|
||||
const page = pageWithAdapter(false);
|
||||
const failure = new Error("Execution context was destroyed");
|
||||
vi.mocked(page.evaluate).mockRejectedValue(failure);
|
||||
await expect(assertWebGpuAdapterAvailable(page, true)).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
describe("in-page adapter check (real callback, stubbed navigator.gpu)", () => {
|
||||
const runInPage = {
|
||||
evaluate: (fn: (...args: unknown[]) => unknown, ...args: unknown[]) => fn(...args),
|
||||
} as unknown as Page;
|
||||
const stubAdapter = (adapter: unknown) =>
|
||||
vi.stubGlobal("navigator", { gpu: { requestAdapter: async () => adapter } });
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
const workingAdapter = (fallback = false) => ({
|
||||
info: { isFallbackAdapter: fallback },
|
||||
requestDevice: vi.fn().mockResolvedValue({ destroy: vi.fn() }),
|
||||
});
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("accepts a hardware adapter", async () => {
|
||||
stubAdapter({ info: { isFallbackAdapter: false } });
|
||||
stubAdapter(workingAdapter());
|
||||
await expect(assertWebGpuAdapterAvailable(runInPage, true)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a software fallback adapter such as swiftshader", async () => {
|
||||
stubAdapter({ info: { isFallbackAdapter: true } });
|
||||
const adapter = workingAdapter(true);
|
||||
stubAdapter(adapter);
|
||||
await expect(assertWebGpuAdapterAvailable(runInPage, true)).rejects.toBeInstanceOf(
|
||||
WebGpuUnavailableError,
|
||||
);
|
||||
expect(adapter.requestDevice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a software fallback adapter only on a launch that runs software WebGPU", async () => {
|
||||
stubAdapter({ info: { isFallbackAdapter: true } });
|
||||
const adapter = workingAdapter(true);
|
||||
const destroy = vi.fn();
|
||||
adapter.requestDevice.mockResolvedValue({ destroy });
|
||||
stubAdapter(adapter);
|
||||
await expect(assertWebGpuAdapterAvailable(runInPage, true, true)).resolves.toBeUndefined();
|
||||
await expect(assertWebGpuAdapterAvailable(runInPage, true, false)).rejects.toBeInstanceOf(
|
||||
WebGpuUnavailableError,
|
||||
);
|
||||
expect(adapter.requestDevice).toHaveBeenCalledExactlyOnceWith();
|
||||
expect(destroy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("refuses a host with no navigator.gpu", async () => {
|
||||
@@ -429,6 +452,121 @@ describe("assertWebGpuAdapterAvailable", () => {
|
||||
WebGpuUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a missing adapter and clears its timeout", async () => {
|
||||
stubAdapter(null);
|
||||
await expect(assertWebGpuAdapterAvailable(runInPage, true)).rejects.toBeInstanceOf(
|
||||
WebGpuUnavailableError,
|
||||
);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("creates a descriptor-free device, destroys it, and clears its timeout", async () => {
|
||||
const destroy = vi.fn();
|
||||
const adapter = workingAdapter();
|
||||
adapter.requestDevice.mockResolvedValue({ destroy });
|
||||
stubAdapter(adapter);
|
||||
|
||||
await expect(assertWebGpuAdapterAvailable(runInPage, true)).resolves.toBeUndefined();
|
||||
|
||||
expect(adapter.requestDevice).toHaveBeenCalledExactlyOnceWith();
|
||||
expect(destroy).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([false, true])("rejects a device-creation failure (software: %s)", async (software) => {
|
||||
const adapter = workingAdapter(software);
|
||||
adapter.requestDevice.mockRejectedValue(new Error("OperationError: device unavailable"));
|
||||
stubAdapter(adapter);
|
||||
|
||||
await expect(assertWebGpuAdapterAvailable(runInPage, true, software)).rejects.toBeInstanceOf(
|
||||
WebGpuUnavailableError,
|
||||
);
|
||||
expect(adapter.requestDevice).toHaveBeenCalledExactlyOnceWith();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a synchronous device-creation failure and clears its timeout", async () => {
|
||||
const adapter = workingAdapter();
|
||||
adapter.requestDevice.mockImplementation(() => {
|
||||
throw new Error("device creation failed");
|
||||
});
|
||||
stubAdapter(adapter);
|
||||
await expect(assertWebGpuAdapterAvailable(runInPage, true)).rejects.toBeInstanceOf(
|
||||
WebGpuUnavailableError,
|
||||
);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("shares the ten-second budget across adapter and device creation", async () => {
|
||||
const adapter = workingAdapter();
|
||||
adapter.requestDevice.mockImplementation(() => new Promise(() => {}));
|
||||
vi.stubGlobal("navigator", {
|
||||
gpu: {
|
||||
requestAdapter: () => new Promise((resolve) => setTimeout(() => resolve(adapter), 9_000)),
|
||||
},
|
||||
});
|
||||
const result = assertWebGpuAdapterAvailable(runInPage, true).catch((error: unknown) => error);
|
||||
await vi.advanceTimersByTimeAsync(9_000);
|
||||
expect(adapter.requestDevice).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(await result).toBeInstanceOf(WebGpuUnavailableError);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("bounds an adapter request that never completes", async () => {
|
||||
vi.stubGlobal("navigator", { gpu: { requestAdapter: () => new Promise(() => {}) } });
|
||||
const result = assertWebGpuAdapterAvailable(runInPage, true).catch((error: unknown) => error);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(await result).toBeInstanceOf(WebGpuUnavailableError);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not create a device when the adapter arrives after the deadline", async () => {
|
||||
const adapter = workingAdapter();
|
||||
vi.stubGlobal("navigator", {
|
||||
gpu: {
|
||||
requestAdapter: () =>
|
||||
new Promise((resolve) => setTimeout(() => resolve(adapter), 11_000)),
|
||||
},
|
||||
});
|
||||
const result = assertWebGpuAdapterAvailable(runInPage, true).catch((error: unknown) => error);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(await result).toBeInstanceOf(WebGpuUnavailableError);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(adapter.requestDevice).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("destroys a device that resolves after the deadline", async () => {
|
||||
const destroy = vi.fn();
|
||||
const adapter = workingAdapter();
|
||||
adapter.requestDevice.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve({ destroy }), 11_000)),
|
||||
);
|
||||
stubAdapter(adapter);
|
||||
const result = assertWebGpuAdapterAvailable(runInPage, true).catch((error: unknown) => error);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(await result).toBeInstanceOf(WebGpuUnavailableError);
|
||||
expect(destroy).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(destroy).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("clears the timeout when the adapter request rejects", async () => {
|
||||
vi.stubGlobal("navigator", {
|
||||
gpu: {
|
||||
requestAdapter: async () => {
|
||||
throw new Error("adapter unavailable");
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(assertWebGpuAdapterAvailable(runInPage, true)).rejects.toBeInstanceOf(
|
||||
WebGpuUnavailableError,
|
||||
);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1182,12 +1182,12 @@ export function compositionRequiresWebGpu(html: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** No hardware WebGPU adapter on this host; distinct from a browser or navigation failure. */
|
||||
/** No usable WebGPU device on this launch; distinct from a browser or navigation failure. */
|
||||
export class WebGpuUnavailableError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"This composition declares data-requires-webgpu, but no hardware WebGPU adapter could be " +
|
||||
"obtained on this browser launch. Run on a host with a GPU, set " +
|
||||
"This composition declares data-requires-webgpu, but a usable WebGPU device could not be " +
|
||||
"created on this browser launch. Run on a host with a GPU, set " +
|
||||
"PRODUCER_ALLOW_SOFTWARE_WEBGPU=true to render it on SwiftShader (slow), or remove " +
|
||||
"data-requires-webgpu.",
|
||||
);
|
||||
@@ -1195,11 +1195,11 @@ export class WebGpuUnavailableError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const WEBGPU_ADAPTER_PROBE_TIMEOUT_MS = 10_000;
|
||||
const WEBGPU_DEVICE_PROBE_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Confirms the already-navigated page can obtain a hardware WebGPU adapter, or any adapter when the launch runs
|
||||
* software WebGPU ({@link usesSoftwareWebGpu}); no-op otherwise.
|
||||
* Confirms the already-navigated page can create a device on a hardware WebGPU adapter, or any adapter when
|
||||
* the launch runs software WebGPU ({@link usesSoftwareWebGpu}); no-op otherwise.
|
||||
* `page` must be on a secure-context origin, or navigator.gpu always reads absent.
|
||||
*/
|
||||
export async function assertWebGpuAdapterAvailable(
|
||||
@@ -1208,26 +1208,44 @@ export async function assertWebGpuAdapterAvailable(
|
||||
softwareWebGpu = false,
|
||||
): Promise<void> {
|
||||
if (!requiresWebGpu) return;
|
||||
// requestAdapter() has no native timeout; a broken driver can hang it
|
||||
// indefinitely. Race it in-page so a stuck adapter reads as "unavailable"
|
||||
// instead of hanging the caller.
|
||||
const hasUsableAdapter = await page.evaluate(
|
||||
// An adapter can exist even when missing driver libraries make device creation fail.
|
||||
// Both requests can hang; share one deadline and release late devices as well.
|
||||
const hasUsableDevice = await page.evaluate(
|
||||
async (timeoutMs, acceptFallback) => {
|
||||
if (typeof navigator === "undefined" || !navigator.gpu) return false;
|
||||
let expired = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const adapter = await Promise.race([
|
||||
navigator.gpu.requestAdapter(),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), timeoutMs)),
|
||||
const probe = async () => {
|
||||
const adapter = await navigator.gpu.requestAdapter();
|
||||
if (!adapter || expired || (!acceptFallback && adapter.info.isFallbackAdapter))
|
||||
return false;
|
||||
const device = await adapter.requestDevice();
|
||||
try {
|
||||
return !expired;
|
||||
} finally {
|
||||
device.destroy();
|
||||
}
|
||||
};
|
||||
return await Promise.race([
|
||||
probe(),
|
||||
new Promise<false>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
expired = true;
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
return !!adapter && (acceptFallback || !adapter.info.isFallbackAdapter);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
WEBGPU_ADAPTER_PROBE_TIMEOUT_MS,
|
||||
WEBGPU_DEVICE_PROBE_TIMEOUT_MS,
|
||||
softwareWebGpu,
|
||||
);
|
||||
if (!hasUsableAdapter) throw new WebGpuUnavailableError();
|
||||
if (!hasUsableDevice) throw new WebGpuUnavailableError();
|
||||
}
|
||||
|
||||
function getBrowserGpuArgs(
|
||||
|
||||
Reference in New Issue
Block a user