Run the check browser on the GPU instead of rasterising in software

The harness forced software WebGL (--disable-gpu --use-gl=swiftshader) on every host.
Measured here on an idle editor page over 20 s: that costs 373% of a core at 8 fps,
while the Intel HD 630 via ANGLE/Vulkan costs 69% at 56 fps. The full suite now runs
in ~124 s. Note the app is not rendering 60 fps as previously assumed - in software
it only reaches 8, and a single frame costs more than any throttle interval would save,
which is why capping the framerate had measured no effect.

--use-angle=gl and --use-gl=egl look cheaper still but get no WebGL context at all,
so the app never renders. They are not usable and are named in the comment as such.

There is deliberately no software fallback: a host without a usable GPU should fail
loudly rather than drag the machine to a standstill. None of the 267 checks reads the
rendered image anyway - they read window.__gm.locate() and the debug hook, and the
screenshots assert nothing - but Pixi needs WebGL2 to run at all.

The browser launch moves out of harness.mjs into tools/lib/browser.mjs, which also
supports GM_REMOTE=user@host via an SSH-forwarded CDP port. That path is unfinished:
the tunnel degraded until the suite crawled at ~25 s per check, and forcing software
WebGL on a desktop with a real card brought it to its knees. README says so plainly
rather than advertising it.

Also: the jump-run HUD check asserted a fixed "Coins 1/10", which fails on a faster
host where the same walk sweeps up two coins. It now asserts the HUD against the count
the run actually reached - the real guarantee is that HUD and state agree.

Verified: npm run check green (404 tests), 267 e2e ALL PASSED and 24 jump-run checks
ALL PASSED against the GPU flags. The fallback removal leaves the flag set on this host
identical (same flags, different order), so it was not re-run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-09 08:46:16 +02:00
1 parent 3d038e11cb
commit 32a40ee34b
5 files changed
+180 -29

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
/**
* Where the headless browser for a check run lives.
*
* Local by default. Set `GM_REMOTE=user@host` to run it on another machine instead: an SSH
* session forwards the CDP port back to 127.0.0.1, so everything downstream – waitForChrome,
* /json/new, the WebSocket – stays unchanged. The checks load the public base URL, so the
* remote side needs nothing but a browser and internet.
*
* Why bother: even with the GPU in use an idle editor page keeps rendering frames that never
* change, and this machine has 8 GB with most of the swap already gone.
*/
import { spawn, execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
const HERE = dirname(fileURLToPath(import.meta.url));
const LAUNCHER = join(HERE, '..', 'remote', 'gm-browser.ps1');
const FLAGS = [
'--headless',
'--hide-scrollbars',
/**
* Measured on this host (idle editor page, 20 s): software WebGL costs 373% of a core at 8 fps,
* the Intel HD 630 via ANGLE/Vulkan costs 69% at 56 fps. There is deliberately no software
* fallback — a host without a usable GPU should fail loudly rather than melt down. `--use-angle=gl`
* and `--use-gl=egl` look even cheaper but get no WebGL context at all — do not use them.
*/
'--use-angle=vulkan',
'--ignore-gpu-blocklist',
'--enable-gpu',
'--enable-features=Vulkan',
// None of this serves a headless check, and its stderr was full of GCM sign-in retries.
'--disable-background-networking',
'--disable-sync',
'--disable-extensions',
'--no-first-run',
'--mute-audio',
];
function sshArgs(key) {
return [
'-i',
key,
'-o',
'IdentitiesOnly=yes',
'-o',
'BatchMode=yes',
'-o',
'StrictHostKeyChecking=accept-new',
'-o',
'ConnectTimeout=15',
// A dead peer must surface as a closed socket, not as a run that waits forever.
'-o',
'ServerAliveInterval=10',
'-o',
'ServerAliveCountMax=3',
];
}
/**
* Starts the browser and returns its process plus a `stop()` that ends it the way that host
* needs. `proc.stderr` carries the browser's own last words either way.
*/
export function launchBrowser({ port, width, height }) {
const remote = process.env.GM_REMOTE;
if (!remote) {
const proc = spawn(
'chromium',
// --no-sandbox: this container runs as root, where chromium refuses to start otherwise.
[
...FLAGS,
'--no-sandbox',
`--remote-debugging-port=${port}`,
`--window-size=${width},${height}`,
'about:blank',
],
{ stdio: ['ignore', 'ignore', 'pipe'] },
);
return { proc, stop: () => proc.kill('SIGTERM'), where: 'local chromium' };
}
const key = process.env.GM_REMOTE_KEY ?? join(homedir(), '.ssh', 'gm-win.key');
// Copied every run: a stale launcher on the remote is a debugging session nobody enjoys.
execFileSync('scp', [...sshArgs(key), LAUNCHER, `${remote}:gm-browser.ps1`], { stdio: 'pipe' });
const proc = spawn(
'ssh',
[
...sshArgs(key),
'-o',
'ExitOnForwardFailure=yes',
'-L',
`${port}:127.0.0.1:${port}`,
remote,
`powershell -NoProfile -ExecutionPolicy Bypass -File gm-browser.ps1 -Port ${port} -Size ${width},${height}`,
],
// stdin stays open on purpose: closing it is what tells the launcher to kill the browser.
{ stdio: ['pipe', 'ignore', 'pipe'] },
);
return { proc, stop: () => proc.stdin.end(), where: `remote browser on ${remote}` };
}