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:
CRTkafa 2026-10-15 00:28:32 +08:00
parent b350781ab5
commit 89d94d6993
10 changed files with 372 additions and 17 deletions

View file

@ -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 });
})();
}