diff --git a/README.md b/README.md index ef6db536..5f5cd536 100644 --- a/README.md +++ b/README.md @@ -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 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 + +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 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 diff --git a/tools/lib/browser.mjs b/tools/lib/browser.mjs new file mode 100644 index 00000000..0ca0cab4 --- /dev/null +++ b/tools/lib/browser.mjs @@ -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}` }; +} diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 1cc45e9a..1b2d14e7 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -7,19 +7,19 @@ * * await runChecks({ projectId, baseUrl, port, shotPrefix }, async (t) => { ... }); * - * The harness always waits for chromium to actually exit (a SwiftShader GPU process kept - * rendering at full CPU once when only SIGTERM was sent) and exits with code 1 on failures. + * The harness always waits for chromium to actually exit (a leftover GPU process kept rendering at + * 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 * 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. */ -import { spawn } from 'node:child_process'; import { writeFileSync } from 'node:fs'; +import { launchBrowser } from './browser.mjs'; 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 SERVER_TIMEOUT_MS = 20_000; @@ -315,28 +315,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { export async function runChecks(options, body) { const { projectId, baseUrl, port = 9333, shotPrefix = 'gm-e2e', width = 1280, height = 800 } = options; - const chrome = spawn( - 'chromium', - [ - '--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 { proc: chrome, stop: stopBrowser, where } = launchBrowser({ port, width, height }); + console.log(`browser: ${where}`); const consoleLog = []; let failures = 0; @@ -418,12 +398,12 @@ export async function runChecks(options, body) { } finally { await new Promise((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', () => { clearTimeout(timer); resolve(); }); - chrome.kill('SIGTERM'); + stopBrowser(); }); } if (consoleLog.length) console.log('--- console ---\n' + consoleLog.slice(0, 20).join('\n')); diff --git a/tools/remote/gm-browser.ps1 b/tools/remote/gm-browser.ps1 new file mode 100644 index 00000000..140376e6 --- /dev/null +++ b/tools/remote/gm-browser.ps1 @@ -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") +} diff --git a/tools/smoke-jumprun.mjs b/tools/smoke-jumprun.mjs index 02eeb30c..fbdba141 100644 --- a/tools/smoke-jumprun.mjs +++ b/tools/smoke-jumprun.mjs @@ -171,7 +171,10 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async ( afterCoin.coins >= 1 && coinsAfter < coinsBefore, 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}`); await shot('05-collected');