Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,10 @@ Data is stored in [Supabase](https://supabase.com) (open source Firebase alterna

**`/browse` fails?** `cd ~/.claude/skills/gstack && bun install && bun run build`

If startup says `Local port bind was blocked by the environment (EPERM)` or `EACCES`, that is usually a sandbox or permission restriction, not real port exhaustion. Re-run with escalated permissions or outside the sandbox.

If you see the older message `No available port after 5 attempts`, your install is stale. Rebuild or upgrade gstack so browse reports the real bind error.

**Stale install?** Run `/gstack-upgrade` — or set `auto_upgrade: true` in `~/.gstack/config.yaml`

**Want shorter commands?** `cd ~/.claude/skills/gstack && ./setup --no-prefix` — switches from `/gstack-qa` to `/qa`. Your choice is remembered for future upgrades.
Expand Down
104 changes: 104 additions & 0 deletions browse/src/port.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import * as net from 'net';

export type PortCheckResult =
| { ok: true }
| { ok: false; code?: string; message?: string };

type CheckPortFn = (port: number, hostname: string) => Promise<PortCheckResult>;

export interface FindPortOptions {
requestedPort?: number;
hostname?: string;
minPort?: number;
maxPort?: number;
maxRetries?: number;
randomInt?: (minPort: number, maxPort: number) => number;
checkPort?: CheckPortFn;
}

export function checkPortAvailability(port: number, hostname: string = '127.0.0.1'): Promise<PortCheckResult> {
return new Promise((resolve) => {
const srv = net.createServer();
srv.once('error', (err: NodeJS.ErrnoException) => {
resolve({
ok: false,
code: err.code,
message: err.message,
});
});
srv.listen(port, hostname, () => {
srv.close(() => resolve({ ok: true }));
});
});
}

function isPermissionError(code?: string): boolean {
return code === 'EPERM' || code === 'EACCES';
}

function formatPermissionError(port: number, code?: string, explicit: boolean = false): Error {
const detail = code ? ` (${code})` : '';
if (explicit) {
return new Error(
`[browse] Port ${port} (from BROWSE_PORT env) was blocked by the environment${detail}. ` +
`Re-run with escalated permissions or outside the sandbox.`,
);
}

return new Error(
`[browse] Local port bind was blocked by the environment${detail} while probing port ${port}. ` +
`Re-run with escalated permissions or outside the sandbox.`,
);
}

function formatUnexpectedBindError(port: number, message?: string, explicit: boolean = false): Error {
if (explicit) {
return new Error(
`[browse] Port ${port} (from BROWSE_PORT env) could not be bound: ${message || 'unknown error'}`,
);
}

return new Error(
`[browse] Unexpected error while probing port ${port}: ${message || 'unknown error'}`,
);
}

export async function findPort(options: FindPortOptions = {}): Promise<number> {
const hostname = options.hostname || '127.0.0.1';
const minPort = options.minPort ?? 10000;
const maxPort = options.maxPort ?? 60000;
const maxRetries = options.maxRetries ?? 5;
const requestedPort = options.requestedPort ?? 0;
const randomInt = options.randomInt || ((min: number, max: number) => min + Math.floor(Math.random() * (max - min)));
const checkPort = options.checkPort || checkPortAvailability;

if (requestedPort) {
const result = await checkPort(requestedPort, hostname);
if (result.ok) {
return requestedPort;
}
if (isPermissionError(result.code)) {
throw formatPermissionError(requestedPort, result.code, true);
}
if (result.code === 'EADDRINUSE') {
throw new Error(`[browse] Port ${requestedPort} (from BROWSE_PORT env) is in use`);
}
throw formatUnexpectedBindError(requestedPort, result.message, true);
}

for (let attempt = 0; attempt < maxRetries; attempt++) {
const port = randomInt(minPort, maxPort);
const result = await checkPort(port, hostname);
if (result.ok) {
return port;
}
if (isPermissionError(result.code)) {
throw formatPermissionError(port, result.code);
}
if (result.code !== 'EADDRINUSE') {
throw formatUnexpectedBindError(port, result.message);
}
}

throw new Error(`[browse] No available port after ${maxRetries} attempts in range ${minPort}-${maxPort}`);
}
41 changes: 2 additions & 39 deletions browse/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
import { inspectElement, modifyStyle, resetModifications, getModificationHistory, detachSession, type InspectorResult } from './cdp-inspector';
import { findPort } from './port';
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
// fail posix_spawn on all executables including /bin/bash)
import * as fs from 'fs';
import * as net from 'net';
import * as path from 'path';
import * as crypto from 'crypto';

Expand Down Expand Up @@ -710,43 +710,6 @@ function emitInspectorEvent(event: any): void {
const browserManager = new BrowserManager();
let isShuttingDown = false;

// Test if a port is available by binding and immediately releasing.
// Uses net.createServer instead of Bun.serve to avoid a race condition
// in the Node.js polyfill where listen/close are async but the caller
// expects synchronous bind semantics. See: #486
function isPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<boolean> {
return new Promise((resolve) => {
const srv = net.createServer();
srv.once('error', () => resolve(false));
srv.listen(port, hostname, () => {
srv.close(() => resolve(true));
});
});
}

// Find port: explicit BROWSE_PORT, or random in 10000-60000
async function findPort(): Promise<number> {
// Explicit port override (for debugging)
if (BROWSE_PORT) {
if (await isPortAvailable(BROWSE_PORT)) {
return BROWSE_PORT;
}
throw new Error(`[browse] Port ${BROWSE_PORT} (from BROWSE_PORT env) is in use`);
}

// Random port with retry
const MIN_PORT = 10000;
const MAX_PORT = 60000;
const MAX_RETRIES = 5;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const port = MIN_PORT + Math.floor(Math.random() * (MAX_PORT - MIN_PORT));
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`[browse] No available port after ${MAX_RETRIES} attempts in range ${MIN_PORT}-${MAX_PORT}`);
}

/**
* Translate Playwright errors into actionable messages for AI agents.
*/
Expand Down Expand Up @@ -1021,7 +984,7 @@ async function start() {
if (err.code !== 'ENOENT') console.debug('[browse] Log cleanup dialog:', err.message);
}

const port = await findPort();
const port = await findPort({ requestedPort: BROWSE_PORT || undefined });

// Launch browser (headless or headed with extension)
// BROWSE_HEADLESS_SKIP=1 skips browser launch entirely (for HTTP-only testing)
Expand Down
50 changes: 50 additions & 0 deletions browse/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,56 @@ describe('CLI server script resolution', () => {
// ─── CLI lifecycle ──────────────────────────────────────────────

describe('CLI lifecycle', () => {
test('explicit BROWSE_PORT is preserved when CLI auto-starts the server', async () => {
const stateFile = `/tmp/browse-test-state-port-${Date.now()}.json`;

const requestedPort = await new Promise<number>((resolve, reject) => {
const srv = require('net').createServer();
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
});

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;
cliEnv.BROWSE_HEADLESS_SKIP = '1';
cliEnv.BROWSE_PORT = String(requestedPort);

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;
let restartedPort: number | null = null;
if (fs.existsSync(stateFile)) {
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
restartedPid = state.pid ?? null;
restartedPort = state.port ?? null;
fs.unlinkSync(stateFile);
}
if (restartedPid) {
try { process.kill(restartedPid, 'SIGTERM'); } catch {}
}

expect(result.code).toBe(0);
expect(result.stderr).toContain('Starting server');
expect(restartedPort).toBe(requestedPort);
}, 20000);

test('dead state file triggers a clean restart', async () => {
const stateFile = `/tmp/browse-test-state-${Date.now()}.json`;
fs.writeFileSync(stateFile, JSON.stringify({
Expand Down
34 changes: 34 additions & 0 deletions browse/test/findport.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, test, expect } from 'bun:test';
import * as net from 'net';
import * as path from 'path';
import { findPort } from '../src/port';

const polyfillPath = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs');

Expand Down Expand Up @@ -28,6 +29,39 @@ function getFreePort(): Promise<number> {
}

describe('findPort / isPortAvailable', () => {
test('findPort surfaces permission-denied errors instead of reporting port exhaustion', async () => {
await expect(findPort({
maxRetries: 5,
minPort: 12000,
maxPort: 12001,
randomInt: () => 12000,
checkPort: async () => ({
ok: false,
code: 'EPERM',
message: 'listen EPERM: operation not permitted 127.0.0.1:12000',
}),
})).rejects.toThrow(/blocked by the environment/i);
});

test('findPort reports permission-denied on explicit BROWSE_PORT overrides', async () => {
await expect(findPort({
requestedPort: 43123,
checkPort: async () => ({
ok: false,
code: 'EACCES',
message: 'listen EACCES: permission denied 127.0.0.1:43123',
}),
})).rejects.toThrow(/43123/);

await expect(findPort({
requestedPort: 43123,
checkPort: async () => ({
ok: false,
code: 'EACCES',
message: 'listen EACCES: permission denied 127.0.0.1:43123',
}),
})).rejects.toThrow(/blocked by the environment/i);
});

test('isPortAvailable returns true for a free port', async () => {
// Use the same isPortAvailable logic from server.ts
Expand Down