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

+22
View File
@@ -405,8 +405,30 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru
npm run build # writes dist/ and regenerates index.html npm run build # writes dist/ and regenerates index.html
node tools/seed-e2e.mjs && node tools/e2e.mjs # 267 editor checks, screenshots in /tmp/gm-e2e-*.png node tools/seed-e2e.mjs && node tools/e2e.mjs # 267 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
GM_REMOTE=user@host node tools/e2e.mjs # run the browser on another machine
``` ```
**A run needs a working GPU.** An idle editor page keeps rendering a picture that never changes, and
in software that is ruinous: measured here on an idle page over 20 s, software WebGL costs **373% of
a core at 8 fps**, the same page on the Intel HD 630 via ANGLE/Vulkan costs **69% at 56 fps**. The
full suite takes ~124 s that way. `tools/lib/browser.mjs` therefore always asks for the GPU and has
**no software fallback** – on a host without one, chromium should fail loudly rather than melt the
machine down. (`--use-angle=gl` and `--use-gl=egl` measure cheaper still, but get no WebGL context at
all, so the app never renders.)
**`GM_REMOTE=user@host` moves the browser to another machine** – `tools/lib/browser.mjs` starts it
over SSH and forwards its CDP port back to `127.0.0.1`, so the harness itself is unchanged. The
checks load the public base URL, so the remote side needs only a browser and internet: no repo, no
node. Setup is an SSH key (`GM_REMOTE_KEY`, default `~/.ssh/gm-win.key`). `tools/remote/gm-browser.ps1`
is copied over on every run and ties the browser to the SSH session – closing stdin kills it, however
the run ends; without that leash a dropped connection left eight orphaned `chrome.exe` behind.
Local CPU during a remote run is genuinely idle, but **the last remote attempt was not usable**: a
single CDP call cost ~5 ms at first, then the tunnel degraded until the suite crawled at ~25 s per
check. Since the GPU flags fixed the local cost, remote is a fallback that has not been made to work
end to end – treat it as unfinished, not as a supported path.
**Prettier owns the formatting** (110 columns, single quotes, `.prettierrc.json`); Markdown is **Prettier owns the formatting** (110 columns, single quotes, `.prettierrc.json`); Markdown is
excluded because it pads table cells to equal width. ESLint therefore only ever means "this is a excluded because it pads table cells to equal width. ESLint therefore only ever means "this is a
mistake", never "this looks different": `eslint-config-prettier` switches every stylistic rule mistake", never "this looks different": `eslint-config-prettier` switches every stylistic rule
+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}` };
}
+8 -28
View File
@@ -7,19 +7,19 @@
* *
* await runChecks({ projectId, baseUrl, port, shotPrefix }, async (t) => { ... }); * await runChecks({ projectId, baseUrl, port, shotPrefix }, async (t) => { ... });
* *
* The harness always waits for chromium to actually exit (a SwiftShader GPU process kept * The harness always waits for chromium to actually exit (a leftover GPU process kept rendering at
* rendering at full CPU once when only SIGTERM was sent) and exits with code 1 on failures. * full CPU once when only SIGTERM was sent) and exits with code 1 on failures.
* *
* A run that ends without printing its summary exits 1 as well. Every wait here can time out for * A run that ends without printing its summary exits 1 as well. Every wait here can time out for
* that reason: chromium dies under memory pressure on this machine, and a run that stops halfway * that reason: chromium dies under memory pressure on this machine, and a run that stops halfway
* used to exit 0 – silently reporting success for checks it never ran. * used to exit 0 – silently reporting success for checks it never ran.
*/ */
import { spawn } from 'node:child_process';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import { launchBrowser } from './browser.mjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Generous: navigation and screenshots under swiftshader are slow, a wedged chromium is forever. */ /** Generous on purpose: a slow navigation still finishes, a wedged chromium never does. */
const CDP_TIMEOUT_MS = 30_000; const CDP_TIMEOUT_MS = 30_000;
const SERVER_TIMEOUT_MS = 20_000; const SERVER_TIMEOUT_MS = 20_000;
@@ -315,28 +315,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
export async function runChecks(options, body) { export async function runChecks(options, body) {
const { projectId, baseUrl, port = 9333, shotPrefix = 'gm-e2e', width = 1280, height = 800 } = options; const { projectId, baseUrl, port = 9333, shotPrefix = 'gm-e2e', width = 1280, height = 800 } = options;
const chrome = spawn( const { proc: chrome, stop: stopBrowser, where } = launchBrowser({ port, width, height });
'chromium', console.log(`browser: ${where}`);
[
'--headless',
'--no-sandbox',
'--disable-gpu',
'--use-gl=swiftshader',
'--enable-unsafe-swiftshader',
`--remote-debugging-port=${port}`,
`--window-size=${width},${height}`,
'--hide-scrollbars',
// Memory on this machine is tight and a long run is where chromium dies. None of this
// serves a headless check: the stderr was full of GCM sign-in retries before.
'--disable-background-networking',
'--disable-sync',
'--disable-extensions',
'--no-first-run',
'--mute-audio',
'about:blank',
],
{ stdio: ['ignore', 'ignore', 'pipe'] },
);
const consoleLog = []; const consoleLog = [];
let failures = 0; let failures = 0;
@@ -418,12 +398,12 @@ export async function runChecks(options, body) {
} finally { } finally {
await new Promise((resolve) => { await new Promise((resolve) => {
if (chrome.exitCode !== null) return resolve(); if (chrome.exitCode !== null) return resolve();
const timer = setTimeout(() => chrome.kill('SIGKILL'), 5000); const timer = setTimeout(() => chrome.kill('SIGKILL'), 15_000);
chrome.once('exit', () => { chrome.once('exit', () => {
clearTimeout(timer); clearTimeout(timer);
resolve(); resolve();
}); });
chrome.kill('SIGTERM'); stopBrowser();
}); });
} }
if (consoleLog.length) console.log('--- console ---\n' + consoleLog.slice(0, 20).join('\n')); if (consoleLog.length) console.log('--- console ---\n' + consoleLog.slice(0, 20).join('\n'));
+43
View File
@@ -0,0 +1,43 @@
# Launches a headless browser with the CDP port open and ties its life to this SSH session:
# stdin closing (the caller went away, however violently) kills the whole process tree. Without
# that leash the browser survives a dropped connection - measured: 8 orphaned chrome.exe.
param([int]$Port = 9333, [string]$Size = "1280,800")
$ErrorActionPreference = 'Stop'
$candidates = @(
"C:\Program Files\Google\Chrome\Application\chrome.exe",
"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
)
$exe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $exe) { [Console]::Error.WriteLine("no chrome or edge found"); exit 2 }
$udd = Join-Path $env:TEMP "gm-e2e-$Port"
Remove-Item -Recurse -Force $udd -ErrorAction SilentlyContinue
# No --disable-gpu here: forcing software WebGL on a desktop with a real card rasterises every
# frame on the CPU and made the host unusable. Let the machine use the card it has.
$flags = @(
'--headless', '--ignore-gpu-blocklist', '--renderer-process-limit=1',
"--remote-debugging-port=$Port", "--user-data-dir=`"$udd`"", "--window-size=$Size",
'--hide-scrollbars', '--disable-background-networking', '--disable-sync',
'--disable-extensions', '--no-first-run', '--mute-audio', '--no-default-browser-check',
'about:blank'
)
$proc = Start-Process -FilePath $exe -ArgumentList $flags -PassThru -NoNewWindow
[Console]::Error.WriteLine("gm-browser: $exe pid=$($proc.Id) port=$Port")
# Either the browser dies on its own, or our caller does. Both must end this script.
while (-not $proc.HasExited) {
if ([Console]::In.ReadLine() -eq $null) { break }
}
if ($proc.HasExited) {
[Console]::Error.WriteLine("gm-browser: browser exited on its own with code $($proc.ExitCode)")
} else {
# taskkill and tasklist filters are denied to this account; Stop-Process is not. Killing by name
# is safe here because the account exists only to run these tests.
Get-Process chrome, msedge -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
[Console]::Error.WriteLine("gm-browser: stdin closed, killed the browser")
}
+4 -1
View File
@@ -171,7 +171,10 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
afterCoin.coins >= 1 && coinsAfter < coinsBefore, afterCoin.coins >= 1 && coinsAfter < coinsBefore,
JSON.stringify({ coins: afterCoin.coins, before: coinsBefore, after: coinsAfter }), JSON.stringify({ coins: afterCoin.coins, before: coinsBefore, after: coinsAfter }),
); );
check('the HUD shows the counter', await visible('Coins 1/10')); // Against the count the run actually reached, not a fixed 1: how many coins the walk sweeps up
// depends on machine speed – a faster host collects two.
const hud = `Coins ${afterCoin.coins}/${afterCoin.needed}`;
check('the HUD shows the counter', await visible(hud), hud);
check('the clock is counting down', afterCoin.timeLeft < 28, `timeLeft ${afterCoin.timeLeft}`); check('the clock is counting down', afterCoin.timeLeft < 28, `timeLeft ${afterCoin.timeLeft}`);
await shot('05-collected'); await shot('05-collected');