fix(browse): isolate headless lifecycle from shared profiles

This commit is contained in:
garrytan committed 2026-09-15 18:07:16 +00:00
1 parent 4a3c6a8a3c
commit 073b0f8ae6
8 files changed
+252 -14

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Changelog
## [1.87.2.0] - 2026-09-15
### Fixed
- Starting or stopping a headless browse daemon no longer kills another project's headed browser or removes its shared profile locks. Headed launches still clear stale profile locks, and headless cleanup still reaps Chromium children by their recorded identity. (#2817)
## [1.87.0.0] - 2026-09-11
**`/cso` now distinguishes verified vulnerabilities from hypotheses and coverage gaps, and qualified comprehensive audits can produce replayable repair bundles without changing your working branch.**
+1 -1
View File
@@ -1 +1 @@
1.87.0.0
1.87.2.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.87.0.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.87.2.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+13 -6
View File
@@ -515,8 +515,10 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
// holding it) before launch, so an auto-restart after an abrupt kill isn't
// blocked by the previous Chromium's SingletonLock — the self-inflicted
// crash-loop. Previously only the manual connect preamble did this.
await killOrphanChromium();
cleanChromiumProfileLocks();
if ((extraEnv?.BROWSE_HEADED ?? process.env.BROWSE_HEADED) === '1') {
await killOrphanChromium();
cleanChromiumProfileLocks();
}
// Allow the caller to opt out of the parent-process watchdog by setting
// BROWSE_PARENT_PID=0 in the environment. Useful for CI, non-interactive
@@ -1818,8 +1820,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
// #1781: killing the daemon can orphan its Chromium child tree, which keeps
// holding the SingletonLock and makes the next `connect` fail to launch.
// Reap the orphan via the lock, then clear the lock files + state.
await killOrphanChromium();
cleanChromiumProfileLocks();
if (existingState.mode === 'headed') {
await killOrphanChromium();
cleanChromiumProfileLocks();
}
await reapRecordedChromium(existingState);
// Xvfb orphan cleanup: if the recorded PID still matches our Xvfb (by
// cmdline AND start-time), kill it. PID-only would risk killing a
// recycled PID belonging to an unrelated process.
@@ -1876,9 +1881,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
// NEXT launch is clean (same cleanup as the disconnect force path).
// The headless child has no SingletonLock — reap it via the recorded
// identity too (#2709).
await killOrphanChromium();
if (stopState.mode === 'headed') {
await killOrphanChromium();
cleanChromiumProfileLocks();
}
await reapRecordedChromium(stopState);
cleanChromiumProfileLocks();
safeUnlinkQuiet(config.stateFile);
console.log('Daemon stopped (forced — tabs/cookies/logins discarded).');
process.exit(0);
+6 -2
View File
@@ -1469,7 +1469,9 @@ function emergencyCleanup() {
// Clean Chromium profile locks via the shared helper (defensive guard
// refuses to operate on unrecognized profile dirs).
cleanSingletonLocks(resolveChromiumProfile());
if (activeBrowserManager.getConnectionMode() === 'headed' || process.env.BROWSE_HEADED === '1') {
cleanSingletonLocks(resolveChromiumProfile());
}
safeUnlinkQuiet(config.stateFile);
}
// Same import.meta.main gate as SIGINT/SIGTERM — embedders register their
@@ -1704,7 +1706,9 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
await cfgBrowserManager.close();
cleanSingletonLocks(resolveChromiumProfile());
if (cfgBrowserManager.getConnectionMode() === 'headed') {
cleanSingletonLocks(resolveChromiumProfile());
}
safeUnlinkQuiet(config.stateFile);
process.exit(exitCode);
}
@@ -0,0 +1,218 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { isProcessAlive, safeKill } from '../src/error-handling';
import { readPidStartTime } from '../src/xvfb';
const CLI = path.resolve(import.meta.dir, '../src/cli.ts');
const LOCKS = ['SingletonLock', 'SingletonSocket', 'SingletonCookie'];
describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817)', () => {
let scratch: string;
let profile: string;
let stateFile: string;
let env: Record<string, string>;
let children: ReturnType<typeof Bun.spawn>[];
let daemonPid: number | undefined;
beforeEach(() => {
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');
fs.mkdirSync(profile, { recursive: true });
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
env = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !/^(BROWSE_|GSTACK_|CHROMIUM_PROFILE$|CLAUDE_PLUGIN_DATA$)/.test(key)) {
env[key] = value;
}
}
Object.assign(env, {
HOME: scratch,
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(
process.env.XDG_CACHE_HOME || path.join(os.homedir(), process.platform === 'darwin' ? 'Library/Caches' : '.cache'),
'ms-playwright',
),
GSTACK_HOME: path.join(scratch, '.gstack'),
GSTACK_SECURITY_OFF: '1',
BROWSE_STATE_FILE: stateFile,
BROWSE_PORT: '0',
BROWSE_PARENT_PID: '0',
BROWSE_START_TIMEOUT: '30000',
});
children = [];
daemonPid = undefined;
});
afterEach(async () => {
if (fs.existsSync(stateFile)) {
daemonPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid;
}
if (daemonPid) safeKill(-daemonPid, 'SIGKILL');
for (const child of children) child.kill('SIGKILL');
await Promise.all(children.map(child => child.exited));
fs.rmSync(scratch, { recursive: true, force: true });
});
async function spawnChromiumHolder() {
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], {
env,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'ignore',
});
children.push(child);
const reader = child.stdout.getReader();
const ready = await reader.read();
reader.releaseLock();
expect(new TextDecoder().decode(ready.value)).toContain('ready');
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]));
}
function expectLocksIntact(pid: number) {
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');
}
async function runCli(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;
const log = path.join(path.dirname(stateFile), 'browse-daemon.log');
expect(code, stdout + stderr + (fs.existsSync(log) ? fs.readFileSync(log, 'utf-8') : '')).toBe(0);
}
test('headless startup leaves another project\'s live headed lock holder and locks alone', async () => {
const holder = await spawnChromiumHolder();
seedLocks(holder.pid);
await runCli(['status']);
expect(daemonPid).toBeGreaterThan(0);
expect(isProcessAlive(daemonPid!)).toBe(true);
expectLocksIntact(holder.pid);
}, 60_000);
test('headless startup still reaps its own recorded orphan without touching the headed profile', async () => {
const holder = await spawnChromiumHolder();
const orphan = await spawnChromiumHolder();
seedLocks(holder.pid);
fs.writeFileSync(stateFile, JSON.stringify({
pid: 999_999_999,
port: 0,
token: 'test-token',
mode: 'launched',
chromiumPid: orphan.pid,
chromiumStartTime: readPidStartTime(orphan.pid),
}));
await runCli(['status']);
expect(isProcessAlive(orphan.pid)).toBe(false);
expectLocksIntact(holder.pid);
}, 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);
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;
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]);
}, 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']);
if (args[0] === 'disconnect') {
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
state.configHash = 'proxy-only-config';
fs.writeFileSync(stateFile, JSON.stringify(state));
}
const holder = await spawnChromiumHolder();
seedLocks(holder.pid);
await runCli(args);
const deadline = Date.now() + 10000;
while (fs.existsSync(stateFile) && Date.now() < deadline) await Bun.sleep(50);
expect(fs.existsSync(stateFile)).toBe(false);
expectLocksIntact(holder.pid);
}, 60_000);
}
for (const mode of ['launched', 'headed'] as const) {
test(`${mode} factory shutdown scopes profile cleanup to the active browser mode`, async () => {
const holder = await spawnChromiumHolder();
seedLocks(holder.pid);
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'))};
const handle = buildFetchHandler({
authToken: 'profile-isolation-test-token',
browsePort: 0,
idleTimeoutMs: 1800000,
config: resolveConfig(),
ownsTerminalAgent: false,
browserManager: {
getConnectionMode: () => ${JSON.stringify(mode)},
isWatching: () => false,
close: async () => {},
},
});
await handle.shutdown();
`], { env, stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' });
children.push(child);
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
expect(code, stderr).toBe(0);
if (mode === 'headed') {
for (const lock of LOCKS) expect(fs.readdirSync(profile)).not.toContain(lock);
} else {
expectLocksIntact(holder.pid);
}
}, 60_000);
}
});
+6 -3
View File
@@ -531,7 +531,7 @@ describe('idle timer + onDisconnect dual-instance fix', () => {
test('lifecycle handlers (idleCheckTick + parent watchdog + SIGTERM) read activeBrowserManager, not module-level browserManager', () => {
// Static guard against a future refactor reintroducing a stale read.
// The 3 lifecycle sites this plan fixed all call getConnectionMode via
// The 4 lifecycle sites all call getConnectionMode via
// the indirection. Other module-level browserManager reads inside
// handleCommandInternalImpl (informational mode reporting in response
// payloads) are out of scope and intentionally untouched.
@@ -540,7 +540,10 @@ describe('idle timer + onDisconnect dual-instance fix', () => {
expect(factoryStart).toBeGreaterThan(0);
const moduleLevel = src.slice(0, factoryStart);
const activeCount = (moduleLevel.match(/activeBrowserManager\.getConnectionMode\(\)/g) || []).length;
// Edit 2 (idleCheckTick), Edit 3 (parent watchdog), Edit 6 (SIGTERM).
expect(activeCount).toBe(3);
// idleCheckTick, parent watchdog, SIGTERM, and emergencyCleanup.
expect(activeCount).toBe(4);
const emergencyStart = src.indexOf('function emergencyCleanup()');
expect(emergencyStart).toBeGreaterThan(0);
expect(src.slice(emergencyStart, factoryStart)).toContain("activeBrowserManager.getConnectionMode() === 'headed'");
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.87.0",
"version": "1.87.2",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",