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.
115 lines
5.4 KiB
PowerShell
115 lines
5.4 KiB
PowerShell
# Launches a headless browser with the CDP port open and ties its life to whoever is using it.
|
|
#
|
|
# Two measured facts shape this. Windows OpenSSH does not kill its child when the connection
|
|
# drops - waiting for the caller to die leaves the browser running forever (6 orphaned chrome.exe
|
|
# after a SIGKILLed tunnel). And stdin is no signal either: only the first chunk a caller writes
|
|
# ever arrives here, so a heartbeat over stdin looks identical to a dead caller.
|
|
#
|
|
# What is reliable is the CDP port itself: while a run is under way the forwarded connection is
|
|
# established, and it closes the moment the tunnel or the run dies. So the browser stays for as
|
|
# long as somebody is talking to it. The job object then makes the kill total - when this process
|
|
# ends for any reason, KILL_ON_JOB_CLOSE takes the whole browser tree with it.
|
|
param([int]$Port = 9333, [string]$Size = "1280,800", [int]$IdleSeconds = 45, [int]$MaxMinutes = 30)
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
Add-Type -TypeDefinition @'
|
|
using System;
|
|
using System.Runtime.InteropServices;
|
|
public static class GmJob {
|
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
|
static extern IntPtr CreateJobObject(IntPtr attrs, string name);
|
|
[DllImport("kernel32.dll")]
|
|
static extern bool SetInformationJobObject(IntPtr job, int infoClass, IntPtr info, uint len);
|
|
[DllImport("kernel32.dll")]
|
|
static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
|
|
|
|
const int ExtendedLimitInformation = 9;
|
|
const int LimitKillOnJobClose = 0x2000;
|
|
// Offset of LimitFlags inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION on x64, and the struct's size.
|
|
const int LimitFlagsOffset = 16;
|
|
const int StructSize = 144;
|
|
|
|
public static IntPtr Create() {
|
|
IntPtr job = CreateJobObject(IntPtr.Zero, null);
|
|
if (job == IntPtr.Zero) throw new Exception("CreateJobObject failed");
|
|
IntPtr info = Marshal.AllocHGlobal(StructSize);
|
|
try {
|
|
for (int i = 0; i < StructSize; i++) Marshal.WriteByte(info, i, 0);
|
|
Marshal.WriteInt32(info, LimitFlagsOffset, LimitKillOnJobClose);
|
|
if (!SetInformationJobObject(job, ExtendedLimitInformation, info, StructSize))
|
|
throw new Exception("SetInformationJobObject failed: " + Marshal.GetLastWin32Error());
|
|
} finally { Marshal.FreeHGlobal(info); }
|
|
return job;
|
|
}
|
|
|
|
public static void Assign(IntPtr job, IntPtr process) {
|
|
if (!AssignProcessToJobObject(job, process))
|
|
throw new Exception("AssignProcessToJobObject failed: " + Marshal.GetLastWin32Error());
|
|
}
|
|
}
|
|
'@
|
|
|
|
$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'
|
|
)
|
|
|
|
$job = [GmJob]::Create()
|
|
$proc = Start-Process -FilePath $exe -ArgumentList $flags -PassThru -NoNewWindow
|
|
[GmJob]::Assign($job, $proc.Handle)
|
|
[Console]::Error.WriteLine("gm-browser: $exe pid=$($proc.Id) port=$Port job=on")
|
|
|
|
# Either the browser dies on its own, or nobody is driving it any more. Both must end this script.
|
|
# The window has to survive the gap before the first client connects, hence starting the clock now.
|
|
$reason = "the browser exited on its own with code"
|
|
$started = Get-Date
|
|
$lastClient = $started
|
|
while (-not $proc.HasExited) {
|
|
Start-Sleep -Seconds 3
|
|
if (((Get-Date) - $started).TotalMinutes -ge $MaxMinutes) {
|
|
$reason = "the $MaxMinutes-minute cap"
|
|
break
|
|
}
|
|
# .NET rather than Get-NetTCPConnection: that one goes through WMI, which is denied to this
|
|
# account - its CIM session broke mid-run and took the browser down with it.
|
|
try {
|
|
$tcp = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpConnections()
|
|
$clients = @($tcp | Where-Object { $_.LocalEndPoint.Port -eq $Port -and $_.State -eq 'Established' })
|
|
} catch {
|
|
# A probe that fails says nothing about the run; the cap above is what bounds an orphan.
|
|
[Console]::Error.WriteLine("gm-browser: connection probe failed: $($_.Exception.Message)")
|
|
continue
|
|
}
|
|
if ($clients.Count -gt 0) {
|
|
$lastClient = Get-Date
|
|
} elseif (((Get-Date) - $lastClient).TotalSeconds -ge $IdleSeconds) {
|
|
$reason = "no CDP client for $IdleSeconds s"
|
|
break
|
|
}
|
|
}
|
|
|
|
if ($proc.HasExited) {
|
|
[Console]::Error.WriteLine("gm-browser: $reason $($proc.ExitCode)")
|
|
} else {
|
|
# taskkill and tasklist filters are denied to this account; Stop-Process is not. The job object
|
|
# would do it anyway when this process ends - this just makes the kill immediate.
|
|
Get-Process chrome, msedge -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
|
[Console]::Error.WriteLine("gm-browser: $reason, killed the browser")
|
|
}
|