Both shutdown paths ran Get-Process chrome,msedge | Stop-Process -Force, which is every browser on the machine. The remote host is somebody's desktop: a test run must not close the windows they are working in. It only stayed harmless because the test account cannot touch another user's processes. The launcher now writes its browser pid to %TEMP%\gm-browser-<port>.pid and kills that pid; the job object still takes the renderer children. Measured against the Windows desktop: five msedge processes belonging to the logged-in user before the run, five after, and our chrome gone. 24 smoke checks ALL PASSED.
121 lines
5.7 KiB
PowerShell
121 lines
5.7 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)
|
|
# The pid file is how the caller ends this run's browser without touching the browser windows the
|
|
# person at this desk has open.
|
|
$pidFile = Join-Path $env:TEMP "gm-browser-$Port.pid"
|
|
Set-Content -Path $pidFile -Value $proc.Id
|
|
[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 {
|
|
# By pid, never by name: this host is somebody's desktop and their own Chrome must survive a test
|
|
# run. taskkill is denied to this account, Stop-Process is not, and the job object takes the
|
|
# renderer children when this process ends - this just makes the kill immediate.
|
|
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
|
[Console]::Error.WriteLine("gm-browser: $reason, killed the browser")
|
|
}
|
|
Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
|