// Every Chrome storebound starts itself (cli/console/session.ts: the console window, spawned with a DevTools port, not // through Playwright) must end with the run that started it. An incident (October 2026): a headless Chrome of a test // run (--user-data-dir=$TMPDIR/storebound-profile-apple-XXXX --headless=new) outlived its parent for ~20 hours as an // orphan (parent PID 1). macOS LaunchServices took it for the running "Google Chrome" app, so clicking Chrome in the // Dock only activated that windowless instance and the owner's own Chrome would not open. A Chrome started with // --remote-debugging-port does not notice its parent dying (Playwright's own launches use a pipe and do), so: // - guardChrome: while such a Chrome runs, process exit kills it (SIGTERM: Chromium shuts down cleanly and flushes its // cookies), SIGINT / SIGTERM / SIGHUP close it gracefully before this process exits (128 + signal), and a // detached watchdog (its own process group, so a Ctrl+C or a SIGKILL of this process does not take it along) // ends it when this process is gone in any way, SIGKILL included. The watchdog checks the Chrome's command line // still names the profile, so a reused pid is never killed. // - reapStaleChromes: before every start, a storebound Chrome left from an earlier run (its user data dir is one of // storebound's, its parent is launchd / init / a subreaper: an orphan) is ended when it is headless (nobody can // use it) and named when it has a window (the owner may still be signing in there). // Sources (checked October 2026): Chromium's ShutdownDetector treats SIGTERM / SIGINT / SIGHUP as a normal browser // shutdown https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/shutdown_signal_handlers_posix.cc ; // orphans go to the nearest subreaper, else PID 1 https://man7.org/linux/man-pages/man2/PR_SET_CHILD_SUBREAPER.2const.html import { constants } from 'node:os'; import * as rt from '../lib/rt'; /** What a storebound Chrome's --user-data-dir contains: its own profile, an opt-in copy, the tests' throwaway profiles. */ export const PROFILE_MARKERS = ['/.storebound/console-profile/', '\\.storebound\\console-profile\\', 'storebound-profile-', 'storebound-chrome-']; export interface PsRow { pid: number; ppid: number; command: string } /** `ps -A -ww -o pid=,ppid=,command=` output as rows (lines that do not parse are skipped). */ export function parsePs(out: string): PsRow[] { const rows: PsRow[] = []; for (const line of out.split('\n')) { const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/); if (m) rows.push({ pid: Number(m[1]), ppid: Number(m[2]), command: m[3]! }); } return rows; } /** The --user-data-dir value of a command line (up to the next ` --` switch: the path may contain spaces). */ export function userDataDirOf(command: string): string | null { const m = command.match(/--user-data-dir=(.+?)(?=\s--|$)/); return m ? m[1]!.replace(/^"|"$/g, '') : null; } /** A process whose parent is gone: re-parented to PID 1 (launchd, init) or to a subreaper (systemd --user, init). */ export function isOrphan(row: PsRow, byPid: Map): boolean { if (row.ppid === 1) return true; const parent = byPid.get(row.ppid); return !!parent && /(^|\/)(systemd|init|launchd)(\s|$)/.test(parent.command); } /** * storebound's orphaned Chromes in a process list: the browser process (no --type=, the helpers end with it) started * with a DevTools port on one of storebound's profiles, whose parent is gone. `reap`: headless ones (nobody can use * them, they only block the Dock's Chrome). `windows`: ones with a window, left to the owner. */ export function staleChromes(rows: PsRow[], markers: string[] = PROFILE_MARKERS): { reap: PsRow[]; windows: PsRow[] } { const byPid = new Map(rows.map(r => [r.pid, r])); const reap: PsRow[] = [], windows: PsRow[] = []; for (const r of rows) { if (/\s--type=/.test(r.command) || !r.command.includes('--remote-debugging-port=')) continue; const dir = userDataDirOf(r.command); if (!dir || !markers.some(m => dir.includes(m)) || !isOrphan(r, byPid)) continue; (/\s--headless(=|\s|$)/.test(r.command) ? reap : windows).push(r); } return { reap, windows }; } /** A zombie has ended: it only waits for its parent to reap it. An orphan's new parent may never do that (a container * whose PID 1 is not an init), and kill(pid, 0) still succeeds on it. */ export function isZombie(pid: number): boolean { if (process.platform === 'win32') return false; try { return /^Z/.test(rt.spawnSync(['ps', '-o', 'stat=', '-p', String(pid)], { stdout: 'pipe', stderr: 'ignore', timeout: 5000 }).stdout.toString().trim()); } catch { return false; } } function alive(pid: number): boolean { try { process.kill(pid, 0); } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM'; } return !isZombie(pid); } /** SIGTERM (Chromium's clean shutdown), then SIGKILL after `graceMs`. True when the process is gone. */ export async function endProcess(pid: number, graceMs = 5000): Promise { try { process.kill(pid, 'SIGTERM'); } catch { return true; } const end = Date.now() + graceMs; while (alive(pid) && Date.now() < end) await rt.sleep(100); if (alive(pid)) { try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } await rt.sleep(100); } return !alive(pid); } /** The process list (macOS, Linux); empty on Windows or when ps is missing. */ function processList(): string { if (process.platform === 'win32') return ''; try { return rt.spawnSync(['ps', '-A', '-ww', '-o', 'pid=,ppid=,command='], { stdout: 'pipe', stderr: 'ignore', timeout: 5000 }).stdout.toString(); } catch { return ''; } } /** * Ends the headless storebound Chromes earlier runs left behind and names the orphaned windows. Called before every * start; one `ps` call. Never throws. */ export async function reapStaleChromes(o: { say?: (m: string) => void; list?: () => string; markers?: string[] } = {}): Promise<{ reaped: number[]; windows: number[] }> { const { reap, windows } = staleChromes(parsePs((o.list ?? processList)()), o.markers); const reaped: number[] = []; for (const r of reap) { if (await endProcess(r.pid)) reaped.push(r.pid); o.say?.(`browser: ended a headless Chrome an earlier storebound run left behind (process ${r.pid}, profile ${userDataDirOf(r.command)}); it kept your own Chrome from opening from the Dock`); } for (const r of windows) o.say?.(`browser: a Chrome window of an earlier storebound run is still open without its run (process ${r.pid}, profile ${userDataDirOf(r.command)}); close it when you are done with it`); return { reaped, windows: windows.map(r => r.pid) }; } /** * The watchdog's command line: waits while `parent` lives and `child` still runs with `marker` on its command line * (a reused pid is never touched), then ends the child (SIGTERM, SIGKILL after 10 s). POSIX sh, or PowerShell on * Windows (it holds the child's process handle, so its pid cannot be reused meanwhile). */ export function watchdogArgv(platform: string, parent: number, child: number, marker: string, intervalS = 1): string[] { if (platform === 'win32') { return ['powershell', '-NoProfile', '-NonInteractive', '-Command', `$c = Get-Process -Id ${child} -ErrorAction SilentlyContinue; if (-not $c) { exit 0 }; ` + `$p = Get-Process -Id ${parent} -ErrorAction SilentlyContinue; ` + 'if ($p) { $p.WaitForExit() }; ' // the parent's lifetime is the bound: no polling + 'if (-not $c.HasExited) { $c.CloseMainWindow() | Out-Null; if (-not $c.WaitForExit(10000)) { $c.Kill() } }']; } const script = [ 'p=$1; c=$2; m=$3; t=$4', 'if command -v ps >/dev/null 2>&1; then alive() { ps -p "$c" -o command= 2>/dev/null | grep -qF -- "$m"; }; else alive() { kill -0 "$c" 2>/dev/null; }; fi', 'while kill -0 "$p" 2>/dev/null && alive; do sleep "$t"; done', 'alive || exit 0', 'kill -TERM "$c" 2>/dev/null', 'i=0; while [ "$i" -lt 50 ] && alive; do sleep 0.2; i=$((i+1)); done', 'alive && kill -KILL "$c" 2>/dev/null', 'exit 0', ].join('\n'); return ['/bin/sh', '-c', script, 'storebound-chrome-watchdog', String(parent), String(child), marker, String(intervalS)]; } /** Starts the watchdog for `child` (detached, its own process group, never keeps this process alive). Null if it cannot. */ export function startWatchdog(child: number, marker: string, o: { parent?: number; intervalS?: number; platform?: string } = {}): rt.Subprocess | null { try { const w = rt.spawn(watchdogArgv(o.platform ?? process.platform, o.parent ?? process.pid, child, marker, o.intervalS), { stdio: ['ignore', 'ignore', 'ignore'], detached: true }); w.unref(); return w; } catch { return null; } } interface Guarded { pid: number; close: () => Promise } const live = new Map(); const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const; /** How long a signal waits for the graceful closes before this process exits anyway. */ export const SIGNAL_GRACE_MS = 15_000; let exitHooked = false, stopping = false; const killAll = () => { for (const g of live.values()) try { process.kill(g.pid, 'SIGTERM'); } catch { /* gone */ } }; function onSignal(sig: NodeJS.Signals): void { if (stopping) return; stopping = true; const all = Promise.all([...live.values()].map(g => g.close().catch(() => { /* closed already */ }))); void Promise.race([all, rt.sleep(SIGNAL_GRACE_MS)]).finally(() => { killAll(); process.exit(128 + ((constants.signals as Record)[sig] ?? 1)); }); } const handlers = new Map(SIGNALS.map(s => [s, () => onSignal(s)] as const)); /** * Ties a Chrome storebound started to this process (see the header): exit kills it, a signal closes it with `close` * (graceful, cookies flushed) and exits, a watchdog ends it when this process dies. Returns `release`, called once * the Chrome has exited (safe to call twice). `marker`: a string of its command line (its --user-data-dir switch). */ export function guardChrome(pid: number, marker: string, close: () => Promise, o: { watchdog?: boolean } = {}): () => void { if (!exitHooked) { exitHooked = true; process.on('exit', killAll); } if (live.size === 0) for (const [s, h] of handlers) process.on(s, h); live.set(pid, { pid, close }); const dog = o.watchdog === false ? null : startWatchdog(pid, marker); return () => { dog?.kill('SIGTERM'); if (!live.delete(pid) || live.size > 0) return; for (const [s, h] of handlers) process.off(s, h); }; } /** Tests: the pids guarded right now. */ export const guardedPids = (): number[] => [...live.keys()];