fix browse lifecycle on Windows

This commit is contained in:
Garrett (A) 2026-06-12 01:45:09 -04:00
parent a5833c413f
commit e0f07ec4e5
6 changed files with 179 additions and 51 deletions

View File

@ -91,6 +91,7 @@ globalThis.Bun = {
stdio,
env: options.env,
cwd: options.cwd,
windowsHide: true,
});
return {

View File

@ -143,7 +143,7 @@ async function killServer(pid: number): Promise<void> {
try {
Bun.spawnSync(
['taskkill', '/PID', String(pid), '/T', '/F'],
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000 }
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000, windowsHide: true }
);
} catch (err: any) {
if (err?.code !== 'ENOENT') throw err;
@ -323,9 +323,9 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
const launcherCode =
`const{spawn}=require('child_process');` +
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
`{detached:true,stdio:['ignore','ignore','ignore'],windowsHide:true,env:Object.assign({},process.env,` +
`${extraEnvStr})}).unref()`;
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true });
} else {
// macOS/Linux: Bun.spawn().unref() only removes the child from Bun's event
// loop — it does NOT call setsid(), so the spawned server stays in the
@ -501,6 +501,75 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
}
}
async function printPassiveStatus(): Promise<void> {
const state = readState();
if (!state) {
console.log('Status: stopped');
return;
}
try {
const resp = await fetch(`http://127.0.0.1:${state.port}/status`, {
headers: { 'Authorization': `Bearer ${state.token}` },
signal: AbortSignal.timeout(2000),
});
if (resp.ok) {
const body = await resp.json() as any;
console.log([
`Status: ${body.status || 'unknown'}`,
`Mode: ${body.mode || state.mode || 'unknown'}`,
`URL: ${body.url || '(unknown)'}`,
`Tabs: ${body.tabs ?? '?'}`,
`PID: ${body.pid || state.pid}`,
].join('\n'));
return;
}
} catch {
// Fall through to stopped/unavailable summary.
}
console.log([
'Status: stopped',
`PID: ${state.pid}`,
`Port: ${state.port}`,
'Reason: daemon is not responding',
].join('\n'));
}
async function stopExistingServer(): Promise<void> {
const state = readState();
if (!state) {
console.log('No browse server running.');
return;
}
if (await isServerHealthy(state.port)) {
try {
await fetch(`http://127.0.0.1:${state.port}/command`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${state.token}`,
},
body: JSON.stringify({ command: 'stop', args: [] }),
signal: AbortSignal.timeout(1000),
});
} catch {
// The daemon often exits before it can return the HTTP response.
}
await Bun.sleep(500);
}
if (state.pid && isProcessAlive(state.pid)) {
await killServer(state.pid);
}
await killOrphanChromium();
cleanChromiumProfileLocks();
safeUnlinkQuiet(config.stateFile);
console.log('Browse server stopped.');
}
/**
* Extract `--tab-id <N>` from args and return { tabId, args } with the flag stripped.
* Used by make-pdf's tab-scoped flow: every browse command (newtab, load-html, js,
@ -1039,6 +1108,17 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
const command = args[0];
const commandArgs = args.slice(1);
// Passive lifecycle commands must not create a daemon.
if (command === 'status') {
await printPassiveStatus();
process.exit(0);
}
if (command === 'stop') {
await stopExistingServer();
process.exit(0);
}
// ─── 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.

View File

@ -529,6 +529,7 @@ async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
windowsHide: true,
});
proc.stdin.write(encryptedBytes.toString('base64'));
@ -779,6 +780,7 @@ function isBrowserRunning(browserName: string): Promise<boolean> {
return new Promise((resolve) => {
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
stdout: 'pipe', stderr: 'pipe',
windowsHide: true,
});
proc.exited.then(async () => {
const out = await new Response(proc.stdout).text();
@ -869,7 +871,7 @@ export async function importCookiesViaCdp(
'--disable-extensions',
'--disable-sync',
'--no-default-browser-check',
], { stdout: 'pipe', stderr: 'pipe' });
], { stdout: 'pipe', stderr: 'pipe', windowsHide: true });
// Wait for Chrome to start, then find a page target's WebSocket URL.
// Network.getAllCookies is only available on page targets, not browser.

View File

@ -5,7 +5,7 @@
* Bun.serve HTTP on localhost routes commands to Playwright
* Console/network/dialog buffers: CircularBuffer in-memory + async disk flush
* Chromium crash server EXITS with clear error (CLI auto-restarts)
* Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min)
* Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min, 5 min on Windows)
*
* State:
* State file: <project-root>/.gstack/browse.json (set via BROWSE_STATE_FILE env)
@ -140,7 +140,8 @@ function sanitizeAuthToken(raw: string | undefined): string | null {
// and factory-scoped validateAuth closes over the same value. start() reads
// env once via resolveConfigFromEnv() and threads the result through.
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min
const DEFAULT_IDLE_TIMEOUT_MS = process.platform === 'win32' ? 300_000 : 1_800_000;
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || String(DEFAULT_IDLE_TIMEOUT_MS), 10);
/**
* Port the local listener bound to. Set once the daemon picks a port.
@ -1806,6 +1807,28 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
});
}
// Status snapshot — auth required, does NOT reset idle timer.
if (url.pathname === '/status' && req.method === 'GET') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const healthy = await browserManager.isHealthy();
return new Response(JSON.stringify({
status: healthy ? 'healthy' : 'unhealthy',
mode: browserManager.getConnectionMode(),
uptime: Math.floor((Date.now() - startTime) / 1000),
url: browserManager.getCurrentUrl(),
tabs: browserManager.getTabCount(),
pid: process.pid,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// ─── /pty-session — mint sessionId + lease + attachToken ─────────
//
// v1.44+ four-tuple shape:

View File

@ -0,0 +1,67 @@
import { describe, test, expect } from 'bun:test';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
function cliEnv(stateFile: string): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) env[key] = value;
}
env.BROWSE_STATE_FILE = stateFile;
return env;
}
function runCli(args: string[], stateFile: string): Promise<{ code: number; stdout: string; stderr: string }> {
const cliPath = path.resolve(__dirname, '../src/cli.ts');
return new Promise((resolve) => {
const proc = spawn('bun', ['run', cliPath, ...args], {
timeout: 15_000,
env: cliEnv(stateFile),
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => stdout += data.toString());
proc.stderr.on('data', (data) => stderr += data.toString());
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
});
}
describe('CLI lifecycle commands', () => {
test('status with no state does not start a daemon', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cli-lifecycle-'));
const stateFile = path.join(dir, 'browse.json');
try {
const result = await runCli(['status'], stateFile);
expect(result.code).toBe(0);
expect(result.stdout).toContain('Status: stopped');
expect(result.stderr).not.toContain('Starting server');
expect(fs.existsSync(stateFile)).toBe(false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 20_000);
test('status on a dead state file does not start a daemon', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cli-lifecycle-'));
const stateFile = path.join(dir, 'browse.json');
try {
fs.writeFileSync(stateFile, JSON.stringify({
port: 1,
token: 'fake',
pid: 999999,
}));
const result = await runCli(['status'], stateFile);
expect(result.code).toBe(0);
expect(result.stdout).toContain('Status: stopped');
expect(result.stdout).toContain('Reason: daemon is not responding');
expect(result.stderr).not.toContain('Starting server');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 20_000);
});

View File

@ -14,7 +14,6 @@ import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands
import { handleMetaCommand } from '../src/meta-commands';
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, CircularBuffer } from '../src/buffers';
import * as fs from 'fs';
import { spawn } from 'child_process';
import * as path from 'path';
// Thin wrappers that bridge old test calls (bm as 3rd arg) to new signatures (session + bm)
@ -862,50 +861,6 @@ describe('CLI server script resolution', () => {
});
});
// ─── CLI lifecycle ──────────────────────────────────────────────
describe('CLI lifecycle', () => {
test('dead state file triggers a clean restart', async () => {
const stateFile = `/tmp/browse-test-state-${Date.now()}.json`;
fs.writeFileSync(stateFile, JSON.stringify({
port: 1,
token: 'fake',
pid: 999999,
}));
const cliPath = path.resolve(__dirname, '../src/cli.ts');
const cliEnv: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) cliEnv[k] = v;
}
cliEnv.BROWSE_STATE_FILE = stateFile;
const result = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
const proc = spawn('bun', ['run', cliPath, 'status'], {
timeout: 15000,
env: cliEnv,
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', (d) => stdout += d.toString());
proc.stderr.on('data', (d) => stderr += d.toString());
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
});
let restartedPid: number | null = null;
if (fs.existsSync(stateFile)) {
restartedPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid;
fs.unlinkSync(stateFile);
}
if (restartedPid) {
try { process.kill(restartedPid, 'SIGTERM'); } catch {}
}
expect(result.code).toBe(0);
expect(result.stdout).toContain('Status: healthy');
expect(result.stderr).toContain('Starting server');
}, 20000);
});
// ─── Buffer bounds ──────────────────────────────────────────────
describe('Buffer bounds', () => {