fix: align local WebGPU capture behavior (#2907)

* fix: align local WebGPU capture behavior

* fix: address WebGPU capture review feedback

* fix: retain overlapping GPU seek work

* fix: satisfy runtime seek completion types

* fix: drain concurrent GPU seek work

* fix: prevent WebGPU capture barrier starvation

* fix: keep WebGPU presentation active during render seeks
This commit is contained in:
James Russo
2026-07-30 21:52:30 -07:00
committed by GitHub
parent 1d636f603c
commit 3a6b7f0612
34 changed files with 1208 additions and 137 deletions
@@ -1,5 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { dispatchSeekEvent, forceDispatchSeekEvent, resetSeekDispatchState } from "./seek-dispatch";
import {
dispatchSeekEvent,
forceDispatchSeekEvent,
resetSeekDispatchState,
waitForSeekCompletion,
type HfSeekEventDetail,
} from "./seek-dispatch";
describe("seek-dispatch", () => {
beforeEach(() => {
@@ -42,4 +48,167 @@ describe("seek-dispatch", () => {
window.removeEventListener("hf-seek", handler);
expect(handler).toHaveBeenCalledTimes(1);
});
it("waits for all GPU work registered synchronously by listeners", async () => {
let finish: (() => void) | undefined;
const gpuWork = new Promise<void>((resolve) => {
finish = resolve;
});
const handler = (event: Event) => {
(event as CustomEvent<HfSeekEventDetail>).detail.waitUntil(gpuWork);
};
window.addEventListener("hf-seek", handler);
dispatchSeekEvent(9);
window.removeEventListener("hf-seek", handler);
let settled = false;
const pending = waitForSeekCompletion().then(() => {
settled = true;
});
await Promise.resolve();
expect(settled).toBe(false);
finish?.();
await pending;
expect(settled).toBe(true);
});
it("retains overlapping seek generations until a capture observes them", async () => {
let finishFirst: (() => void) | undefined;
let finishSecond: (() => void) | undefined;
const firstGpuWork = new Promise<void>((resolve) => {
finishFirst = resolve;
});
const secondGpuWork = new Promise<void>((resolve) => {
finishSecond = resolve;
});
const handler = (event: Event) => {
const detail = (event as CustomEvent<HfSeekEventDetail>).detail;
detail.waitUntil(detail.time === 10 ? firstGpuWork : secondGpuWork);
};
window.addEventListener("hf-seek", handler);
dispatchSeekEvent(10);
dispatchSeekEvent(11);
window.removeEventListener("hf-seek", handler);
let settled = false;
const pending = waitForSeekCompletion().then(() => {
settled = true;
});
finishSecond?.();
await Promise.resolve();
expect(settled).toBe(false);
finishFirst?.();
await pending;
expect(settled).toBe(true);
});
it("drains success and failure generations registered after the capture wait starts", async () => {
let finishFirst: (() => void) | undefined;
let finishSecond: (() => void) | undefined;
let failThird: ((reason: unknown) => void) | undefined;
const firstGpuWork = new Promise<void>((resolve) => {
finishFirst = resolve;
});
const secondGpuWork = new Promise<void>((resolve) => {
finishSecond = resolve;
});
const thirdGpuWork = new Promise<void>((_resolve, reject) => {
failThird = reject;
});
const handler = (event: Event) => {
const detail = (event as CustomEvent<HfSeekEventDetail>).detail;
const gpuWork =
detail.time === 13 ? firstGpuWork : detail.time === 14 ? secondGpuWork : thirdGpuWork;
detail.waitUntil(gpuWork);
};
window.addEventListener("hf-seek", handler);
dispatchSeekEvent(13);
let settled = false;
const failure = new Error("later GPU queue failed");
const pending = waitForSeekCompletion()
.then(() => {
settled = true;
})
.catch((reason: unknown) => {
settled = true;
throw reason;
});
await Promise.resolve();
forceDispatchSeekEvent(14);
forceDispatchSeekEvent(15);
finishFirst?.();
await Promise.resolve();
expect(settled).toBe(false);
finishSecond?.();
await Promise.resolve();
expect(settled).toBe(false);
failThird?.(failure);
await expect(pending).rejects.toBe(failure);
window.removeEventListener("hf-seek", handler);
await expect(waitForSeekCompletion()).resolves.toBeUndefined();
});
it("does not retain empty or fulfilled heartbeat generations between captures", async () => {
for (let i = 0; i < 20; i += 1) {
forceDispatchSeekEvent(20);
}
const handler = (event: Event) => {
(event as CustomEvent<HfSeekEventDetail>).detail.waitUntil(Promise.resolve());
};
window.addEventListener("hf-seek", handler);
for (let i = 0; i < 20; i += 1) {
forceDispatchSeekEvent(21);
}
window.removeEventListener("hf-seek", handler);
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
const promiseAll = vi.spyOn(Promise, "all");
await waitForSeekCompletion();
expect(promiseAll).not.toHaveBeenCalled();
});
it("reports a rejected generation once, then consumes it", async () => {
const failure = new Error("GPU queue failed");
const handler = (event: Event) => {
(event as CustomEvent<HfSeekEventDetail>).detail.waitUntil(Promise.reject(failure));
};
window.addEventListener("hf-seek", handler);
dispatchSeekEvent(12);
window.removeEventListener("hf-seek", handler);
await expect(waitForSeekCompletion()).rejects.toBe(failure);
await expect(waitForSeekCompletion()).resolves.toBeUndefined();
});
it("reports one rejected generation to concurrent capture barriers", async () => {
let failGpuWork: ((reason: unknown) => void) | undefined;
const gpuWork = new Promise<void>((_resolve, reject) => {
failGpuWork = reject;
});
const failure = new Error("GPU queue failed");
const handler = (event: Event) => {
(event as CustomEvent<HfSeekEventDetail>).detail.waitUntil(gpuWork);
};
window.addEventListener("hf-seek", handler);
dispatchSeekEvent(22);
window.removeEventListener("hf-seek", handler);
const firstCapture = waitForSeekCompletion();
const secondCapture = waitForSeekCompletion();
failGpuWork?.(failure);
await expect(Promise.allSettled([firstCapture, secondCapture])).resolves.toEqual([
{ status: "rejected", reason: failure },
{ status: "rejected", reason: failure },
]);
await expect(waitForSeekCompletion()).resolves.toBeUndefined();
});
});
@@ -19,15 +19,52 @@ import { swallow } from "../diagnostics";
*/
let _lastDispatchedTime = -1;
type SeekCompletionResult = { status: "fulfilled" } | { status: "rejected"; reason: unknown };
let _pendingCompletions = new Set<Promise<SeekCompletionResult>>();
let _pendingFailure: { reason: unknown } | undefined;
let _activeCompletionBarriers = 0;
export interface HfSeekEventDetail {
time: number;
waitUntil: (promise: PromiseLike<unknown>) => void;
}
function dispatch(time: number): void {
const pending: PromiseLike<unknown>[] = [];
let accepting = true;
const detail: HfSeekEventDetail = {
time,
waitUntil: (promise) => {
if (!accepting) {
throw new Error("hf-seek waitUntil() must be called synchronously from the event listener");
}
pending.push(promise);
},
};
try {
window.dispatchEvent(new CustomEvent<HfSeekEventDetail>("hf-seek", { detail }));
} catch (err) {
swallow("runtime.adapters.seek-dispatch.site1", err);
} finally {
accepting = false;
}
if (pending.length === 0) return;
const completion = Promise.all(pending)
.then<SeekCompletionResult>(() => ({ status: "fulfilled" }))
.catch<SeekCompletionResult>((reason: unknown) => ({ status: "rejected", reason }));
_pendingCompletions.add(completion);
void completion.then((result) => {
if (!_pendingCompletions.delete(completion)) return;
if (result.status === "rejected" && _pendingFailure === undefined) {
_pendingFailure = { reason: result.reason };
}
});
}
export function dispatchSeekEvent(time: number): void {
if (time === _lastDispatchedTime) return;
_lastDispatchedTime = time;
try {
window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time } }));
} catch (err) {
swallow("runtime.adapters.seek-dispatch.site1", err);
}
dispatch(time);
}
/**
@@ -42,14 +79,44 @@ export function dispatchSeekEvent(time: number): void {
*/
export function forceDispatchSeekEvent(time: number): void {
_lastDispatchedTime = time;
dispatch(time);
}
export function isSeekCompletionBarrierActive(): boolean {
return _activeCompletionBarriers > 0;
}
export async function waitForSeekCompletion(): Promise<void> {
_activeCompletionBarriers += 1;
let failed = _pendingFailure;
try {
window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time } }));
} catch (err) {
swallow("runtime.adapters.seek-dispatch.force", err);
// Let concurrently started barriers snapshot the same retained failure
// before either one consumes it.
await Promise.resolve();
while (_pendingCompletions.size > 0) {
const results = await Promise.all([..._pendingCompletions]);
const rejected = results.find((result) => result.status === "rejected");
if (failed === undefined && rejected?.status === "rejected") {
failed = { reason: rejected.reason };
}
}
const retainedFailure = _pendingFailure;
if (failed === undefined) {
failed = retainedFailure;
}
if (_pendingFailure === retainedFailure) {
_pendingFailure = undefined;
}
if (failed) throw failed.reason;
} finally {
_activeCompletionBarriers -= 1;
}
}
/** Reset internal state — used in tests to prevent cross-test contamination. */
export function resetSeekDispatchState(): void {
_lastDispatchedTime = -1;
_pendingCompletions = new Set();
_pendingFailure = undefined;
_activeCompletionBarriers = 0;
}
@@ -1,11 +1,17 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createTypegpuAdapter } from "./typegpu";
import { resetSeekDispatchState } from "./seek-dispatch";
import { createTypegpuAdapter, TYPEGPU_PRESENT_HEARTBEAT_MS } from "./typegpu";
import {
resetSeekDispatchState,
waitForSeekCompletion,
type HfSeekEventDetail,
} from "./seek-dispatch";
const gpuWindow = window as Window & { __hfTypegpuTime?: number };
describe("typegpu adapter", () => {
beforeEach(() => {
vi.useRealTimers();
document.body.innerHTML = "";
delete gpuWindow.__hfTypegpuTime;
// Reset shared dedup state so each test starts with a clean dispatch history
resetSeekDispatchState();
@@ -86,4 +92,77 @@ describe("typegpu adapter", () => {
const adapter = createTypegpuAdapter();
expect(() => adapter.discover()).not.toThrow();
});
it("re-presents the paused WebGPU frame without advancing seek time", async () => {
vi.useFakeTimers();
document.body.innerHTML =
'<div data-composition-id="gpu" data-requires-webgpu data-duration="2"></div>';
const adapter = createTypegpuAdapter();
const times: number[] = [];
const handler = (event: Event) => {
times.push((event as CustomEvent<{ time: number }>).detail.time);
};
window.addEventListener("hf-seek", handler);
adapter.seek({ time: 1.25 });
adapter.pause();
await vi.advanceTimersByTimeAsync(TYPEGPU_PRESENT_HEARTBEAT_MS);
adapter.play?.();
await vi.advanceTimersByTimeAsync(TYPEGPU_PRESENT_HEARTBEAT_MS * 2);
window.removeEventListener("hf-seek", handler);
expect(times).toEqual([1.25, 1.25]);
expect(gpuWindow.__hfTypegpuTime).toBe(1.25);
});
it("does not start a present heartbeat without the WebGPU capability marker", async () => {
vi.useFakeTimers();
document.body.innerHTML = '<div data-composition-id="dom" data-duration="2"></div>';
const adapter = createTypegpuAdapter();
const handler = vi.fn();
window.addEventListener("hf-seek", handler);
adapter.seek({ time: 1.25 });
adapter.pause();
await vi.advanceTimersByTimeAsync(TYPEGPU_PRESENT_HEARTBEAT_MS * 2);
window.removeEventListener("hf-seek", handler);
expect(handler).toHaveBeenCalledOnce();
});
it("pauses presentation heartbeats while a capture barrier drains slow GPU work", async () => {
vi.useFakeTimers();
document.body.innerHTML =
'<div data-composition-id="gpu" data-requires-webgpu data-duration="2"></div>';
const adapter = createTypegpuAdapter();
const times: number[] = [];
const completionLatency = TYPEGPU_PRESENT_HEARTBEAT_MS * 2 + 1;
const handler = (event: Event) => {
const detail = (event as CustomEvent<HfSeekEventDetail>).detail;
times.push(detail.time);
detail.waitUntil(
new Promise<void>((resolve) => {
setTimeout(resolve, completionLatency);
}),
);
};
window.addEventListener("hf-seek", handler);
adapter.seek({ time: 1.25 });
adapter.pause();
let settled = false;
const capture = waitForSeekCompletion().then(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(TYPEGPU_PRESENT_HEARTBEAT_MS * 2);
expect(settled).toBe(false);
expect(times).toEqual([1.25]);
await vi.advanceTimersByTimeAsync(1);
await capture;
adapter.play?.();
window.removeEventListener("hf-seek", handler);
expect(settled).toBe(true);
expect(times).toEqual([1.25]);
});
});
+35 -5
View File
@@ -1,5 +1,11 @@
import type { RuntimeDeterministicAdapter } from "../types";
import { dispatchSeekEvent } from "./seek-dispatch";
import {
dispatchSeekEvent,
forceDispatchSeekEvent,
isSeekCompletionBarrierActive,
} from "./seek-dispatch";
export const TYPEGPU_PRESENT_HEARTBEAT_MS = 250;
/**
* TypeGPU / WebGPU adapter for HyperFrames
@@ -31,7 +37,10 @@ import { dispatchSeekEvent } from "./seek-dispatch";
* }
*
* // Seek: fired by HyperFrames whenever the player scrubs or plays
* window.addEventListener("hf-seek", (e) => render(e.detail.time));
* window.addEventListener("hf-seek", (e) => {
* render(e.detail.time);
* e.detail.waitUntil(device.queue.onSubmittedWorkDone());
* });
*
* // Initial frame at t=0
* render(window.__hfTypegpuTime ?? 0);
@@ -44,9 +53,9 @@ import { dispatchSeekEvent } from "./seek-dispatch";
*
* ## Render-mode determinism
*
* For frame-perfect video renders, call `await device.queue.onSubmittedWorkDone()`
* after each `render(time)` invocation before the frame is captured. This ensures
* the GPU has finished writing to the canvas before the engine screenshots it.
* For frame-perfect video renders, register GPU completion synchronously with
* `e.detail.waitUntil(device.queue.onSubmittedWorkDone())` after `render(time)`.
* HyperFrames awaits the registered work before screenshots and frame capture.
*
* ## Browser feature detection
*
@@ -64,6 +73,24 @@ import { dispatchSeekEvent } from "./seek-dispatch";
export function createTypegpuAdapter(): RuntimeDeterministicAdapter {
let forcedTime: number | null = null;
let lastForcedTime = 0;
let presentHeartbeat: number | null = null;
const stopPresentHeartbeat = () => {
if (presentHeartbeat === null) return;
window.clearInterval(presentHeartbeat);
presentHeartbeat = null;
};
const startPresentHeartbeat = () => {
if (presentHeartbeat !== null) return;
if (!document.querySelector("[data-composition-id][data-requires-webgpu]")) return;
presentHeartbeat = window.setInterval(() => {
if (forcedTime === null) return;
if (isSeekCompletionBarrierActive()) return;
window.__hfTypegpuTime = forcedTime;
forceDispatchSeekEvent(forcedTime);
}, TYPEGPU_PRESENT_HEARTBEAT_MS);
};
return {
name: "typegpu",
@@ -83,13 +110,16 @@ export function createTypegpuAdapter(): RuntimeDeterministicAdapter {
if (forcedTime == null) {
forcedTime = Math.max(0, lastForcedTime);
}
startPresentHeartbeat();
},
play: () => {
stopPresentHeartbeat();
forcedTime = null;
},
revert: () => {
stopPresentHeartbeat();
forcedTime = null;
lastForcedTime = 0;
},