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,
|
stdio,
|
||||||
env: options.env,
|
env: options.env,
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ async function killServer(pid: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
Bun.spawnSync(
|
Bun.spawnSync(
|
||||||
['taskkill', '/PID', String(pid), '/T', '/F'],
|
['taskkill', '/PID', String(pid), '/T', '/F'],
|
||||||
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000 }
|
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000, windowsHide: true }
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code !== 'ENOENT') throw err;
|
if (err?.code !== 'ENOENT') throw err;
|
||||||
|
|
@ -323,9 +323,9 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||||
const launcherCode =
|
const launcherCode =
|
||||||
`const{spawn}=require('child_process');` +
|
`const{spawn}=require('child_process');` +
|
||||||
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
|
`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()`;
|
`${extraEnvStr})}).unref()`;
|
||||||
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
|
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true });
|
||||||
} else {
|
} else {
|
||||||
// macOS/Linux: Bun.spawn().unref() only removes the child from Bun's event
|
// 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
|
// 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.
|
* 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,
|
* 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 command = args[0];
|
||||||
const commandArgs = args.slice(1);
|
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) ────────────────────
|
// ─── Headed Connect (pre-server command) ────────────────────
|
||||||
// connect must be handled BEFORE ensureServer() because it needs
|
// connect must be handled BEFORE ensureServer() because it needs
|
||||||
// to restart the server in headed mode with the Chrome extension.
|
// to restart the server in headed mode with the Chrome extension.
|
||||||
|
|
|
||||||
|
|
@ -529,6 +529,7 @@ async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
|
||||||
stdin: 'pipe',
|
stdin: 'pipe',
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.stdin.write(encryptedBytes.toString('base64'));
|
proc.stdin.write(encryptedBytes.toString('base64'));
|
||||||
|
|
@ -779,6 +780,7 @@ function isBrowserRunning(browserName: string): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
|
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
|
||||||
stdout: 'pipe', stderr: 'pipe',
|
stdout: 'pipe', stderr: 'pipe',
|
||||||
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
proc.exited.then(async () => {
|
proc.exited.then(async () => {
|
||||||
const out = await new Response(proc.stdout).text();
|
const out = await new Response(proc.stdout).text();
|
||||||
|
|
@ -869,7 +871,7 @@ export async function importCookiesViaCdp(
|
||||||
'--disable-extensions',
|
'--disable-extensions',
|
||||||
'--disable-sync',
|
'--disable-sync',
|
||||||
'--no-default-browser-check',
|
'--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.
|
// Wait for Chrome to start, then find a page target's WebSocket URL.
|
||||||
// Network.getAllCookies is only available on page targets, not browser.
|
// Network.getAllCookies is only available on page targets, not browser.
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Bun.serve HTTP on localhost → routes commands to Playwright
|
* Bun.serve HTTP on localhost → routes commands to Playwright
|
||||||
* Console/network/dialog buffers: CircularBuffer in-memory + async disk flush
|
* Console/network/dialog buffers: CircularBuffer in-memory + async disk flush
|
||||||
* Chromium crash → server EXITS with clear error (CLI auto-restarts)
|
* 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:
|
||||||
* State file: <project-root>/.gstack/browse.json (set via BROWSE_STATE_FILE env)
|
* 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
|
// and factory-scoped validateAuth closes over the same value. start() reads
|
||||||
// env once via resolveConfigFromEnv() and threads the result through.
|
// env once via resolveConfigFromEnv() and threads the result through.
|
||||||
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
|
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.
|
* 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 ─────────
|
// ─── /pty-session — mint sessionId + lease + attachToken ─────────
|
||||||
//
|
//
|
||||||
// v1.44+ four-tuple shape:
|
// 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 { handleMetaCommand } from '../src/meta-commands';
|
||||||
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, CircularBuffer } from '../src/buffers';
|
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, CircularBuffer } from '../src/buffers';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
// Thin wrappers that bridge old test calls (bm as 3rd arg) to new signatures (session + bm)
|
// 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 ──────────────────────────────────────────────
|
// ─── Buffer bounds ──────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Buffer bounds', () => {
|
describe('Buffer bounds', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue