# 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