fix(engine): verify WebGPU device creation before capture (#6343)

This commit is contained in:
Abdullah M. Nazif
2026-10-25 22:54:30 +00:00
committed by GitHub
parent 553f2c5880
commit abb06b47b7
3 changed files with 192 additions and 23 deletions
+13
View File
@@ -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);
});
});
});
+34 -16
View File
@@ -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(