/** * 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}`, ], { stdio: ['ignore', 'ignore', 'pipe'] }, ); return { proc, /** * The launcher notices a finished run on its own, but only after its idle window – a browser * lingering a minute on someone else's desktop is rude, so end it now and let that window be * the net for runs that die without reaching here. Only this run's pid: the host is somebody's * desktop, and killing by name would close the browser they are reading this in. */ stop: () => { const pidFile = `$env:TEMP\\gm-browser-${port}.pid`; try { execFileSync( 'ssh', [ ...sshArgs(key), remote, `powershell -NoProfile -Command "if (Test-Path ${pidFile}) { Stop-Process -Id (Get-Content ${pidFile}) -Force -ErrorAction SilentlyContinue }"`, ], { stdio: 'pipe', timeout: 20_000 }, ); } catch { // Unreachable host, denied, already gone: the idle window still collects it. } proc.kill('SIGTERM'); }, where: `remote browser on ${remote}`, }; }