From 7d10949adb5773004beb3f8801bda2d775e52cf3 Mon Sep 17 00:00:00 2001 From: raf-com Date: Sun, 29 Mar 2026 13:17:09 -0700 Subject: [PATCH] fix(browse): replace Bun.serve port test with async net.Server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows via the Node.js polyfill, Bun.serve() wraps http.createServer() which is async — server.stop() doesn't wait for the port to actually be released before returning. This causes EADDRINUSE on every cold start when findPort() races with itself. Fix: use net.createServer() in a Promise wrapper that resolves only after server.close() fires its callback, guaranteeing the port is fully free. Also removes currentUrl from the unauthenticated /health endpoint to avoid leaking active page context to any local process that can reach the server. Fixes: #486 (Windows EADDRINUSE race), #473 (health endpoint data leak) Supersedes: #490, #493 Co-Authored-By: Claude Sonnet 4.6 --- browse/src/server.ts | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/browse/src/server.ts b/browse/src/server.ts index fe2c27cbc..10f0d6844 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -161,17 +161,32 @@ export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS }; const browserManager = new BrowserManager(); let isShuttingDown = false; +// Check if a TCP port is free by binding a net.Server and immediately releasing it. +// Using net.createServer (available in both Bun and Node.js) instead of Bun.serve so +// that the async listen/close cycle is fully awaited before we return. Bun.serve + +// server.stop() is racy on Windows via the Node.js polyfill: http.createServer.listen +// is async but stop() doesn't await the close callback, causing EADDRINUSE when the +// real server tries to bind the port we just "freed". See: github.com/garrytan/gstack/issues/486 +function isPortAvailable(port: number): Promise { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const net = require('net') as typeof import('net'); + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(port, '127.0.0.1', () => { + srv.close(() => resolve(true)); + }); + srv.on('error', () => resolve(false)); + }); +} + // Find port: explicit BROWSE_PORT, or random in 10000-60000 async function findPort(): Promise { // Explicit port override (for debugging) if (BROWSE_PORT) { - try { - const testServer = Bun.serve({ port: BROWSE_PORT, fetch: () => new Response('ok') }); - testServer.stop(); - return BROWSE_PORT; - } catch { + if (!(await isPortAvailable(BROWSE_PORT))) { throw new Error(`[browse] Port ${BROWSE_PORT} (from BROWSE_PORT env) is in use`); } + return BROWSE_PORT; } // Random port with retry @@ -180,13 +195,7 @@ async function findPort(): Promise { const MAX_RETRIES = 5; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { const port = MIN_PORT + Math.floor(Math.random() * (MAX_PORT - MIN_PORT)); - try { - const testServer = Bun.serve({ port, fetch: () => new Response('ok') }); - testServer.stop(); - return port; - } catch { - continue; - } + if (await isPortAvailable(port)) return port; } throw new Error(`[browse] No available port after ${MAX_RETRIES} attempts in range ${MIN_PORT}-${MAX_PORT}`); } @@ -321,13 +330,14 @@ async function start() { } // Health check — no auth required (now async) + // Note: currentUrl intentionally omitted — it could expose sensitive browsing + // context to any local process that can reach the health endpoint. if (url.pathname === '/health') { const healthy = await browserManager.isHealthy(); return new Response(JSON.stringify({ status: healthy ? 'healthy' : 'unhealthy', uptime: Math.floor((Date.now() - startTime) / 1000), tabs: browserManager.getTabCount(), - currentUrl: browserManager.getCurrentUrl(), }), { status: 200, headers: { 'Content-Type': 'application/json' },