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
|
|
@ -1,5 +1,13 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **A Chrome storebound starts never outlives its run.** The console window's Chrome is ended on exit, closed
|
||||
gracefully on Ctrl+C / SIGTERM / SIGHUP, and ended by a watchdog when the run dies in any way (SIGKILL included).
|
||||
Every start first ends headless storebound Chromes earlier runs left behind (an orphan kept the owner's own Chrome
|
||||
from opening from the macOS Dock) and names orphaned windows. Headless runs on macOS prefer Chromium / Chrome for
|
||||
Testing over Google Chrome.app. `console fill` closes the window on an error too. Pitfall 68.
|
||||
|
||||
## 0.8.4 · 2026-10-14
|
||||
|
||||
Existing products' prices never change without `--allow-price-changes`; PPP pricing compares like with like and keeps
|
||||
|
|
|
|||
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 });
|
||||
})();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -203,6 +203,8 @@ export interface SpawnOptions {
|
|||
stderr?: Io | null;
|
||||
/** [stdin, stdout, stderr]; wins over the single options. */
|
||||
stdio?: [Io | null, Io | null, Io | null];
|
||||
/** Its own process group (session): a Ctrl+C sent to the terminal's group does not reach it. */
|
||||
detached?: boolean;
|
||||
}
|
||||
|
||||
/** A running child, shaped like Bun's Subprocess (the parts storebound uses). */
|
||||
|
|
@ -258,7 +260,7 @@ export function spawn(argv: string[], o: SpawnOptions = {}): Subprocess {
|
|||
const cmd = checkExecutable(argv[0], o);
|
||||
const [i0, o0, e0] = o.stdio ?? [o.stdin, o.stdout, o.stderr];
|
||||
const stdio: StdioOptions = [io(i0, 'ignore'), io(o0, 'pipe'), io(e0, 'inherit')];
|
||||
const child = nodeSpawn(cmd, argv.slice(1), { cwd: o.cwd, env: envOf(o.env) ?? process.env, stdio, windowsHide: true });
|
||||
const child = nodeSpawn(cmd, argv.slice(1), { cwd: o.cwd, env: envOf(o.env) ?? process.env, stdio, windowsHide: true, ...(o.detached ? { detached: true } : {}) });
|
||||
let exitCode: number | null = null, signalCode: string | null = null;
|
||||
const exited = new Promise<number>(res => {
|
||||
let done = false;
|
||||
|
|
|
|||
|
|
@ -176,11 +176,16 @@ export interface LaunchDeps {
|
|||
* STOREBOUND_CHROME, Google Chrome, then Playwright's full Chromium; kind "chrome" = Google Chrome only. Never the
|
||||
* headless shell. Nothing installed: the same error as withCandidates.
|
||||
*/
|
||||
export function persistentExecutable(kind: 'visible' | 'chrome', i: Installed, env: Record<string, string | undefined> = process.env): { label: string; path: string } {
|
||||
export function persistentExecutable(kind: 'visible' | 'chrome', i: Installed, env: Record<string, string | undefined> = process.env, o: { headless?: boolean; platform?: string } = {}): { label: string; path: string } {
|
||||
const list: { label: string; path: string }[] = [];
|
||||
if (kind === 'visible' && i.storeboundChrome) list.push({ label: `STOREBOUND_CHROME (${i.storeboundChrome})`, path: i.storeboundChrome });
|
||||
// headless on macOS (tests): Playwright's Chromium first. LaunchServices counts a headless run of Google Chrome.app as
|
||||
// the running "Google Chrome", so while it runs the Dock's Chrome only activates it and the owner's Chrome stays
|
||||
// closed (October 2026). Chromium / Chrome for Testing has a bundle id of its own.
|
||||
const chromiumFirst = kind === 'visible' && o.headless === true && (o.platform ?? process.platform) === 'darwin';
|
||||
if (chromiumFirst && i.chromium) list.push({ label: `Playwright Chromium (${i.chromium})`, path: i.chromium });
|
||||
if (i.chrome) list.push({ label: `Google Chrome (${i.chrome})`, path: i.chrome });
|
||||
if (kind === 'visible' && i.chromium) list.push({ label: `Playwright Chromium (${i.chromium})`, path: i.chromium });
|
||||
if (kind === 'visible' && !chromiumFirst && i.chromium) list.push({ label: `Playwright Chromium (${i.chromium})`, path: i.chromium });
|
||||
if (list[0]) return list[0];
|
||||
const skipped = env.STOREBOUND_CHROME && !i.storeboundChrome && kind !== 'chrome' ? `; STOREBOUND_CHROME ${env.STOREBOUND_CHROME}: no such file` : '';
|
||||
throw new Error(`no usable browser (looked for ${LOOKED_FOR[kind]})${skipped}. `
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ fails without the fix.
|
|||
|
||||
| # | date | lesson | guard |
|
||||
|---|---|---|---|
|
||||
| 68 | 2026-10 | A headless Chrome started by storebound's console automation (a test run's `--user-data-dir=$TMPDIR/storebound-profile-apple-…`, `--remote-debugging-port=0 --headless=new`) outlived its parent for ~20 hours as an orphan (parent PID 1). macOS LaunchServices took it for the running "Google Chrome", so the Dock's Chrome only activated the windowless instance and the owner's own Chrome would not open. A Chrome started with a DevTools port does not notice its parent dying. | `cli/console/chrome-guard.ts`: every Chrome storebound starts itself is ended on process exit, closed gracefully on SIGINT / SIGTERM / SIGHUP, and ended by a detached watchdog when the parent dies (SIGKILL included; a reused pid is never killed); every start first ends headless orphans on storebound's profiles and names orphaned windows; headless runs on macOS prefer Chromium / Chrome for Testing over Google Chrome.app; `console fill` closes the window on an error too (`tests/chrome-guard.test.ts`). |
|
||||
| 63 | 2026-10 | STAR DASH: re-running `play products` / `prices import` to create 8 new IAPs also changed the live prices of existing products; `--only` was needed. In a terminal (no `--yes`) the one confirmation covered price changes too. | Price changes of existing products need `--allow-price-changes`, TTY or not; the terminal question covers creates and other updates only; with the flag a second question lists each old -> new (asked in a terminal even with `--yes`); dry runs show a "price changes on existing products (held)" section (`allowPriceChanges`, `heldSection` in `cli/lib/price-gate.ts`; `play products`, `prices import`, `play subscriptions`, `apple subscriptions`). `tests/price-gate.test.ts` (TTY and non-TTY). |
|
||||
| 62 | 2026-10 | STAR DASH: `prices ppp` cut high-income markets (Germany EUR 3.59 instead of 5.99 for USD 4.99; UK, France, Japan cut too): it set base x PPP level x exchange rate, a net price, against VAT-inclusive store prices, and cut every region. | Par = the store's own conversion of the US price (Play convertRegionPrices, Apple equalizations; offline base x fx x (1 + VAT) rounded up); share = clamp(PPP level, 0.3, 1); defaults: only decreases, PPP only where the level is <= 0.5, World Bank high-income economies and CN at par, Play skips CN, top-10 revenue market drops > 20 % warned (from `.storebound/reports/` sales reports, else a static list); unchanged regions are left out of the sheet (`shareOf`, `offlinePar`, `planRows`, `topMarketDrops`, `topMarketsFromReports` in `cli/lib/ppp.ts`). `tests/ppp.test.ts`. |
|
||||
| 61 | 2026-10 | STAR DASH: `prices ppp --json` printed log lines on stdout before the JSON, so parsing stdout failed. | `withJsonOutput` (`cli/lib/log.ts`) wraps every `--json` run in `cli/index.ts`: console.log / info go to stderr, `printJson` writes stdout, prompts use `promptOut()`; commands that printed JSON with console.log use `printJson`. `tests/json-stdout.test.ts` runs every command that reads `--json` against the fake stores and parses stdout. |
|
||||
|
|
|
|||
|
|
@ -779,3 +779,18 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
- **Guard:** `checkoutInfo`, `builtIpaEntitlements` (cli/apple/signing.ts), `machoEntitlements`,
|
||||
`compareCapabilities` (cli/lib/signing.ts), `tests/signing-capabilities.test.ts` (entitlements only on another
|
||||
branch; IPAs with entitlements).
|
||||
|
||||
## 68. The owner's Chrome would not open: an orphaned headless storebound Chrome
|
||||
- **Symptom:** clicking Chrome in the Dock does nothing (no window); `ps` shows a Chrome with
|
||||
`--user-data-dir=…storebound-profile-…` (or `…/.storebound/console-profile/…`, `storebound-chrome-…`),
|
||||
`--remote-debugging-port=0 --headless=new` and parent PID 1.
|
||||
- **Cause:** a Chrome storebound started for a console flow (here a test run) outlived its run. Started with a
|
||||
DevTools port, Chrome does not notice its parent dying; macOS LaunchServices then counts the windowless instance
|
||||
as the running "Google Chrome", so the Dock only activates it.
|
||||
- **Do:** run any `storebound console …` command (or `bun test tests/chrome-guard.test.ts`): every start ends such
|
||||
headless orphans and names orphaned windows. By hand: `kill <pid>` of the process without `--type=`. Never kill a
|
||||
Chrome whose parent is a running storebound (a run that waits for its window), or the owner's own Chrome.
|
||||
- **Guard:** `guardChrome` / `startWatchdog` / `reapStaleChromes` (cli/console/chrome-guard.ts): exit, Ctrl+C,
|
||||
SIGTERM / SIGHUP and the parent's death (SIGKILL included) end the Chrome; `console fill` closes it on errors too;
|
||||
headless runs on macOS prefer Chromium / Chrome for Testing (its own bundle id) over Google Chrome.app
|
||||
(`persistentExecutable`); `tests/chrome-guard.test.ts`.
|
||||
|
|
|
|||
140
tests/chrome-guard.test.ts
Normal file
140
tests/chrome-guard.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// 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, 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); return true; } catch { return false; } };
|
||||
async function until(what: string, ok: () => boolean, ms = 5000): Promise<void> {
|
||||
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);
|
||||
});
|
||||
|
|
@ -95,11 +95,13 @@ const notInstalled = (e: unknown) => /Executable doesn't exist|distribution '[^'
|
|||
* --restore-last-session, so session cookies are dropped on relaunch. `label` is null when neither is installed.
|
||||
*/
|
||||
export async function persistentBrowser(): Promise<{ label: string | null; skipWhy: string }> {
|
||||
const { installedBrowsers, launchCandidates } = await import('../../cli/shots/browser');
|
||||
const { installedBrowsers, persistentExecutable } = await import('../../cli/shots/browser');
|
||||
let pw: any = null;
|
||||
try { pw = (await import('playwright')).chromium; } catch (e) { return { label: null, skipWhy: `playwright is not installed (${firstLine(e)})` }; }
|
||||
// STOREBOUND_CHROME left out: the tests pin the resolver's choice of an installed Chrome / full Chromium
|
||||
const c = launchCandidates('visible', installedBrowsers(pw, { ...process.env, STOREBOUND_CHROME: undefined }))[0];
|
||||
// STOREBOUND_CHROME left out: the tests pin the resolver's choice of an installed Chrome / full Chromium (headless on
|
||||
// macOS: Chromium first, so a test run never counts as the owner's running "Google Chrome", cli/console/chrome-guard.ts)
|
||||
let c: { label: string } | null = null;
|
||||
try { c = persistentExecutable('visible', installedBrowsers(pw, { ...process.env, STOREBOUND_CHROME: undefined }), {}, { headless: true }); } catch { /* none */ }
|
||||
return c ? { label: c.label, skipWhy: '' }
|
||||
: { label: null, skipWhy: 'neither Google Chrome nor Playwright\'s full Chromium is installed (the headless shell drops session cookies on relaunch); install Google Chrome or run `bunx playwright install chromium`' };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue