@hyperframes/engine 0.6.4 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/services/frameCapture.d.ts +83 -0
- package/dist/services/frameCapture.d.ts.map +1 -1
- package/dist/services/frameCapture.js +130 -27
- package/dist/services/frameCapture.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/assertSwiftShader.d.ts +59 -0
- package/dist/utils/assertSwiftShader.d.ts.map +1 -0
- package/dist/utils/assertSwiftShader.js +106 -0
- package/dist/utils/assertSwiftShader.js.map +1 -0
- package/package.json +2 -2
- package/src/index.ts +15 -1
- package/src/services/frameCapture-discardWarmup.test.ts +183 -0
- package/src/services/frameCapture-warmupTicks.test.ts +174 -0
- package/src/services/frameCapture.ts +184 -24
- package/src/types.ts +14 -0
- package/src/utils/assertSwiftShader.test.ts +130 -0
- package/src/utils/assertSwiftShader.ts +126 -0
|
@@ -78,6 +78,81 @@ export interface CaptureSession {
|
|
|
78
78
|
const BROWSER_CONSOLE_BUFFER_SIZE = 200;
|
|
79
79
|
const CAPTURE_SESSION_CLOSE_TIMEOUT_MS = 5_000;
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Fixed warmup-loop iteration count used when `CaptureOptions.lockWarmupTicks`
|
|
83
|
+
* is `true`. Picked to roughly match the median tick count observed by the
|
|
84
|
+
* unlocked wall-clock loop during a typical 2s page load at 30fps — so
|
|
85
|
+
* `beginFrameTimeTicks` lands in a similar range regardless of host speed.
|
|
86
|
+
*/
|
|
87
|
+
export const LOCKED_WARMUP_TICKS = 60;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Internal driver for the BeginFrame warmup loop.
|
|
91
|
+
*
|
|
92
|
+
* - Unlocked: exits as soon as `state.running` flips to `false`. Tick count
|
|
93
|
+
* varies with wall-clock page-load time.
|
|
94
|
+
* - Locked: ignores `state.running` entirely and exits once it has driven
|
|
95
|
+
* exactly `LOCKED_WARMUP_TICKS` iterations. Caller awaits this promise
|
|
96
|
+
* after page-readiness so `session.beginFrameTimeTicks` is identical
|
|
97
|
+
* across hosts.
|
|
98
|
+
* - `tick` errors are swallowed (Chrome's `beginFrame` is best-effort
|
|
99
|
+
* during page load — the page hasn't installed CDP listeners yet). When
|
|
100
|
+
* `tick` throws, the iteration count does NOT advance.
|
|
101
|
+
*
|
|
102
|
+
* `intervalMs` is the BeginFrame interval (≈33ms at 30fps).
|
|
103
|
+
*
|
|
104
|
+
* `frameTimeTicks` is derived as `ticks * intervalMs` and exposed via
|
|
105
|
+
* {@link warmupFrameTimeTicks} — not stored on the state, to keep `ticks`
|
|
106
|
+
* the single source of truth.
|
|
107
|
+
*/
|
|
108
|
+
export interface WarmupTickState {
|
|
109
|
+
running: boolean;
|
|
110
|
+
ticks: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface WarmupTickOptions {
|
|
114
|
+
intervalMs: number;
|
|
115
|
+
lockWarmupTicks: boolean;
|
|
116
|
+
tick: (frameTimeTicks: number, intervalMs: number) => Promise<void>;
|
|
117
|
+
/** Injectable so tests can advance "time" without real setTimeout. */
|
|
118
|
+
sleep?: (ms: number) => Promise<void>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const realSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Derive the current simulated frame time from a warmup state. Single source
|
|
125
|
+
* of truth so tests and callers stay in sync.
|
|
126
|
+
*/
|
|
127
|
+
export function warmupFrameTimeTicks(state: WarmupTickState, intervalMs: number): number {
|
|
128
|
+
return state.ticks * intervalMs;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function driveWarmupTicks(
|
|
132
|
+
options: WarmupTickOptions,
|
|
133
|
+
state: WarmupTickState,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const sleep = options.sleep ?? realSleep;
|
|
136
|
+
while (true) {
|
|
137
|
+
if (options.lockWarmupTicks) {
|
|
138
|
+
// Locked mode exits on the iteration count, ignoring `state.running` —
|
|
139
|
+
// the caller flips `running=false` after page-readiness but we keep
|
|
140
|
+
// ticking until LOCKED_WARMUP_TICKS so the count is host-independent.
|
|
141
|
+
if (state.ticks >= LOCKED_WARMUP_TICKS) return;
|
|
142
|
+
} else {
|
|
143
|
+
// Unlocked mode is wall-clock-bounded.
|
|
144
|
+
if (!state.running) return;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
await options.tick(state.ticks * options.intervalMs, options.intervalMs);
|
|
148
|
+
state.ticks += 1;
|
|
149
|
+
} catch {
|
|
150
|
+
// Page not ready yet; keep spinning.
|
|
151
|
+
}
|
|
152
|
+
await sleep(options.intervalMs);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
81
156
|
async function waitForCloseWithTimeout(promise: Promise<unknown>): Promise<boolean> {
|
|
82
157
|
let timedOut = false;
|
|
83
158
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -447,38 +522,53 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
|
|
447
522
|
|
|
448
523
|
// In BeginFrame mode, Chrome's event loop is paused until we issue frames.
|
|
449
524
|
// Start a warmup loop to drive rAF/setTimeout callbacks during page load.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
525
|
+
//
|
|
526
|
+
// The unlocked path runs while `warmupState.running` stays true — wall-
|
|
527
|
+
// clock-bounded. The locked path (`options.lockWarmupTicks`) additionally
|
|
528
|
+
// exits at exactly `LOCKED_WARMUP_TICKS` iterations so `beginFrameTimeTicks`
|
|
529
|
+
// is deterministic across hosts with different page-load latencies.
|
|
453
530
|
const warmupIntervalMs = 33; // ~30fps
|
|
531
|
+
const warmupState: WarmupTickState = {
|
|
532
|
+
running: true,
|
|
533
|
+
ticks: 0,
|
|
534
|
+
};
|
|
535
|
+
const lockWarmupTicks = session.options.lockWarmupTicks === true;
|
|
454
536
|
let warmupClient: import("puppeteer-core").CDPSession | null = null;
|
|
455
537
|
|
|
456
|
-
const
|
|
538
|
+
const acquireWarmupClient = async (): Promise<void> => {
|
|
457
539
|
try {
|
|
458
540
|
warmupClient = await getCdpSession(page);
|
|
459
541
|
await warmupClient.send("HeadlessExperimental.enable");
|
|
460
542
|
} catch {
|
|
461
543
|
/* page not ready yet */
|
|
462
544
|
}
|
|
545
|
+
};
|
|
463
546
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
547
|
+
const warmupLoopPromise = (async () => {
|
|
548
|
+
await acquireWarmupClient();
|
|
549
|
+
await driveWarmupTicks(
|
|
550
|
+
{
|
|
551
|
+
intervalMs: warmupIntervalMs,
|
|
552
|
+
lockWarmupTicks,
|
|
553
|
+
tick: async (frameTimeTicks, interval) => {
|
|
554
|
+
if (!warmupClient) {
|
|
555
|
+
// No CDP yet — let driveWarmupTicks count the tick anyway so the
|
|
556
|
+
// locked iteration count is reached deterministically. Throwing
|
|
557
|
+
// would skip the ticks++ increment, leaking host-load variance
|
|
558
|
+
// back into the count.
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
467
561
|
await warmupClient.send("HeadlessExperimental.beginFrame", {
|
|
468
|
-
frameTimeTicks
|
|
469
|
-
interval
|
|
562
|
+
frameTimeTicks,
|
|
563
|
+
interval,
|
|
470
564
|
noDisplayUpdates: true,
|
|
471
565
|
});
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
await new Promise((r) => setTimeout(r, warmupIntervalMs));
|
|
479
|
-
}
|
|
480
|
-
};
|
|
481
|
-
warmupLoop().catch(() => {});
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
warmupState,
|
|
569
|
+
);
|
|
570
|
+
})();
|
|
571
|
+
warmupLoopPromise.catch(() => {});
|
|
482
572
|
|
|
483
573
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
484
574
|
|
|
@@ -497,7 +587,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
|
|
497
587
|
`!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`,
|
|
498
588
|
);
|
|
499
589
|
if (!pageReady) {
|
|
500
|
-
|
|
590
|
+
warmupState.running = false;
|
|
501
591
|
throw new Error(
|
|
502
592
|
`[FrameCapture] window.__hf not ready after ${pageReadyTimeout}ms. Page must expose window.__hf = { duration, seek }.`,
|
|
503
593
|
);
|
|
@@ -521,11 +611,18 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
|
|
521
611
|
await page.evaluate(`document.fonts?.ready`);
|
|
522
612
|
await waitForOptionalTailwindReady(page, pageReadyTimeout);
|
|
523
613
|
|
|
524
|
-
// Stop warmup
|
|
525
|
-
|
|
614
|
+
// Stop warmup. Unlocked mode exits on this flag; locked mode keeps ticking
|
|
615
|
+
// until LOCKED_WARMUP_TICKS, so we await its promise to ensure the count is
|
|
616
|
+
// exact before deriving the baseline.
|
|
617
|
+
warmupState.running = false;
|
|
618
|
+
if (lockWarmupTicks) {
|
|
619
|
+
await warmupLoopPromise.catch(() => {});
|
|
620
|
+
}
|
|
526
621
|
|
|
527
|
-
// Set base frame time ticks past warmup range
|
|
528
|
-
|
|
622
|
+
// Set base frame time ticks past warmup range. Locked mode pins to the
|
|
623
|
+
// constant so chunk workers on different hosts compute the same baseline.
|
|
624
|
+
const baseTickCount = lockWarmupTicks ? LOCKED_WARMUP_TICKS : warmupState.ticks;
|
|
625
|
+
session.beginFrameTimeTicks = (baseTickCount + 10) * session.beginFrameIntervalMs;
|
|
529
626
|
|
|
530
627
|
// For PNG captures, inject the transparent-background override + stylesheet
|
|
531
628
|
// (see the screenshot-mode branch above for the rationale). BeginFrame mode
|
|
@@ -712,6 +809,69 @@ export async function captureFrameToBuffer(
|
|
|
712
809
|
return { buffer, captureTimeMs };
|
|
713
810
|
}
|
|
714
811
|
|
|
812
|
+
/**
|
|
813
|
+
* Type of the "inner capture" function consumed by
|
|
814
|
+
* {@link discardWarmupCapture}. Matches the real `captureFrameCore` signature
|
|
815
|
+
* with the buffer-bearing result trimmed to what the caller actually uses
|
|
816
|
+
* (the wrapper never inspects the buffer). Exposed so unit tests can inject
|
|
817
|
+
* a stub instead of driving Chrome end-to-end.
|
|
818
|
+
*/
|
|
819
|
+
export type DiscardWarmupInnerCapture = (
|
|
820
|
+
session: CaptureSession,
|
|
821
|
+
frameIndex: number,
|
|
822
|
+
time: number,
|
|
823
|
+
) => Promise<{ buffer: Buffer; quantizedTime: number; captureTimeMs: number }>;
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Perform one capture, throw away the buffer, and restore any session
|
|
827
|
+
* side-effects (perf counters, BeginFrame damage tallies) so downstream
|
|
828
|
+
* captures see state identical to a fresh session.
|
|
829
|
+
*
|
|
830
|
+
* Distributed chunk workers need this because Chrome's BeginFrame screenshot
|
|
831
|
+
* pipeline maintains a per-process `lastFrameCache`: when a captured frame's
|
|
832
|
+
* `hasDamage` reports `false`, the screenshot path returns the previously
|
|
833
|
+
* captured buffer. For chunk N (N > 0) the worker has no prior frame in its
|
|
834
|
+
* cache, so the very first capture's `hasDamage` reporting diverges from
|
|
835
|
+
* what an in-process render at the same absolute frame index would see (the
|
|
836
|
+
* in-process renderer always has frame N-1 cached). One discard capture
|
|
837
|
+
* before the first real capture primes the cache.
|
|
838
|
+
*
|
|
839
|
+
* The function intentionally restores perf state so the warmup capture does
|
|
840
|
+
* NOT bias `getCapturePerfSummary()`'s per-frame averages.
|
|
841
|
+
*
|
|
842
|
+
* No file is written; the buffer is discarded.
|
|
843
|
+
*
|
|
844
|
+
* @param session — initialized capture session
|
|
845
|
+
* @param frameIndex — frame index to warm up with (default 0). Chunk
|
|
846
|
+
* workers typically pass their chunk's first absolute frame index.
|
|
847
|
+
* @param time — time in seconds (default 0). Chunk workers typically pass
|
|
848
|
+
* the corresponding `frameIndex / fps`.
|
|
849
|
+
* @param innerCapture — injectable for tests; defaults to the real
|
|
850
|
+
* `captureFrameCore`.
|
|
851
|
+
*/
|
|
852
|
+
export async function discardWarmupCapture(
|
|
853
|
+
session: CaptureSession,
|
|
854
|
+
frameIndex: number = 0,
|
|
855
|
+
time: number = 0,
|
|
856
|
+
innerCapture: DiscardWarmupInnerCapture = captureFrameCore,
|
|
857
|
+
): Promise<void> {
|
|
858
|
+
// Snapshot the side-effect counters captureFrameCore mutates. We use a
|
|
859
|
+
// shallow `{...}` for capturePerf because all five fields are primitive
|
|
860
|
+
// numbers — no nested state to deep-copy.
|
|
861
|
+
const perfBefore = { ...session.capturePerf };
|
|
862
|
+
const hasDamageBefore = session.beginFrameHasDamageCount;
|
|
863
|
+
const noDamageBefore = session.beginFrameNoDamageCount;
|
|
864
|
+
try {
|
|
865
|
+
await innerCapture(session, frameIndex, time);
|
|
866
|
+
} finally {
|
|
867
|
+
// Always restore — even on error. A failed warmup capture should not
|
|
868
|
+
// leak inflated perf counters into the real capture summary.
|
|
869
|
+
session.capturePerf = perfBefore;
|
|
870
|
+
session.beginFrameHasDamageCount = hasDamageBefore;
|
|
871
|
+
session.beginFrameNoDamageCount = noDamageBefore;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
715
875
|
export async function closeCaptureSession(session: CaptureSession): Promise<void> {
|
|
716
876
|
// INVARIANT: closeCaptureSession is idempotent. The renderOrchestrator HDR
|
|
717
877
|
// cleanup path tracks a `domSessionClosed` flag and may still re-call this
|
package/src/types.ts
CHANGED
|
@@ -120,6 +120,20 @@ export interface CaptureOptions {
|
|
|
120
120
|
* `--variables-file <path>`. Must be a JSON-serializable plain object.
|
|
121
121
|
*/
|
|
122
122
|
variables?: Record<string, unknown>;
|
|
123
|
+
/**
|
|
124
|
+
* When `true`, the BeginFrame warmup loop driven during page navigation
|
|
125
|
+
* runs exactly `LOCKED_WARMUP_TICKS` (60) iterations regardless of how
|
|
126
|
+
* long the page load takes, making `session.beginFrameTimeTicks`
|
|
127
|
+
* deterministic across machines with different page-load latencies.
|
|
128
|
+
*
|
|
129
|
+
* Default `false`: wall-clock-bounded driver — ticks until page-readiness
|
|
130
|
+
* completes, accumulating whatever count the host CPU manages. Preserves
|
|
131
|
+
* the in-process renderer's BeginFrame timing baselines.
|
|
132
|
+
*
|
|
133
|
+
* Has no effect outside BeginFrame mode (screenshot capture never runs a
|
|
134
|
+
* warmup loop).
|
|
135
|
+
*/
|
|
136
|
+
lockWarmupTicks?: boolean;
|
|
123
137
|
}
|
|
124
138
|
|
|
125
139
|
export interface CaptureVideoMetadataHint {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for assertSwiftShader and its companion readWebGlVendorInfo helper.
|
|
3
|
+
*
|
|
4
|
+
* We don't spin up a real Chrome here — the assertion's contract is "given a
|
|
5
|
+
* WebGL info pair, accept SwiftShader and reject anything else." Tests inject
|
|
6
|
+
* the info pair through the optional `readInfo` override that the
|
|
7
|
+
* production code path leaves as a default.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import type { Page } from "puppeteer-core";
|
|
12
|
+
import {
|
|
13
|
+
BROWSER_GPU_NOT_SOFTWARE,
|
|
14
|
+
SwiftShaderAssertionError,
|
|
15
|
+
assertSwiftShader,
|
|
16
|
+
} from "./assertSwiftShader.js";
|
|
17
|
+
|
|
18
|
+
// Minimal Page stub. Only assertSwiftShader's default `readInfo` ever touches
|
|
19
|
+
// `page.goto` / `page.evaluate`; when we inject a custom `readInfo` the page
|
|
20
|
+
// object is never used, so an empty cast is safe.
|
|
21
|
+
const stubPage = {} as unknown as Page;
|
|
22
|
+
|
|
23
|
+
describe("assertSwiftShader", () => {
|
|
24
|
+
it("accepts the canonical SwiftShader vendor + renderer pair", async () => {
|
|
25
|
+
await assertSwiftShader(stubPage, async () => ({
|
|
26
|
+
vendor: "Google Inc. (Google)",
|
|
27
|
+
renderer:
|
|
28
|
+
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
|
|
29
|
+
}));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("accepts SwiftShader regardless of trailing whitespace on vendor", async () => {
|
|
33
|
+
await assertSwiftShader(stubPage, async () => ({
|
|
34
|
+
vendor: " Google Inc. (Google) ",
|
|
35
|
+
renderer: "SwiftShader",
|
|
36
|
+
}));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("accepts case-insensitive renderer token", async () => {
|
|
40
|
+
await assertSwiftShader(stubPage, async () => ({
|
|
41
|
+
vendor: "Google Inc. (Google)",
|
|
42
|
+
renderer: "ANGLE (Google, swiftshader Device, swiftshader driver)",
|
|
43
|
+
}));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("throws SwiftShaderAssertionError when vendor is hardware-accelerated", async () => {
|
|
47
|
+
let caught: unknown;
|
|
48
|
+
try {
|
|
49
|
+
await assertSwiftShader(stubPage, async () => ({
|
|
50
|
+
vendor: "Google Inc. (NVIDIA Corporation)",
|
|
51
|
+
renderer: "ANGLE (NVIDIA, NVIDIA GeForce RTX 4090, OpenGL 4.6)",
|
|
52
|
+
}));
|
|
53
|
+
} catch (err) {
|
|
54
|
+
caught = err;
|
|
55
|
+
}
|
|
56
|
+
expect(caught).toBeInstanceOf(SwiftShaderAssertionError);
|
|
57
|
+
expect((caught as SwiftShaderAssertionError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
|
|
58
|
+
expect((caught as Error).message).toContain("non-SwiftShader");
|
|
59
|
+
expect((caught as Error).message).toContain("--use-gl=swiftshader");
|
|
60
|
+
expect((caught as SwiftShaderAssertionError).vendor).toBe("Google Inc. (NVIDIA Corporation)");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("throws when the renderer string lacks SwiftShader even if vendor matches", async () => {
|
|
64
|
+
// Google Inc. is the umbrella vendor for many ANGLE backends — vendor
|
|
65
|
+
// alone is not enough; the renderer must actually mention SwiftShader.
|
|
66
|
+
let caught: unknown;
|
|
67
|
+
try {
|
|
68
|
+
await assertSwiftShader(stubPage, async () => ({
|
|
69
|
+
vendor: "Google Inc. (Google)",
|
|
70
|
+
renderer: "ANGLE (Google, Vulkan 1.3.0 (Intel(R) UHD Graphics 630), OpenGL ES 3.0)",
|
|
71
|
+
}));
|
|
72
|
+
} catch (err) {
|
|
73
|
+
caught = err;
|
|
74
|
+
}
|
|
75
|
+
expect(caught).toBeInstanceOf(SwiftShaderAssertionError);
|
|
76
|
+
expect((caught as SwiftShaderAssertionError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("throws when both vendor and renderer are empty", async () => {
|
|
80
|
+
// Some chrome:// pages return empty strings before the GPU info table
|
|
81
|
+
// populates. We treat that as failure rather than silently passing.
|
|
82
|
+
let caught: unknown;
|
|
83
|
+
try {
|
|
84
|
+
await assertSwiftShader(stubPage, async () => ({ vendor: "", renderer: "" }));
|
|
85
|
+
} catch (err) {
|
|
86
|
+
caught = err;
|
|
87
|
+
}
|
|
88
|
+
expect(caught).toBeInstanceOf(SwiftShaderAssertionError);
|
|
89
|
+
expect((caught as SwiftShaderAssertionError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("propagates errors from the info reader without wrapping", async () => {
|
|
93
|
+
const upstream = new Error("simulated CDP failure");
|
|
94
|
+
let caught: unknown;
|
|
95
|
+
try {
|
|
96
|
+
await assertSwiftShader(stubPage, async () => {
|
|
97
|
+
throw upstream;
|
|
98
|
+
});
|
|
99
|
+
} catch (err) {
|
|
100
|
+
caught = err;
|
|
101
|
+
}
|
|
102
|
+
// Reader errors should not be masked by SwiftShaderAssertionError —
|
|
103
|
+
// they are a separate failure class (probably retryable).
|
|
104
|
+
expect(caught).toBe(upstream);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("rejects an unrelated vendor that happens to contain the SwiftShader token in the renderer", async () => {
|
|
108
|
+
// Defensive: if some future ANGLE build uses a non-Google vendor string
|
|
109
|
+
// but still mentions SwiftShader in the renderer for some reason, we
|
|
110
|
+
// still want to require the exact Google vendor signature.
|
|
111
|
+
let caught: unknown;
|
|
112
|
+
try {
|
|
113
|
+
await assertSwiftShader(stubPage, async () => ({
|
|
114
|
+
vendor: "Mesa/X.org",
|
|
115
|
+
renderer: "llvmpipe (SwiftShader compatible)",
|
|
116
|
+
}));
|
|
117
|
+
} catch (err) {
|
|
118
|
+
caught = err;
|
|
119
|
+
}
|
|
120
|
+
expect(caught).toBeInstanceOf(SwiftShaderAssertionError);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("SwiftShaderAssertionError", () => {
|
|
125
|
+
it("exposes the BROWSER_GPU_NOT_SOFTWARE typed-failure code", () => {
|
|
126
|
+
const err = new SwiftShaderAssertionError("test", "v", "r");
|
|
127
|
+
expect(err.code).toBe(BROWSER_GPU_NOT_SOFTWARE);
|
|
128
|
+
expect(err.code).toBe("BROWSER_GPU_NOT_SOFTWARE");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assertSwiftShader — verify Chrome's WebGL is rendered by SwiftShader.
|
|
3
|
+
*
|
|
4
|
+
* Distributed renders pixel-lock on the GPU backend: hardware GL is bitwise
|
|
5
|
+
* unstable across worker machines (different drivers, driver versions, GL
|
|
6
|
+
* extension sets, even differing fp32 rounding on the same vendor). Chunk
|
|
7
|
+
* workers launch Chrome with `--use-gl=swiftshader --use-angle=swiftshader`
|
|
8
|
+
* so every worker uses the same pure-software GL implementation.
|
|
9
|
+
*
|
|
10
|
+
* Those Chrome flags are advisory: a misconfigured base image, a missing
|
|
11
|
+
* SwiftShader library, or a `chrome://gpu` blocklist override can silently
|
|
12
|
+
* downgrade to system GL. The distributed pipeline cannot detect the
|
|
13
|
+
* downgrade by sampling pixels (one machine = one render), so we read
|
|
14
|
+
* `chrome://gpu` directly after launch and refuse to render if the active
|
|
15
|
+
* GL renderer is anything other than SwiftShader.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Page } from "puppeteer-core";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Error code classifying this failure as non-retryable for distributed
|
|
22
|
+
* workflow adapters — a downgraded GPU on a worker will not heal on retry.
|
|
23
|
+
*/
|
|
24
|
+
export const BROWSER_GPU_NOT_SOFTWARE = "BROWSER_GPU_NOT_SOFTWARE";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Error thrown when chrome://gpu reports a non-SwiftShader WebGL backend.
|
|
28
|
+
*
|
|
29
|
+
* Carries a `code` property so the adapter can match on it without parsing
|
|
30
|
+
* the message string — Temporal/Step Functions retry policies key off the
|
|
31
|
+
* code, not the message.
|
|
32
|
+
*/
|
|
33
|
+
export class SwiftShaderAssertionError extends Error {
|
|
34
|
+
readonly code: typeof BROWSER_GPU_NOT_SOFTWARE = BROWSER_GPU_NOT_SOFTWARE;
|
|
35
|
+
readonly vendor: string;
|
|
36
|
+
readonly renderer: string;
|
|
37
|
+
|
|
38
|
+
constructor(message: string, vendor: string, renderer: string) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "SwiftShaderAssertionError";
|
|
41
|
+
this.vendor = vendor;
|
|
42
|
+
this.renderer = renderer;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* SwiftShader identifies itself on `chrome://gpu` and in
|
|
48
|
+
* `WEBGL_debug_renderer_info` with this exact vendor string. Locking to
|
|
49
|
+
* Google's own GL string (rather than a substring match on "swiftshader")
|
|
50
|
+
* avoids false-positives from third-party ANGLE backends that incidentally
|
|
51
|
+
* mention SwiftShader in unrelated diagnostic text.
|
|
52
|
+
*/
|
|
53
|
+
const SWIFTSHADER_VENDOR_SIGNATURE = "Google Inc. (Google)";
|
|
54
|
+
/**
|
|
55
|
+
* Renderer string contains the literal "SwiftShader" token. We match
|
|
56
|
+
* case-insensitively and only require the substring; Chrome occasionally
|
|
57
|
+
* appends a build suffix (e.g. " Vulkan 1.3").
|
|
58
|
+
*/
|
|
59
|
+
const SWIFTSHADER_RENDERER_TOKEN = "swiftshader";
|
|
60
|
+
|
|
61
|
+
interface WebGlInfo {
|
|
62
|
+
vendor: string;
|
|
63
|
+
renderer: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read the WebGL vendor/renderer strings from a live `chrome://gpu` page.
|
|
68
|
+
*
|
|
69
|
+
* Extracted from `assertSwiftShader` so tests can stub the navigation +
|
|
70
|
+
* extraction step. Returns the raw values; callers decide how to interpret
|
|
71
|
+
* them. Both fields are best-effort — Chrome returns empty strings if the
|
|
72
|
+
* GPU info table hasn't populated yet, which the caller treats as failure.
|
|
73
|
+
*/
|
|
74
|
+
export async function readWebGlVendorInfo(page: Page): Promise<WebGlInfo> {
|
|
75
|
+
await page.goto("chrome://gpu", { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
76
|
+
// The "GL_VENDOR" / "GL_RENDERER" rows live inside <info-view> shadow DOM
|
|
77
|
+
// in modern Chrome. We pull the structured `info_log_` payload off the
|
|
78
|
+
// page-level globals instead of querying the DOM, since the DOM layout has
|
|
79
|
+
// drifted across versions.
|
|
80
|
+
const info = await page.evaluate((): WebGlInfo => {
|
|
81
|
+
type Row = { description?: string; value?: string };
|
|
82
|
+
type InfoLog = { graphics_info?: { basic_info?: Row[] } };
|
|
83
|
+
const w = window as unknown as { browserBridge?: { gpuInfo_?: InfoLog } };
|
|
84
|
+
const rows: Row[] = w.browserBridge?.gpuInfo_?.graphics_info?.basic_info ?? [];
|
|
85
|
+
let vendor = "";
|
|
86
|
+
let renderer = "";
|
|
87
|
+
for (const row of rows) {
|
|
88
|
+
if (typeof row.description !== "string" || typeof row.value !== "string") continue;
|
|
89
|
+
if (row.description === "GL_VENDOR") vendor = row.value;
|
|
90
|
+
else if (row.description === "GL_RENDERER") renderer = row.value;
|
|
91
|
+
}
|
|
92
|
+
return { vendor, renderer };
|
|
93
|
+
});
|
|
94
|
+
return info;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate that the active WebGL renderer is SwiftShader. Throws
|
|
99
|
+
* `SwiftShaderAssertionError` otherwise.
|
|
100
|
+
*
|
|
101
|
+
* Pass an optional `readInfo` override for tests that don't have a real
|
|
102
|
+
* Puppeteer `Page`. The default implementation navigates to `chrome://gpu`
|
|
103
|
+
* and parses the GL_VENDOR / GL_RENDERER rows.
|
|
104
|
+
*/
|
|
105
|
+
export async function assertSwiftShader(
|
|
106
|
+
page: Page,
|
|
107
|
+
readInfo: (page: Page) => Promise<WebGlInfo> = readWebGlVendorInfo,
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
const { vendor, renderer } = await readInfo(page);
|
|
110
|
+
|
|
111
|
+
const vendorMatches = vendor.trim() === SWIFTSHADER_VENDOR_SIGNATURE;
|
|
112
|
+
const rendererMatches = renderer.toLowerCase().includes(SWIFTSHADER_RENDERER_TOKEN);
|
|
113
|
+
|
|
114
|
+
if (vendorMatches && rendererMatches) return;
|
|
115
|
+
|
|
116
|
+
throw new SwiftShaderAssertionError(
|
|
117
|
+
`[assertSwiftShader] Chrome reported a non-SwiftShader WebGL backend. ` +
|
|
118
|
+
`Distributed renders require pure-software GL for pixel-identical retries. ` +
|
|
119
|
+
`Got vendor=${JSON.stringify(vendor)} renderer=${JSON.stringify(renderer)}; ` +
|
|
120
|
+
`expected vendor=${JSON.stringify(SWIFTSHADER_VENDOR_SIGNATURE)} renderer to contain "SwiftShader". ` +
|
|
121
|
+
`Ensure Chrome was launched with --use-gl=swiftshader --use-angle=swiftshader and that the ` +
|
|
122
|
+
`SwiftShader libraries are present in the runtime image.`,
|
|
123
|
+
vendor,
|
|
124
|
+
renderer,
|
|
125
|
+
);
|
|
126
|
+
}
|