mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
D5: per-project headed Chromium profiles; never kill a live profile owner (#2492)
The headed profile moves from <gstack home>/chromium-profile to <project>/.gstack/chromium-profile (CHROMIUM_PROFILE still wins). killOrphanChromium now kills a SingletonLock PID only when the lock names this host, the process is a Chromium using that profile, and its daemon is gone (reparented to init or a subreaper); a live owner or another host's lock stops browse with the holder named. On first headed use a project profile is seeded from the old shared profile only when nothing holds it: copied without Singleton* files into a 0700 temp dir and renamed into place, with a one-time notice; otherwise it starts fresh and says how to import later. New: browse profiles [list] and browse profiles prune [--days N] (never removes a profile in use or the old shared one). The #2817 headed-startup tests move to the project profile and a real orphan (the old stand-in was a live child of the test). Co-authored-by: miao <miao@macmini-2.local>
This commit is contained in:
1 parent
3f712a4e60
commit
88b480eaed
6 files changed
+405
-52
No files matched your search
@@ -23,6 +23,7 @@ import { emitActivity } from './activity';
|
||||
import { validateNavigationUrl, blockedNavigationReason } from './url-validation';
|
||||
import { TabSession, type RefEntry } from './tab-session';
|
||||
import { resolveChromiumProfile, cleanSingletonLocks, resolveConfig } from './config';
|
||||
import { ensureProjectProfile } from './chromium-profiles';
|
||||
import { launchWithXProtectHeal } from './xprotect-heal';
|
||||
import { readPidStartTime, shouldSpawnXvfb, pickFreeDisplay, spawnXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb';
|
||||
import { withCdpSession } from './cdp-bridge';
|
||||
@@ -750,6 +751,7 @@ export class BrowserManager {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const userDataDir = resolveChromiumProfile();
|
||||
ensureProjectProfile(userDataDir);
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
|
||||
// Pre-launch cleanup of stale SingletonLock/Socket/Cookie. Chromium's
|
||||
@@ -1913,6 +1915,7 @@ export class BrowserManager {
|
||||
// ignoring $CHROMIUM_PROFILE / $GSTACK_HOME and skipping the lock
|
||||
// cleanup — the third shipped drift between the three launch paths.
|
||||
const userDataDir = resolveChromiumProfile();
|
||||
ensureProjectProfile(userDataDir);
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
cleanSingletonLocks(userDataDir);
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Per-project headed Chromium profiles (D5, #2492).
|
||||
*
|
||||
* The headed profile used to be machine-wide (<gstack home>/chromium-profile),
|
||||
* so starting headed browse in one project SIGKILLed another project's live
|
||||
* headed Chromium. Each project now gets <project>/.gstack/chromium-profile
|
||||
* (config.resolveChromiumProfile). This module owns the profile lifecycle:
|
||||
* who holds a profile's SingletonLock, first-use seeding from the old
|
||||
* machine-wide profile, and `browse profiles list|prune`.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { resolveGstackHome } from './config';
|
||||
import { isProcessAlive } from './error-handling';
|
||||
import { readPidCmdline } from './xvfb';
|
||||
import { atomicWriteSync } from '../../lib/fs-atomic';
|
||||
|
||||
export type ProfileOwner =
|
||||
| { state: 'free' }
|
||||
| { state: 'orphan'; pid: number }
|
||||
| { state: 'live'; pid: number; detail: string };
|
||||
|
||||
/** Processes an orphan is reparented to: init, launchd, and common subreapers. */
|
||||
const REAPERS = new Set(['init', 'systemd', 'launchd', 'tini', 'dumb-init', 'docker-init']);
|
||||
|
||||
export function legacyProfileDir(): string {
|
||||
return path.join(resolveGstackHome(), 'chromium-profile');
|
||||
}
|
||||
|
||||
function registryPath(): string {
|
||||
return path.join(resolveGstackHome(), 'browse-profiles.json');
|
||||
}
|
||||
|
||||
function readParentPid(pid: number): number {
|
||||
try {
|
||||
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf-8');
|
||||
return Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]);
|
||||
} catch {
|
||||
try {
|
||||
const r = Bun.spawnSync(['ps', '-o', 'ppid=', '-p', String(pid)], { stdout: 'pipe', stderr: 'pipe', timeout: 2000, windowsHide: true });
|
||||
return r.exitCode === 0 ? Number(r.stdout.toString().trim()) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who holds this profile. The SingletonLock symlink target is "host-PID".
|
||||
* Only a process on this host that is a Chromium using this exact profile
|
||||
* counts as the holder; a dead or recycled PID means the lock is stale. An
|
||||
* orphan (reparented to init or a subreaper after its daemon died) may be
|
||||
* reaped; anything else, including a lock from another host, is a live owner
|
||||
* that must never be killed.
|
||||
*/
|
||||
export function profileOwner(profileDir: string): ProfileOwner {
|
||||
let target: string;
|
||||
try {
|
||||
target = fs.readlinkSync(path.join(profileDir, 'SingletonLock'));
|
||||
} catch {
|
||||
return { state: 'free' };
|
||||
}
|
||||
const dash = target.lastIndexOf('-');
|
||||
const host = target.slice(0, dash);
|
||||
const pid = Number(target.slice(dash + 1));
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return { state: 'free' };
|
||||
if (host !== os.hostname()) {
|
||||
return { state: 'live', pid, detail: `locked by PID ${pid} on another host (${host})` };
|
||||
}
|
||||
if (!isProcessAlive(pid)) return { state: 'free' };
|
||||
const cmd = readPidCmdline(pid);
|
||||
const dirs = new Set([profileDir]);
|
||||
try { dirs.add(fs.realpathSync(profileDir)); } catch {}
|
||||
const usesProfile = [...dirs].some(d => cmd.includes(`--user-data-dir=${d}`));
|
||||
if (!/chrom/i.test(cmd) || !usesProfile) return { state: 'free' };
|
||||
const ppid = readParentPid(pid);
|
||||
const parent = ppid > 1 ? path.basename(readPidCmdline(ppid).split(' ')[0] || '') : '';
|
||||
if (ppid <= 1 || !isProcessAlive(ppid) || REAPERS.has(parent)) return { state: 'orphan', pid };
|
||||
return { state: 'live', pid, detail: `in use by Chromium PID ${pid} (started by PID ${ppid}: ${parent || 'unknown'})` };
|
||||
}
|
||||
|
||||
function readRegistry(): string[] {
|
||||
try {
|
||||
const list = JSON.parse(fs.readFileSync(registryPath(), 'utf-8'));
|
||||
return Array.isArray(list) ? list.filter((p): p is string => typeof p === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeRegistry(list: string[]): void {
|
||||
fs.mkdirSync(path.dirname(registryPath()), { recursive: true });
|
||||
atomicWriteSync(registryPath(), JSON.stringify([...new Set(list)].sort(), null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a project's headed profile on first use. Seeds it from the old
|
||||
* machine-wide profile only when nothing holds that profile, copying into a
|
||||
* temporary 0700 directory without Singleton* files and renaming it into
|
||||
* place, so a concurrent first use or an interrupted copy never leaves a
|
||||
* half-written profile. Says what happened once, through `log`.
|
||||
*/
|
||||
export function ensureProjectProfile(profileDir: string, log: (msg: string) => void = console.log): void {
|
||||
if (fs.existsSync(profileDir)) return;
|
||||
const legacy = legacyProfileDir();
|
||||
if (path.resolve(profileDir) === path.resolve(legacy)) return;
|
||||
writeRegistry([...readRegistry(), path.resolve(profileDir)]);
|
||||
fs.mkdirSync(path.dirname(profileDir), { recursive: true });
|
||||
const fresh = (why: string) => {
|
||||
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
|
||||
if (why) log(why);
|
||||
};
|
||||
if (!fs.existsSync(legacy)) return fresh('');
|
||||
const before = profileOwner(legacy);
|
||||
if (before.state !== 'free') {
|
||||
return fresh(`[browse] Headed browse now keeps a profile per project (${profileDir}). The old shared profile (${legacy}) is ${'detail' in before ? before.detail : `held by orphaned PID ${before.pid}`}, so this project starts with a fresh profile. To import it later: close that browser, delete ${profileDir}, and start headed browse again.`);
|
||||
}
|
||||
const tmp = `${profileDir}.seed-${process.pid}`;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
try {
|
||||
fs.cpSync(legacy, tmp, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
filter: (src) => !path.basename(src).startsWith('Singleton'),
|
||||
});
|
||||
fs.chmodSync(tmp, 0o700);
|
||||
if (profileOwner(legacy).state !== 'free') {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
return fresh(`[browse] The old shared profile (${legacy}) came into use while it was being copied, so this project (${profileDir}) starts with a fresh profile.`);
|
||||
}
|
||||
fs.renameSync(tmp, profileDir);
|
||||
} catch (err) {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
if (fs.existsSync(profileDir)) return;
|
||||
throw err;
|
||||
}
|
||||
log(`[browse] Headed browse now keeps a profile per project. Copied your logins and settings from ${legacy} to ${profileDir} (${formatBytes(dirBytes(profileDir))}). See \`browse profiles\` to list or prune profiles.`);
|
||||
}
|
||||
|
||||
function dirBytes(dir: string): number {
|
||||
let total = 0;
|
||||
const walk = (d: string) => {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const p = path.join(d, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (e.isFile()) total += fs.statSync(p).size;
|
||||
}
|
||||
};
|
||||
try { walk(dir); } catch {}
|
||||
return total;
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function describeOwner(owner: ProfileOwner): string {
|
||||
if (owner.state === 'free') return 'idle';
|
||||
if (owner.state === 'orphan') return `held by orphaned PID ${owner.pid}`;
|
||||
return owner.detail;
|
||||
}
|
||||
|
||||
/** `browse profiles [list]` and `browse profiles prune [--days N]`. Returns the exit code. */
|
||||
export function runProfilesCommand(args: string[], out: (line: string) => void = console.log): number {
|
||||
const sub = args[0] ?? 'list';
|
||||
const known = readRegistry();
|
||||
const legacy = legacyProfileDir();
|
||||
if (sub === 'list') {
|
||||
const rows = [...(fs.existsSync(legacy) ? [legacy] : []), ...known];
|
||||
if (rows.length === 0) {
|
||||
out('No headed browser profiles yet. One is created per project on first `browse --headed` or `browse connect`.');
|
||||
return 0;
|
||||
}
|
||||
for (const dir of rows) {
|
||||
if (!fs.existsSync(dir)) { out(`${dir} (missing: project moved or deleted; \`browse profiles prune\` forgets it)`); continue; }
|
||||
const age = Math.floor((Date.now() - fs.statSync(dir).mtimeMs) / 86_400_000);
|
||||
out(`${dir} ${formatBytes(dirBytes(dir))} last used ${age}d ago ${describeOwner(profileOwner(dir))}${dir === legacy ? ' (old shared profile)' : ''}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (sub === 'prune') {
|
||||
const daysIdx = args.indexOf('--days');
|
||||
const days = daysIdx >= 0 ? Number(args[daysIdx + 1]) : 30;
|
||||
if (!Number.isFinite(days) || days < 0) {
|
||||
out('Usage: browse profiles prune [--days N] (default 30)');
|
||||
return 2;
|
||||
}
|
||||
const keep: string[] = [];
|
||||
let freed = 0;
|
||||
for (const dir of known) {
|
||||
if (!fs.existsSync(dir)) { out(`forgot ${dir} (no longer exists)`); continue; }
|
||||
const idleDays = (Date.now() - fs.statSync(dir).mtimeMs) / 86_400_000;
|
||||
const owner = profileOwner(dir);
|
||||
if (idleDays < days || owner.state !== 'free') { keep.push(dir); continue; }
|
||||
const bytes = dirBytes(dir);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
freed += bytes;
|
||||
out(`removed ${dir} (${formatBytes(bytes)}, idle ${Math.floor(idleDays)}d)`);
|
||||
}
|
||||
writeRegistry(keep);
|
||||
out(`Pruned profiles idle for ${days}+ days; freed ${formatBytes(freed)}. Profiles in use are never removed. The old shared profile (${legacy}) is left alone.`);
|
||||
return 0;
|
||||
}
|
||||
out('Usage: browse profiles [list] | browse profiles prune [--days N]');
|
||||
return 2;
|
||||
}
|
||||
+19
-16
@@ -19,6 +19,7 @@ import { resolveConfig, ensureStateDir, readVersionHash, isPairAgentEnabled, res
|
||||
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config';
|
||||
import { redactProxyUrl } from './proxy-redact';
|
||||
import { spawnTerminalAgent, BUN_CHILD_FLAGS } from './terminal-agent-control';
|
||||
import { profileOwner, ensureProjectProfile, runProfilesCommand } from './chromium-profiles';
|
||||
// Zero side effects on import (documented invariant in token-registry.ts) —
|
||||
// safe to pull the shared pairing default into the CLI.
|
||||
import { DEFAULT_PAIR_SCOPES } from './token-registry';
|
||||
@@ -285,23 +286,21 @@ function cleanChromiumProfileLocks(profileDir: string = chromiumProfileDir()): v
|
||||
}
|
||||
}
|
||||
|
||||
/** Kill an orphaned Chromium that still holds the profile's SingletonLock. The
|
||||
* lock symlink target is "hostname-PID"; killing that PID tears down its
|
||||
* renderer tree so the next launch starts clean. No-op when absent/stale. */
|
||||
/** Kill an orphaned Chromium that still holds the profile's SingletonLock so
|
||||
* the next launch starts clean (#1781). Only a Chromium on this host that uses
|
||||
* this profile and whose daemon is gone is killed; a profile held by a live
|
||||
* process is never killed — browse stops and names the holder (D5, #2492). */
|
||||
async function killOrphanChromium(profileDir: string = chromiumProfileDir()): Promise<void> {
|
||||
try {
|
||||
const lockTarget = fs.readlinkSync(path.join(profileDir, 'SingletonLock')); // "hostname-12345"
|
||||
const orphanPid = parseInt(lockTarget.split('-').pop() || '', 10);
|
||||
if (orphanPid && isProcessAlive(orphanPid)) {
|
||||
safeKill(orphanPid, 'SIGTERM');
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
if (isProcessAlive(orphanPid)) {
|
||||
safeKill(orphanPid, 'SIGKILL');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT' && err?.code !== 'EINVAL') throw err;
|
||||
const owner = profileOwner(profileDir);
|
||||
if (owner.state === 'free') return;
|
||||
if (owner.state === 'live') {
|
||||
throw new Error(`Headed Chromium profile ${profileDir} is ${owner.detail}. Close that browser first, or set CHROMIUM_PROFILE to a different directory.`);
|
||||
}
|
||||
safeKill(owner.pid, 'SIGTERM');
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
if (isProcessAlive(owner.pid)) {
|
||||
safeKill(owner.pid, 'SIGKILL');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,6 +620,7 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
// blocked by the previous Chromium's SingletonLock — the self-inflicted
|
||||
// crash-loop. Previously only the manual connect preamble did this.
|
||||
if ((extraEnv?.BROWSE_HEADED ?? process.env.BROWSE_HEADED) === '1') {
|
||||
ensureProjectProfile(chromiumProfileDir());
|
||||
await killOrphanChromium();
|
||||
cleanChromiumProfileLocks();
|
||||
}
|
||||
@@ -1676,6 +1676,7 @@ Multi-step: chain (reads JSON from stdin)
|
||||
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
|
||||
Server: status | cookie <n>=<v> | header <n>:<v>
|
||||
useragent <str> | stop | restart
|
||||
profiles [list] | profiles prune [--days N] (per-project headed profiles)
|
||||
tunnel revoke <name> | tunnel agents (paired-agent tokens)
|
||||
--force-restart: replace a live-but-busy daemon (any command;
|
||||
LOSES tabs/cookies/logins — never done automatically)
|
||||
@@ -1693,6 +1694,8 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
const command = args[0];
|
||||
const commandArgs = args.slice(1);
|
||||
|
||||
if (command === 'profiles') process.exit(runProfilesCommand(commandArgs));
|
||||
|
||||
// ─── Headed Connect (pre-server command) ────────────────────
|
||||
// connect must be handled BEFORE ensureServer() because it needs
|
||||
// to restart the server in headed mode with the Chrome extension.
|
||||
|
||||
@@ -250,19 +250,21 @@ export function isPairAgentEnabled(): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Chromium profile directory.
|
||||
* Resolve the headed Chromium profile directory.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. `explicit` arg (no production caller passes one today; kept for
|
||||
* direct programmatic use)
|
||||
* 2. CHROMIUM_PROFILE env (used by gbrowser's gbd per-workspace)
|
||||
* 3. <resolveGstackHome()>/chromium-profile (default)
|
||||
* 3. <project state dir>/chromium-profile (default; per project since D5,
|
||||
* #2492 — a machine-wide profile let one project's headed launch kill
|
||||
* another project's live browser)
|
||||
*/
|
||||
export function resolveChromiumProfile(explicit?: string): string {
|
||||
if (explicit && explicit.length > 0) return explicit;
|
||||
const env = process.env.CHROMIUM_PROFILE;
|
||||
if (env && env.length > 0) return env;
|
||||
return path.join(resolveGstackHome(), 'chromium-profile');
|
||||
return path.join(resolveConfig().stateDir, 'chromium-profile');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,7 +52,9 @@ describe('Chromium profile isolation cleanup (#2908)', () => {
|
||||
describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817)', () => {
|
||||
let scratch: string;
|
||||
let profile: string;
|
||||
let projectProfile: string;
|
||||
let stateFile: string;
|
||||
let orphans: number[];
|
||||
let env: Record<string, string>;
|
||||
let children: ReturnType<typeof Bun.spawn>[];
|
||||
let daemonPid: number | undefined;
|
||||
@@ -61,6 +63,7 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817
|
||||
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-profile-isolation-'));
|
||||
profile = path.join(scratch, '.gstack', 'chromium-profile');
|
||||
stateFile = path.join(scratch, 'project-b', '.gstack', 'browse.json');
|
||||
projectProfile = path.join(scratch, 'project-b', '.gstack', 'chromium-profile');
|
||||
fs.mkdirSync(profile, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
||||
env = {};
|
||||
@@ -83,6 +86,7 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817
|
||||
BROWSE_START_TIMEOUT: '30000',
|
||||
});
|
||||
children = [];
|
||||
orphans = [];
|
||||
daemonPid = undefined;
|
||||
});
|
||||
|
||||
@@ -92,14 +96,16 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817
|
||||
}
|
||||
if (daemonPid) killDaemonGroup(daemonPid);
|
||||
for (const child of children) child.kill('SIGKILL');
|
||||
for (const pid of orphans) { try { process.kill(pid, 'SIGKILL'); } catch {} }
|
||||
await Promise.all(children.map(child => child.exited));
|
||||
fs.rmSync(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function spawnChromiumHolder() {
|
||||
/** A stand-in Chromium: its command line names Chromium and the profile it uses. */
|
||||
async function spawnChromiumHolder(userDataDir = profile) {
|
||||
const script = path.join(scratch, `chromium-holder-${children.length}.ts`);
|
||||
fs.writeFileSync(script, 'console.log("ready"); await Bun.sleep(60000);');
|
||||
const child = Bun.spawn([process.execPath, script], {
|
||||
const child = Bun.spawn([process.execPath, script, `--user-data-dir=${userDataDir}`], {
|
||||
env,
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
@@ -113,17 +119,66 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817
|
||||
return child;
|
||||
}
|
||||
|
||||
function seedLocks(pid: number) {
|
||||
fs.symlinkSync(`${os.hostname()}-${pid}`, path.join(profile, LOCKS[0]));
|
||||
fs.symlinkSync('socket-target', path.join(profile, LOCKS[1]));
|
||||
fs.symlinkSync('cookie-target', path.join(profile, LOCKS[2]));
|
||||
/** A stand-in Chromium whose daemon is gone: reparented to init. */
|
||||
async function spawnOrphanedChromium(userDataDir: string) {
|
||||
const script = path.join(scratch, `chromium-orphan-${orphans.length}.ts`);
|
||||
const ready = `${script}.ready`;
|
||||
fs.writeFileSync(script, `require('node:fs').writeFileSync(${JSON.stringify(ready)}, 'ready'); await Bun.sleep(60000);`);
|
||||
const launcher = Bun.spawnSync(['sh', '-c', `"$0" "$1" "$2" >/dev/null 2>&1 & echo $!`, process.execPath, script, `--user-data-dir=${userDataDir}`], { env, stdout: 'pipe', timeout: 10_000 });
|
||||
const pid = Number(launcher.stdout.toString().trim());
|
||||
orphans.push(pid);
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (!fs.existsSync(ready) && Date.now() < deadline) await Bun.sleep(20);
|
||||
expect(fs.existsSync(ready)).toBe(true);
|
||||
return { pid };
|
||||
}
|
||||
|
||||
function expectLocksIntact(pid: number) {
|
||||
function seedLocks(pid: number, dir = profile, host = os.hostname()) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.symlinkSync(`${host}-${pid}`, path.join(dir, LOCKS[0]));
|
||||
fs.symlinkSync('socket-target', path.join(dir, LOCKS[1]));
|
||||
fs.symlinkSync('cookie-target', path.join(dir, LOCKS[2]));
|
||||
}
|
||||
|
||||
function expectLocksIntact(pid: number, dir = profile, host = os.hostname()) {
|
||||
expect(isProcessAlive(pid)).toBe(true);
|
||||
expect(fs.readlinkSync(path.join(profile, LOCKS[0]))).toBe(`${os.hostname()}-${pid}`);
|
||||
expect(fs.readlinkSync(path.join(profile, LOCKS[1]))).toBe('socket-target');
|
||||
expect(fs.readlinkSync(path.join(profile, LOCKS[2]))).toBe('cookie-target');
|
||||
expect(fs.readlinkSync(path.join(dir, LOCKS[0]))).toBe(`${host}-${pid}`);
|
||||
expect(fs.readlinkSync(path.join(dir, LOCKS[1]))).toBe('socket-target');
|
||||
expect(fs.readlinkSync(path.join(dir, LOCKS[2]))).toBe('cookie-target');
|
||||
}
|
||||
|
||||
function writeStubServer() {
|
||||
const serverScript = path.join(scratch, 'stub-server.ts');
|
||||
fs.writeFileSync(serverScript, `
|
||||
import * as fs from 'node:fs';
|
||||
const server = Bun.serve({
|
||||
hostname: '127.0.0.1', port: 0,
|
||||
fetch: () => Response.json({ status: 'healthy' }),
|
||||
});
|
||||
fs.writeFileSync(process.env.BROWSE_STATE_FILE!, JSON.stringify({
|
||||
pid: process.pid, port: server.port, token: 'test-token',
|
||||
mode: process.env.BROWSE_HEADED === '1' ? 'headed' : 'launched',
|
||||
}));
|
||||
`);
|
||||
env.BROWSE_SERVER_SCRIPT = serverScript;
|
||||
}
|
||||
|
||||
async function runCliRaw(args: string[]) {
|
||||
const child = Bun.spawn([process.execPath, CLI, ...args], {
|
||||
cwd: path.join(scratch, 'project-b'),
|
||||
env,
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
children.push(child);
|
||||
const [code, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
]);
|
||||
if (fs.existsSync(stateFile)) daemonPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid;
|
||||
return { code, stdout, stderr };
|
||||
}
|
||||
|
||||
async function runCli(args: string[]) {
|
||||
@@ -176,34 +231,108 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817
|
||||
}, 60_000);
|
||||
|
||||
for (const mode of ['flag', 'environment'] as const) {
|
||||
test(`headed startup via ${mode} still reaps a profile orphan and removes stale locks`, async () => {
|
||||
const orphan = await spawnChromiumHolder();
|
||||
seedLocks(orphan.pid);
|
||||
test(`headed startup via ${mode} reaps an orphaned Chromium on this project's profile and removes stale locks`, async () => {
|
||||
const orphan = await spawnOrphanedChromium(projectProfile);
|
||||
seedLocks(orphan.pid, projectProfile);
|
||||
env.BROWSE_HEADED = mode === 'environment' ? '1' : '0';
|
||||
const serverScript = path.join(scratch, 'stub-server.ts');
|
||||
fs.writeFileSync(serverScript, `
|
||||
import * as fs from 'node:fs';
|
||||
const server = Bun.serve({
|
||||
hostname: '127.0.0.1', port: 0,
|
||||
fetch: () => Response.json({ status: 'healthy' }),
|
||||
});
|
||||
fs.writeFileSync(process.env.BROWSE_STATE_FILE!, JSON.stringify({
|
||||
pid: process.pid, port: server.port, token: 'test-token',
|
||||
mode: process.env.BROWSE_HEADED === '1' ? 'headed' : 'launched',
|
||||
}));
|
||||
`);
|
||||
env.BROWSE_SERVER_SCRIPT = serverScript;
|
||||
writeStubServer();
|
||||
|
||||
await runCli(mode === 'flag' ? ['--headed', 'status'] : ['status']);
|
||||
|
||||
expect(JSON.parse(fs.readFileSync(stateFile, 'utf-8')).mode).toBe('headed');
|
||||
expect(isProcessAlive(orphan.pid)).toBe(false);
|
||||
expect(fs.readdirSync(profile)).not.toContain(LOCKS[0]);
|
||||
expect(fs.readdirSync(profile)).not.toContain(LOCKS[1]);
|
||||
expect(fs.readdirSync(profile)).not.toContain(LOCKS[2]);
|
||||
for (const lock of LOCKS) expect(fs.readdirSync(projectProfile)).not.toContain(lock);
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
test('headed startup in one project leaves another project\'s live headed Chromium alone (D5, #2492)', async () => {
|
||||
const otherProject = path.join(scratch, 'project-a', '.gstack', 'chromium-profile');
|
||||
const holder = await spawnChromiumHolder(otherProject);
|
||||
seedLocks(holder.pid, otherProject);
|
||||
writeStubServer();
|
||||
|
||||
await runCli(['--headed', 'status']);
|
||||
|
||||
expectLocksIntact(holder.pid, otherProject);
|
||||
expect(fs.existsSync(projectProfile)).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test('a live owner of this project\'s profile is never killed; browse names it and stops', async () => {
|
||||
const holder = await spawnChromiumHolder(projectProfile);
|
||||
seedLocks(holder.pid, projectProfile);
|
||||
writeStubServer();
|
||||
|
||||
const r = await runCliRaw(['--headed', 'status']);
|
||||
|
||||
expect(r.code).not.toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain(`in use by Chromium PID ${holder.pid}`);
|
||||
expectLocksIntact(holder.pid, projectProfile);
|
||||
}, 60_000);
|
||||
|
||||
test('a lock written by another host is never acted on', async () => {
|
||||
const holder = await spawnOrphanedChromium(projectProfile);
|
||||
seedLocks(holder.pid, projectProfile, 'some-other-host');
|
||||
writeStubServer();
|
||||
|
||||
const r = await runCliRaw(['--headed', 'status']);
|
||||
|
||||
expect(r.code).not.toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain('on another host (some-other-host)');
|
||||
expectLocksIntact(holder.pid, projectProfile, 'some-other-host');
|
||||
}, 60_000);
|
||||
|
||||
test('first headed use seeds the project profile from a quiescent shared profile, without singleton files', async () => {
|
||||
fs.mkdirSync(path.join(profile, 'Default'), { recursive: true });
|
||||
fs.writeFileSync(path.join(profile, 'Default', 'Cookies'), 'cookie-db');
|
||||
seedLocks(999_999_999);
|
||||
writeStubServer();
|
||||
|
||||
const r = await runCliRaw(['--headed', 'status']);
|
||||
|
||||
expect(r.code, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain(`Copied your logins and settings from ${profile} to ${projectProfile}`);
|
||||
expect(fs.readFileSync(path.join(projectProfile, 'Default', 'Cookies'), 'utf-8')).toBe('cookie-db');
|
||||
expect(fs.statSync(projectProfile).mode & 0o777).toBe(0o700);
|
||||
expect(fs.readdirSync(projectProfile).filter(f => f.startsWith('Singleton') || f.includes('.seed-'))).toEqual([]);
|
||||
expect(fs.readdirSync(path.dirname(projectProfile)).filter(f => f.includes('.seed-'))).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
test('a shared profile in use is not copied; the project starts fresh and says how to import later', async () => {
|
||||
fs.writeFileSync(path.join(profile, 'Cookies'), 'cookie-db');
|
||||
const holder = await spawnChromiumHolder(profile);
|
||||
seedLocks(holder.pid);
|
||||
writeStubServer();
|
||||
|
||||
const r = await runCliRaw(['--headed', 'status']);
|
||||
|
||||
expect(r.code, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('starts with a fresh profile. To import it later');
|
||||
expect(fs.existsSync(path.join(projectProfile, 'Cookies'))).toBe(false);
|
||||
expectLocksIntact(holder.pid);
|
||||
}, 60_000);
|
||||
|
||||
test('browse profiles lists per-project profiles and prune removes idle ones but never one in use', async () => {
|
||||
writeStubServer();
|
||||
await runCli(['--headed', 'status']);
|
||||
const listed = await runCliRaw(['profiles']);
|
||||
expect(listed.code).toBe(0);
|
||||
expect(listed.stdout).toContain(projectProfile);
|
||||
expect(listed.stdout).toContain('(old shared profile)');
|
||||
|
||||
const holder = await spawnChromiumHolder(projectProfile);
|
||||
seedLocks(holder.pid, projectProfile);
|
||||
await runCliRaw(['profiles', 'prune', '--days', '0']);
|
||||
expect(fs.existsSync(projectProfile)).toBe(true);
|
||||
|
||||
holder.kill('SIGKILL');
|
||||
await holder.exited;
|
||||
const pruned = await runCliRaw(['profiles', 'prune', '--days', '0']);
|
||||
expect(pruned.code).toBe(0);
|
||||
expect(pruned.stdout).toContain(`removed ${projectProfile}`);
|
||||
expect(fs.existsSync(projectProfile)).toBe(false);
|
||||
expect(fs.existsSync(profile)).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
for (const args of [['stop'], ['--force-restart', 'stop'], ['disconnect']]) {
|
||||
test(`headless ${args.join(' ')} preserves another project's headed profile`, async () => {
|
||||
await runCli(['status']);
|
||||
@@ -228,6 +357,7 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817
|
||||
test(`${mode} factory shutdown scopes profile cleanup to the active browser mode`, async () => {
|
||||
const holder = await spawnChromiumHolder();
|
||||
seedLocks(holder.pid);
|
||||
seedLocks(999_999_999, projectProfile);
|
||||
const child = Bun.spawn([process.execPath, '-e', `
|
||||
import { buildFetchHandler } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/server.ts'))};
|
||||
import { resolveConfig } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/config.ts'))};
|
||||
@@ -249,10 +379,13 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817
|
||||
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
|
||||
|
||||
expect(code, stderr).toBe(0);
|
||||
// Headed shutdown cleans only this project's profile; another
|
||||
// project's (here the old shared one) is never touched (D5).
|
||||
expectLocksIntact(holder.pid);
|
||||
if (mode === 'headed') {
|
||||
for (const lock of LOCKS) expect(fs.readdirSync(profile)).not.toContain(lock);
|
||||
for (const lock of LOCKS) expect(fs.readdirSync(projectProfile)).not.toContain(lock);
|
||||
} else {
|
||||
expectLocksIntact(holder.pid);
|
||||
expect(fs.readdirSync(projectProfile)).toContain(LOCKS[0]);
|
||||
}
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
@@ -431,17 +431,21 @@ describe('resolveChromiumProfile', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to resolveGstackHome()/chromium-profile when nothing set', () => {
|
||||
test('falls back to the project state dir, not the machine-wide gstack home (D5, #2492)', () => {
|
||||
const origEnv = process.env.CHROMIUM_PROFILE;
|
||||
const origHome = process.env.GSTACK_HOME;
|
||||
const origState = process.env.BROWSE_STATE_FILE;
|
||||
delete process.env.CHROMIUM_PROFILE;
|
||||
process.env.GSTACK_HOME = '/tmp/fallback-gstack';
|
||||
process.env.BROWSE_STATE_FILE = '/tmp/project-a/.gstack/browse.json';
|
||||
try {
|
||||
expect(resolveChromiumProfile()).toBe('/tmp/fallback-gstack/chromium-profile');
|
||||
expect(resolveChromiumProfile()).toBe(path.join('/tmp/project-a/.gstack', 'chromium-profile'));
|
||||
} finally {
|
||||
if (origEnv !== undefined) process.env.CHROMIUM_PROFILE = origEnv;
|
||||
if (origHome === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = origHome;
|
||||
if (origState === undefined) delete process.env.BROWSE_STATE_FILE;
|
||||
else process.env.BROWSE_STATE_FILE = origState;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user