console Chrome never outlives its run: exit kills it, SIGINT / SIGTERM / SIGHUP close it gracefully, a detached watchdog ends it when the parent dies (SIGKILL included; a reused pid is never killed); every start ends headless orphans on storebound profiles and names orphaned windows; headless runs on macOS prefer Chromium / Chrome for Testing over Google Chrome.app (an orphan kept the owner's Chrome from opening from the Dock); console fill closes the window on errors; pitfall 68; tests
This commit is contained in:
parent
b350781ab5
commit
89d94d6993
10 changed files with 372 additions and 17 deletions
169
cli/console/chrome-guard.ts
Normal file
169
cli/console/chrome-guard.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// 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<number, PsRow>): 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 };
|
||||
}
|
||||
|
||||
function alive(pid: number): boolean {
|
||||
try { process.kill(pid, 0); return true; } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM'; }
|
||||
}
|
||||
|
||||
/** SIGTERM (Chromium's clean shutdown), then SIGKILL after `graceMs`. True when the process is gone. */
|
||||
export async function endProcess(pid: number, graceMs = 5000): Promise<boolean> {
|
||||
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<void> }
|
||||
const live = new Map<number, Guarded>();
|
||||
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<string, number>)[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<void>, 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()];
|
||||
|
|
@ -928,11 +928,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
...(args.get('cdp') ? { cdp: args.get('cdp') } : {}), ...(args.get('chrome-profile') ? { chromeProfile: args.get('chrome-profile') } : {}),
|
||||
useChromeSession: parseUseChromeSession(args.get('use-chrome-session') ?? (args.has('use-chrome-session') ? true : undefined)),
|
||||
});
|
||||
// Ctrl+C: close the browser gracefully (Chromium writes its cookies on a clean exit, so the sign-in is kept for the
|
||||
// next run), then remove the temporary profile of copy mode; that also happens on every other exit path
|
||||
let stopping = false;
|
||||
const bye = () => { if (stopping) return; stopping = true; void b.close().finally(() => { b.cleanup(); process.exit(130); }); };
|
||||
process.on('SIGINT', bye); process.on('SIGTERM', bye);
|
||||
// Ctrl+C / SIGTERM / SIGHUP close the browser gracefully (Chromium writes its cookies on a clean exit, so the sign-in
|
||||
// is kept for the next run) and remove the temporary profile of copy mode: cli/console/chrome-guard.ts. Every other
|
||||
// exit path (an error, a timeout) closes it in `finally`: a window left without its run becomes an orphan.
|
||||
try {
|
||||
// App Privacy opens by deep link when the app id is known (config, cache or the API by bundle id), else by the list
|
||||
const cfg = form === 'apple-app-privacy' && !startUrl ? withAppleAppId(config, await appleAppId(root, config, { say })) : config;
|
||||
|
|
@ -946,7 +944,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (out.debugDir) say(`debug dump: ${out.debugDir} (page.json, page.html, screen.jpg per step; no values, cookies or tokens; look at screen.jpg before you attach it to a bug report)`);
|
||||
if (out.timedOut) process.exitCode = 1;
|
||||
if (json) printJson({ ...res, ...out, stoppedAt: meta.stopBefore, browser: b.mode });
|
||||
} finally { b.cleanup(); process.off('SIGINT', bye); process.off('SIGTERM', bye); }
|
||||
} finally {
|
||||
if (!b.isClosed()) await b.close();
|
||||
b.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export interface FillSessionOpts {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@
|
|||
// kills it, so Chromium flushes its cookie store. Apple keeps a long sign-in only with "Keep me signed in" / "Trust
|
||||
// this browser" ticked (storebound says so when it sees the sign-in page).
|
||||
// Cookies are never printed, logged or sent anywhere: only file names and counts are reported.
|
||||
// The Chrome storebound starts never outlives its run (cli/console/chrome-guard.ts, an October 2026 incident: an
|
||||
// orphaned headless Chrome kept the owner's own Chrome from opening from the Dock): exit, Ctrl+C, SIGTERM / SIGHUP and
|
||||
// a watchdog for the parent's death end it, and every start first ends the headless ones earlier runs left behind.
|
||||
// Sources (checked October 2026): DBSC https://blog.google/security/protecting-cookies-with-device-bound-session-credentials/
|
||||
// and https://developer.chrome.com/docs/web-platform/device-bound-session-credentials ; Google's sign-out on suspicious
|
||||
// session cookies https://knowledge.workspace.google.com/admin/security/investigate-and-take-action-on-suspicious-session-cookies ;
|
||||
|
|
@ -43,6 +46,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync
|
|||
import { homedir, tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { chromiumFor, installedBrowsers, type LaunchDeps, persistentExecutable, profileHolder, sandboxArgs } from '../shots/browser';
|
||||
import { guardChrome, reapStaleChromes } from './chrome-guard';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
|
@ -280,6 +284,7 @@ export function browserPlan(o: Pick<OpenOpts, 'store' | 'cdp' | 'fresh' | 'useCh
|
|||
* is closed (macOS keeps Chrome running without windows: then storebound closes it gracefully).
|
||||
*/
|
||||
async function startOwnChrome(root: string, mode: 'copy' | 'profile', exe: { label: string; path: string }, userDataDir: string, o: OpenOpts, cleanup: () => void): Promise<ConsoleBrowser> {
|
||||
await reapStaleChromes({ say: o.say });
|
||||
const holder = profileHolder(userDataDir);
|
||||
if (holder) throw new Error(`the browser profile ${userDataDir} is still open in another browser (process ${holder}), probably an earlier storebound run that waits for its window to close. Close that window (or end that run) and start again`);
|
||||
const portFile = join(userDataDir, 'DevToolsActivePort');
|
||||
|
|
@ -287,7 +292,12 @@ async function startOwnChrome(root: string, mode: 'copy' | 'profile', exe: { lab
|
|||
const args = consoleChromeArgs(userDataDir, { headless: o.headless ?? false });
|
||||
const proc = rt.spawn([exe.path, ...args], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' });
|
||||
let exited = false;
|
||||
const gone = proc.exited.then(() => { exited = true; });
|
||||
// eslint-disable-next-line prefer-const
|
||||
let close: () => Promise<void>;
|
||||
// until it exits: process exit, a signal (graceful close, then the copy is removed) or this process dying ends it
|
||||
const release = proc.pid ? guardChrome(proc.pid, `--user-data-dir=${userDataDir}`, async () => { await close(); cleanup(); }) : () => {};
|
||||
const gone = proc.exited.then(() => { exited = true; release(); });
|
||||
close = async () => { if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, rt.sleep(5000)]); } };
|
||||
const end = Date.now() + (o.launchTimeoutMs ?? 30_000);
|
||||
let endpoint: string | null = null;
|
||||
while (!endpoint && !exited && Date.now() < end) { endpoint = devToolsEndpoint(userDataDir); if (!endpoint) await rt.sleep(100); }
|
||||
|
|
@ -304,15 +314,17 @@ async function startOwnChrome(root: string, mode: 'copy' | 'profile', exe: { lab
|
|||
throw new Error(`${exe.label}: could not attach (${(e as Error).message.split('\n')[0]})`, { cause: e });
|
||||
}
|
||||
const context = browser.contexts()[0] ?? await browser.newContext();
|
||||
const close = async () => {
|
||||
let closing: Promise<void> | null = null;
|
||||
close = () => (closing ??= (async () => {
|
||||
if (exited) return;
|
||||
await gracefully(async () => { const s = await browser.newBrowserCDPSession(); await s.send('Browser.close'); });
|
||||
await Promise.race([gone, rt.sleep(10_000)]);
|
||||
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, rt.sleep(5000)]); }
|
||||
};
|
||||
})());
|
||||
let closed = false;
|
||||
const done = gone.then(async () => { closed = true; cleanup(); await rt.sleep(300); cleanup(); });
|
||||
const page = await ownTab(context);
|
||||
let page: any;
|
||||
try { page = await ownTab(context); } catch (e) { await close(); throw e; } // never a running Chrome without its run
|
||||
const watch = (p: any) => p.on('close', () => { if (!exited && context.pages().length === 0) void close(); });
|
||||
for (const p of context.pages()) watch(p);
|
||||
context.on('page', watch);
|
||||
|
|
@ -323,7 +335,7 @@ async function startOwnChrome(root: string, mode: 'copy' | 'profile', exe: { lab
|
|||
function executableFor(root: string, kind: 'visible' | 'chrome', o: OpenOpts): Promise<{ label: string; path: string }> {
|
||||
return (async () => {
|
||||
const installed = o.launchDeps?.installed ?? installedBrowsers(o.launchDeps?.pw ?? await chromiumFor(root).catch(() => null));
|
||||
return persistentExecutable(kind, installed);
|
||||
return persistentExecutable(kind, installed, process.env, { headless: o.headless ?? false, platform: o.platform ?? process.platform });
|
||||
})();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue