fix: add Windows compatibility for browse server

Bun's subprocess management hangs when Playwright calls chromium.launch()
on Windows. This replaces Bun-specific APIs with cross-runtime equivalents
so the server works under both Bun (Unix) and Node (Windows):

- Replace Bun.serve() with node:http createServer (works in both runtimes)
- Replace Bun.serve() port scanning with node:net createServer
- On Windows, spawn server via node:child_process with detached:true
  (Bun.spawn + unref doesn't properly orphan on Windows)
- Build step now also bundles server.js for Node via --target node
- Fix resolveServerScript() dev-mode check for Windows paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DanMcInerney 2026-03-12 16:41:20 -07:00
parent 1b317aae9a
commit d66bb41e25
3 changed files with 127 additions and 70 deletions

View File

@ -11,6 +11,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { spawn as nodeSpawn } from 'node:child_process';
const PORT_OFFSET = 45600; const PORT_OFFSET = 45600;
const BROWSE_PORT = process.env.CONDUCTOR_PORT const BROWSE_PORT = process.env.CONDUCTOR_PORT
@ -20,6 +21,8 @@ const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`; const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`;
const MAX_START_WAIT = 8000; // 8 seconds to start const MAX_START_WAIT = 8000; // 8 seconds to start
const IS_WINDOWS = process.platform === 'win32';
export function resolveServerScript( export function resolveServerScript(
env: Record<string, string | undefined> = process.env, env: Record<string, string | undefined> = process.env,
metaDir: string = import.meta.dir, metaDir: string = import.meta.dir,
@ -29,8 +32,8 @@ export function resolveServerScript(
return env.BROWSE_SERVER_SCRIPT; return env.BROWSE_SERVER_SCRIPT;
} }
// Dev mode: cli.ts runs directly from browse/src // Dev mode: cli.ts runs directly from browse/src (Unix paths start with /, Windows with drive letter)
if (metaDir.startsWith('/') && !metaDir.includes('$bunfs')) { if (!metaDir.includes('$bunfs')) {
const direct = path.resolve(metaDir, 'server.ts'); const direct = path.resolve(metaDir, 'server.ts');
if (fs.existsSync(direct)) { if (fs.existsSync(direct)) {
return direct; return direct;
@ -46,10 +49,34 @@ export function resolveServerScript(
} }
// Legacy fallback for user-level installs // Legacy fallback for user-level installs
return path.resolve(env.HOME || '/tmp', '.claude/skills/gstack/browse/src/server.ts'); return path.resolve(env.HOME || env.USERPROFILE || '/tmp', '.claude/skills/gstack/browse/src/server.ts');
}
/**
* On Windows, Bun's subprocess management hangs when Playwright spawns Chromium.
* Use a pre-bundled server.js with Node instead. On Unix, Bun works fine.
*/
export function resolveServerBundle(
env: Record<string, string | undefined> = process.env,
execPath: string = process.execPath
): string | null {
if (!IS_WINDOWS) return null;
// Look for compiled server.js next to the browse binary
if (execPath) {
const bundled = path.resolve(path.dirname(execPath), 'server.js');
if (fs.existsSync(bundled)) return bundled;
}
// Fallback: check user-level install
const fallback = path.resolve(env.HOME || env.USERPROFILE || '/tmp', '.claude/skills/gstack/browse/dist/server.js');
if (fs.existsSync(fallback)) return fallback;
return null;
} }
const SERVER_SCRIPT = resolveServerScript(); const SERVER_SCRIPT = resolveServerScript();
const SERVER_BUNDLE = resolveServerBundle();
interface ServerState { interface ServerState {
pid: number; pid: number;
@ -84,13 +111,29 @@ async function startServer(): Promise<ServerState> {
try { fs.unlinkSync(STATE_FILE); } catch {} try { fs.unlinkSync(STATE_FILE); } catch {}
// Start server as detached background process // Start server as detached background process
const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], { // On Windows, Bun + Playwright hangs on chromium.launch(), so use Node with
stdio: ['ignore', 'pipe', 'pipe'], // a pre-bundled server.js. On Unix, Bun runs the .ts source directly.
env: { ...process.env }, // We also use node:child_process on Windows for proper detaching (Bun.spawn
}); // + unref doesn't fully detach on Windows, causing the server to die with the CLI).
const useNode = IS_WINDOWS && SERVER_BUNDLE;
const spawnCmd = useNode ? 'node' : 'bun';
const spawnArgs = useNode ? [SERVER_BUNDLE] : ['run', SERVER_SCRIPT];
// Don't hold the CLI open if (IS_WINDOWS) {
proc.unref(); // node:child_process with detached:true properly orphans on Windows
const child = nodeSpawn(spawnCmd, spawnArgs, {
stdio: 'ignore',
detached: true,
env: { ...process.env },
});
child.unref();
} else {
const proc = Bun.spawn([spawnCmd, ...spawnArgs], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env },
});
proc.unref();
}
// Wait for state file to appear // Wait for state file to appear
const start = Date.now(); const start = Date.now();
@ -102,17 +145,6 @@ async function startServer(): Promise<ServerState> {
await Bun.sleep(100); await Bun.sleep(100);
} }
// If we get here, server didn't start in time
// Try to read stderr for error message
const stderr = proc.stderr;
if (stderr) {
const reader = stderr.getReader();
const { value } = await reader.read();
if (value) {
const errText = new TextDecoder().decode(value);
throw new Error(`Server failed to start:\n${errText}`);
}
}
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`); throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
} }

View File

@ -2,10 +2,12 @@
* gstack browse server persistent Chromium daemon * gstack browse server persistent Chromium daemon
* *
* Architecture: * Architecture:
* Bun.serve HTTP on localhost routes commands to Playwright * HTTP server on localhost routes commands to Playwright
* Console/network buffers: in-memory (all entries) + disk flush every 1s * Console/network buffers: in-memory (all entries) + disk flush every 1s
* 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)
*
* Runtime: works under both Bun and Node (uses node:http/node:net)
*/ */
import { BrowserManager } from './browser-manager'; import { BrowserManager } from './browser-manager';
@ -15,6 +17,8 @@ import { handleMetaCommand } from './meta-commands';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { createServer as createNetServer } from 'node:net';
// ─── Auth (inline) ───────────────────────────────────────────── // ─── Auth (inline) ─────────────────────────────────────────────
const AUTH_TOKEN = crypto.randomUUID(); const AUTH_TOKEN = crypto.randomUUID();
@ -26,11 +30,6 @@ const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`; const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`;
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min
function validateAuth(req: Request): boolean {
const header = req.headers.get('authorization');
return header === `Bearer ${AUTH_TOKEN}`;
}
// ─── Buffer (from buffers.ts) ──────────────────────────────────── // ─── Buffer (from buffers.ts) ────────────────────────────────────
import { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, consoleTotalAdded, networkTotalAdded, type LogEntry, type NetworkEntry } from './buffers'; import { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, consoleTotalAdded, networkTotalAdded, type LogEntry, type NetworkEntry } from './buffers';
export { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, type LogEntry, type NetworkEntry }; export { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, type LogEntry, type NetworkEntry };
@ -107,28 +106,32 @@ const META_COMMANDS = new Set([
'url', 'snapshot', 'url', 'snapshot',
]); ]);
// Check if a port is available using node:net (works in both Bun and Node)
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = createNetServer();
server.once('error', () => resolve(false));
server.listen(port, '127.0.0.1', () => {
server.close(() => resolve(true));
});
});
}
// Find port: deterministic from CONDUCTOR_PORT, or scan range // Find port: deterministic from CONDUCTOR_PORT, or scan range
async function findPort(): Promise<number> { async function findPort(): Promise<number> {
// Deterministic port from CONDUCTOR_PORT (e.g., 55040 - 45600 = 9440) // Deterministic port from CONDUCTOR_PORT (e.g., 55040 - 45600 = 9440)
if (BROWSE_PORT) { if (BROWSE_PORT) {
try { if (await isPortAvailable(BROWSE_PORT)) {
const testServer = Bun.serve({ port: BROWSE_PORT, fetch: () => new Response('ok') });
testServer.stop();
return BROWSE_PORT; return BROWSE_PORT;
} catch {
throw new Error(`[browse] Port ${BROWSE_PORT} (from CONDUCTOR_PORT ${process.env.CONDUCTOR_PORT}) is in use`);
} }
throw new Error(`[browse] Port ${BROWSE_PORT} (from CONDUCTOR_PORT ${process.env.CONDUCTOR_PORT}) is in use`);
} }
// Fallback: scan range // Fallback: scan range
const start = parseInt(process.env.BROWSE_PORT_START || '9400', 10); const start = parseInt(process.env.BROWSE_PORT_START || '9400', 10);
for (let port = start; port < start + 10; port++) { for (let port = start; port < start + 10; port++) {
try { if (await isPortAvailable(port)) {
const testServer = Bun.serve({ port, fetch: () => new Response('ok') });
testServer.stop();
return port; return port;
} catch {
continue;
} }
} }
throw new Error(`[browse] No available port in range ${start}-${start + 9}`); throw new Error(`[browse] No available port in range ${start}-${start + 9}`);
@ -208,43 +211,65 @@ async function start() {
await browserManager.launch(); await browserManager.launch();
const startTime = Date.now(); const startTime = Date.now();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
fetch: async (req) => {
resetIdleTimer();
const url = new URL(req.url); // Collect full request body from node:http IncomingMessage
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (chunk: Buffer) => { data += chunk; });
req.on('end', () => resolve(data));
req.on('error', reject);
});
}
// Health check — no auth required // Send a Response object through node:http ServerResponse
if (url.pathname === '/health') { async function sendResponse(res: ServerResponse, response: Response) {
const healthy = browserManager.isHealthy(); const body = await response.text();
return new Response(JSON.stringify({ const headers: Record<string, string> = {};
status: healthy ? 'healthy' : 'unhealthy', response.headers.forEach((v, k) => { headers[k] = v; });
uptime: Math.floor((Date.now() - startTime) / 1000), res.writeHead(response.status, headers);
tabs: browserManager.getTabCount(), res.end(body);
currentUrl: browserManager.getCurrentUrl(), }
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// All other endpoints require auth const server = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
if (!validateAuth(req)) { resetIdleTimer();
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.pathname === '/command' && req.method === 'POST') { const url = new URL(req.url!, `http://127.0.0.1:${port}`);
const body = await req.json();
return handleCommand(body);
}
return new Response('Not found', { status: 404 }); // Health check — no auth required
}, if (url.pathname === '/health') {
const healthy = browserManager.isHealthy();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: healthy ? 'healthy' : 'unhealthy',
uptime: Math.floor((Date.now() - startTime) / 1000),
tabs: browserManager.getTabCount(),
currentUrl: browserManager.getCurrentUrl(),
}));
return;
}
// All other endpoints require auth
if (req.headers['authorization'] !== `Bearer ${AUTH_TOKEN}`) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
if (url.pathname === '/command' && req.method === 'POST') {
const bodyStr = await readBody(req);
const body = JSON.parse(bodyStr);
const response = await handleCommand(body);
await sendResponse(res, response);
return;
}
res.writeHead(404);
res.end('Not found');
});
await new Promise<void>((resolve) => {
server.listen(port, '127.0.0.1', () => resolve());
}); });
// Write state file // Write state file
@ -253,7 +278,7 @@ async function start() {
port, port,
token: AUTH_TOKEN, token: AUTH_TOKEN,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
serverPath: path.resolve(import.meta.dir, 'server.ts'), serverPath: path.resolve(import.meta.dir ?? __dirname, 'server.ts'),
}; };
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 }); fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });

View File

@ -8,7 +8,7 @@
"browse": "./browse/dist/browse" "browse": "./browse/dist/browse"
}, },
"scripts": { "scripts": {
"build": "bun build --compile browse/src/cli.ts --outfile browse/dist/browse", "build": "bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build browse/src/server.ts --outfile browse/dist/server.js --target node --packages external --format esm",
"dev": "bun run browse/src/cli.ts", "dev": "bun run browse/src/cli.ts",
"server": "bun run browse/src/server.ts", "server": "bun run browse/src/server.ts",
"test": "bun test", "test": "bun test",