// cli/console/chrome-guard.ts: a Chrome storebound starts never outlives its run (October 2026: an orphaned headless // Chrome kept the owner's own Chrome from opening from the Dock). Pure parts (the process list, which Chromes are // stale orphans, the watchdog's command line, the headless executable order) and, on macOS / Linux, real processes // standing in for Chrome (sh + sleep, no browser, about a second): the watchdog ends the child when its parent dies and // never touches a reused pid; the reaper ends a real orphan with a storebound profile on its command line. import { describe, expect, test } from 'bun:test'; import { guardChrome, guardedPids, isOrphan, isZombie, parsePs, reapStaleChromes, staleChromes, startWatchdog, userDataDirOf, watchdogArgv } from '../cli/console/chrome-guard'; import { persistentExecutable } from '../cli/shots/browser'; import * as rt from '../cli/lib/rt'; const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; const PS = [ ' 1 0 /sbin/launchd', ' 500 1 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome', // the owner's own Chrome ` 600 1 ${CHROME} --user-data-dir=/var/folders/x/T/storebound-profile-apple-AbC --remote-debugging-port=0 --no-first-run --restore-last-session --disable-blink-features=AutomationControlled --headless=new`, ` 601 600 ${CHROME} Helper --type=renderer --user-data-dir=/var/folders/x/T/storebound-profile-apple-AbC --remote-debugging-port=0 --headless=new`, ` 700 1 ${CHROME} --user-data-dir=/Users/me/.storebound/console-profile/play --remote-debugging-port=0 --restore-last-session`, // a window ` 800 799 ${CHROME} --user-data-dir=/var/folders/x/T/storebound-chrome-Q1 --remote-debugging-port=0 --headless=new`, // its run is alive ' 799 300 bun storebound console fill apple-new-app', ` 900 1 ${CHROME} --user-data-dir=/tmp/other-tool --remote-debugging-port=0 --headless=new`, // not storebound's ` 910 1 ${CHROME} --user-data-dir=/var/folders/x/T/storebound-profile-play-Z --headless=new`, // no DevTools port: not ours ' 1000 1 /lib/systemd/systemd --user', ` 1010 1000 /opt/google/chrome/chrome --user-data-dir=/tmp/storebound-profile-play-L --remote-debugging-port=0 --headless=new`, // Linux subreaper 'garbage line', ].join('\n'); describe('which Chromes are stale', () => { test('process list rows; the user data dir, also with spaces', () => { const rows = parsePs(PS); expect(rows).toHaveLength(11); // the garbage line is skipped expect(rows[2]).toMatchObject({ pid: 600, ppid: 1 }); expect(userDataDirOf(`${CHROME} --user-data-dir=/Users/you/my apps/.storebound/console-profile/apple --remote-debugging-port=0`)).toBe('/Users/you/my apps/.storebound/console-profile/apple'); expect(userDataDirOf('chrome --headless')).toBeNull(); }); test('orphans: parent PID 1, or a subreaper (systemd --user); never a live storebound run', () => { const rows = parsePs(PS), byPid = new Map(rows.map(r => [r.pid, r])); expect(isOrphan(byPid.get(600)!, byPid)).toBe(true); expect(isOrphan(byPid.get(1010)!, byPid)).toBe(true); expect(isOrphan(byPid.get(800)!, byPid)).toBe(false); }); test('reaped: headless orphans on storebound profiles; windows only named; the owner\'s Chrome, helpers, live runs and other tools never', () => { const { reap, windows } = staleChromes(parsePs(PS)); expect(reap.map(r => r.pid)).toEqual([600, 1010]); expect(windows.map(r => r.pid)).toEqual([700]); }); test('reapStaleChromes says what it ended and which window it left', async () => { const said: string[] = []; const out = await reapStaleChromes({ list: () => PS.replace(/^\s+(600|1010)\s.*$/gm, ''), say: m => said.push(m) }); expect(out).toEqual({ reaped: [], windows: [700] }); expect(said.join('\n')).toContain('process 700'); expect(said.join('\n')).toContain('/Users/me/.storebound/console-profile/play'); }); }); describe('watchdog command line', () => { test('POSIX sh with the parent, the child and its profile switch; PowerShell on Windows holding the child\'s handle', () => { const a = watchdogArgv('darwin', 10, 20, '--user-data-dir=/p q', 0.5); expect(a.slice(0, 2)).toEqual(['/bin/sh', '-c']); expect(a.slice(-4)).toEqual(['10', '20', '--user-data-dir=/p q', '0.5']); expect(a[2]).toContain('grep -qF -- "$m"'); // a reused pid (another command line) is never killed const w = watchdogArgv('win32', 10, 20, 'x'); expect(w[0]).toBe('powershell'); expect(w.at(-1)).toContain('Get-Process -Id 20'); expect(w.at(-1)).toContain('Get-Process -Id 10'); }); test('guardChrome hooks SIGINT / SIGTERM / SIGHUP only while a Chrome runs', () => { const before = ['SIGINT', 'SIGTERM', 'SIGHUP'].map(s => process.listenerCount(s)); const r1 = guardChrome(999_991, 'x', async () => {}, { watchdog: false }); const r2 = guardChrome(999_992, 'x', async () => {}, { watchdog: false }); expect(guardedPids()).toEqual([999_991, 999_992]); expect(['SIGINT', 'SIGTERM', 'SIGHUP'].map(s => process.listenerCount(s))).toEqual(before.map(n => n + 1)); r1(); r1(); expect(process.listenerCount('SIGHUP')).toBe(before[2]! + 1); r2(); expect(guardedPids()).toEqual([]); expect(['SIGINT', 'SIGTERM', 'SIGHUP'].map(s => process.listenerCount(s))).toEqual(before); }); }); describe('headless runs on macOS never use Google Chrome.app when another Chromium is there', () => { const i = { storeboundChrome: null, chrome: CHROME, chromium: '/pw/Google Chrome for Testing' }; test('headless on macOS: Chromium first; a window: Google Chrome first; Linux unchanged; only Chrome: Chrome', () => { expect(persistentExecutable('visible', i, {}, { headless: true, platform: 'darwin' }).path).toBe(i.chromium); expect(persistentExecutable('visible', i, {}, { headless: false, platform: 'darwin' }).path).toBe(CHROME); expect(persistentExecutable('visible', i, {}, { headless: true, platform: 'linux' }).path).toBe(CHROME); expect(persistentExecutable('visible', { ...i, chromium: null }, {}, { headless: true, platform: 'darwin' }).path).toBe(CHROME); expect(persistentExecutable('chrome', i, {}, { headless: true, platform: 'darwin' }).path).toBe(CHROME); // the copy needs real Chrome }); }); // ---- real processes (sh + sleep stand in for Chrome and its run) ---- const posix = process.platform !== 'win32'; const alive = (pid: number) => { try { process.kill(pid, 0); } catch { return false; } return !isZombie(pid); }; async function until(what: string, ok: () => boolean, ms = 5000): Promise { const end = Date.now() + ms; while (!ok()) { if (Date.now() > end) throw new Error(`timed out after ${ms} ms waiting for: ${what}`); await rt.sleep(50); } } const tag = () => `storebound-profile-guardtest-${process.pid}-${Math.random().toString(36).slice(2, 8)}`; describe.skipIf(!posix)('real processes', () => { test.concurrent('the watchdog ends the child once its parent dies (a SIGKILL included)', async () => { const marker = `--user-data-dir=/tmp/${tag()}`; const parent = rt.spawn(['sleep', '30'], { stdout: 'ignore', stderr: 'ignore' }); const child = rt.spawn(['/bin/sh', '-c', `sleep 30; : ${marker}`], { stdout: 'ignore', stderr: 'ignore' }); const dog = startWatchdog(child.pid!, marker, { parent: parent.pid!, intervalS: 0.1 })!; try { await rt.sleep(300); expect(alive(child.pid!)).toBe(true); // parent alive: nothing happens parent.kill('SIGKILL'); await until('the child ended', () => !alive(child.pid!)); expect(await dog.exited).toBe(0); } finally { parent.kill('SIGKILL'); child.kill('SIGKILL'); dog.kill('SIGKILL'); } }, 10_000); test.concurrent('a pid whose command line no longer names the profile (reused) is left alone', async () => { const parent = rt.spawn(['sleep', '30'], { stdout: 'ignore', stderr: 'ignore' }); const other = rt.spawn(['/bin/sh', '-c', 'sleep 30; : unrelated'], { stdout: 'ignore', stderr: 'ignore' }); const dog = startWatchdog(other.pid!, `--user-data-dir=/tmp/${tag()}`, { parent: parent.pid!, intervalS: 0.1 })!; try { expect(await dog.exited).toBe(0); parent.kill('SIGKILL'); await rt.sleep(300); expect(alive(other.pid!)).toBe(true); } finally { parent.kill('SIGKILL'); other.kill('SIGKILL'); } }, 10_000); test.concurrent('the reaper ends a real headless orphan with a storebound profile', async () => { const t = tag(); const fake = `sleep 20; : --user-data-dir=/tmp/${t} --remote-debugging-port=0 --headless=new`; // the outer sh exits at once: the inner one is re-parented (PID 1 or a subreaper), like a Chrome whose run died await rt.spawn(['/bin/sh', '-c', `/bin/sh -c '${fake}' >/dev/null 2>&1 &`], { stdout: 'ignore', stderr: 'ignore' }).exited; const find = () => staleChromes(parsePs(rt.spawnSync(['ps', '-A', '-ww', '-o', 'pid=,ppid=,command=']).stdout.toString()), [t]).reap; await until('the orphan shows up', () => find().length === 1); const [orphan] = find(); const said: string[] = []; const out = await reapStaleChromes({ markers: [t], say: m => said.push(m) }); expect(out.reaped).toEqual([orphan!.pid]); expect(alive(orphan!.pid)).toBe(false); expect(said[0]).toContain(`process ${orphan!.pid}`); }, 15_000); });