mirror of https://github.com/garrytan/gstack.git
fix browse lifecycle on Windows
This commit is contained in:
parent
a5833c413f
commit
e0f07ec4e5
|
|
@ -91,6 +91,7 @@ globalThis.Bun = {
|
|||
stdio,
|
||||
env: options.env,
|
||||
cwd: options.cwd,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue