The leash that ties the Windows browser to the run never held. Three findings, each measured: - stdin is not a signal: only the first chunk a caller writes reaches the remote command, so the stdin heartbeat looked like a dead caller and killed a run at check 44. - a dropped SSH connection does not kill the remote command; Windows OpenSSH leaves it running. Six orphaned chrome.exe survived a SIGKILLed tunnel -- this is what locked up the desktop. - Get-NetTCPConnection goes through WMI, which is denied to the non-admin test account; the broken CIM session took a browser down mid-run. The launcher now watches the forwarded CDP port with .NET sockets: a connected client means the run is alive, 45 s without one ends it. A job object with KILL_ON_JOB_CLOSE and a 30-minute cap catch the rest, and the caller shoots the browser on the way out instead of waiting for the idle window. Measured: survives 80 s of an idle held connection, gone 44 s after kill -9 on node, immediately on an ordered end. Full suite 267 checks in 128 s and 24 smoke checks in 16 s against an RTX 4070, with this machine at load 0.04.
127 lines
4.1 KiB
JavaScript
127 lines
4.1 KiB
JavaScript
/**
|
||
* 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.
|
||
*/
|
||
stop: () => {
|
||
try {
|
||
execFileSync(
|
||
'ssh',
|
||
[
|
||
...sshArgs(key),
|
||
remote,
|
||
'powershell -NoProfile -Command "Get-Process chrome,msedge -ErrorAction SilentlyContinue | Stop-Process -Force"',
|
||
],
|
||
{ 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}`,
|
||
};
|
||
}
|