Skip to content

Commit eca0610

Browse files
committed
Fix Codex browse runtime on Windows
1 parent a6fb317 commit eca0610

5 files changed

Lines changed: 146 additions & 21 deletions

File tree

browse/src/cli.ts

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import * as fs from 'fs';
1313
import * as path from 'path';
14-
import { spawn as nodeSpawn } from 'child_process';
14+
import { spawn as nodeSpawn, spawnSync } from 'child_process';
1515
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
1616
import { writeSecureFile, mkdirSecure } from './file-permissions';
1717
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
@@ -21,7 +21,9 @@ import { spawnTerminalAgent } from './terminal-agent-control';
2121

2222
const config = resolveConfig();
2323
const IS_WINDOWS = process.platform === 'win32';
24-
const MAX_START_WAIT = IS_WINDOWS ? 15000 : (process.env.CI ? 30000 : 8000); // Node+Chromium takes longer on Windows
24+
const DEFAULT_START_WAIT = IS_WINDOWS ? 45000 : (process.env.CI ? 30000 : 8000); // Node+Chromium takes longer on Windows
25+
const MAX_START_WAIT = Number.parseInt(process.env.BROWSE_START_WAIT_MS || '', 10) || DEFAULT_START_WAIT;
26+
let startedServerThisRun = false;
2527

2628
export function resolveServerScript(
2729
env: Record<string, string | undefined> = process.env,
@@ -229,16 +231,15 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
229231

230232
if (IS_WINDOWS && NODE_SERVER_SCRIPT) {
231233
// Windows: Bun.spawn() + proc.unref() doesn't truly detach on Windows —
232-
// when the CLI exits, the server dies with it. Use Node's child_process.spawn
233-
// with { detached: true } instead, which is the gold standard for Windows
234-
// process independence. Credit: PR #191 by @fqueiro.
234+
// when the CLI exits, the server dies with it. Use a tiny Node launcher
235+
// with { detached: true }, which is the reliable Windows detach path.
235236
const extraEnvStr = JSON.stringify({ BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...(extraEnv || {}) });
236237
const launcherCode =
237238
`const{spawn}=require('child_process');` +
238239
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
239240
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
240241
`${extraEnvStr})}).unref()`;
241-
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
242+
spawnSync('node', ['-e', launcherCode], { stdio: 'ignore' });
242243
} else {
243244
// macOS/Linux: Bun.spawn().unref() only removes the child from Bun's event
244245
// loop — it does NOT call setsid(), so the spawned server stays in the
@@ -265,6 +266,7 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
265266
while (Date.now() - start < MAX_START_WAIT) {
266267
const state = readState();
267268
if (state && await isServerHealthy(state.port)) {
269+
startedServerThisRun = true;
268270
return state;
269271
}
270272
await Bun.sleep(100);
@@ -384,7 +386,10 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
384386
const start = Date.now();
385387
while (Date.now() - start < MAX_START_WAIT) {
386388
const freshState = readState();
387-
if (freshState && await isServerHealthy(freshState.port)) return freshState;
389+
if (freshState && await isServerHealthy(freshState.port)) {
390+
startedServerThisRun = true;
391+
return freshState;
392+
}
388393
await Bun.sleep(200);
389394
}
390395
throw new Error('Timed out waiting for another instance to start the server');
@@ -394,6 +399,7 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
394399
// Re-read state under lock in case another process just started the server
395400
const freshState = readState();
396401
if (freshState && await isServerHealthy(freshState.port)) {
402+
startedServerThisRun = true;
397403
return freshState;
398404
}
399405

@@ -405,8 +411,6 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
405411
console.error(`[browse] Starting server with proxy ${flags.redactedProxyUrl}${flags.headed ? ' (headed)' : ''}...`);
406412
} else if (flags?.headed) {
407413
console.error('[browse] Starting server in headed mode...');
408-
} else {
409-
console.error('[browse] Starting server...');
410414
}
411415
return await startServer(extraEnv);
412416
} finally {
@@ -469,10 +473,8 @@ async function sendCommand(state: ServerState, command: string, args: string[],
469473
}
470474

471475
const text = await resp.text();
472-
473476
if (resp.ok) {
474-
process.stdout.write(text);
475-
if (!text.endsWith('\n')) process.stdout.write('\n');
477+
await writeStdout(text);
476478
} else {
477479
// Try to parse as JSON error
478480
try {
@@ -489,8 +491,15 @@ async function sendCommand(state: ServerState, command: string, args: string[],
489491
console.error('[browse] Command timed out after 30s');
490492
process.exit(1);
491493
}
492-
// Connection error — server may have crashed
494+
// `stop` intentionally tears the daemon down. On Windows/Node the socket
495+
// can close before the response body reaches the CLI; treat that as a
496+
// successful stop instead of triggering the generic crash-restart path.
493497
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
498+
if (command === 'stop' && !(await isServerHealthy(state.port))) {
499+
safeUnlinkQuiet(config.stateFile);
500+
await writeStdout('Server stopped');
501+
return;
502+
}
494503
if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting');
495504
console.error('[browse] Server connection lost. Restarting...');
496505
// Kill the old server to avoid orphaned chromium processes
@@ -513,6 +522,32 @@ async function sendCommand(state: ServerState, command: string, args: string[],
513522
}
514523
}
515524

525+
async function writeStdout(text: string): Promise<void> {
526+
const output = text.endsWith('\n') ? text : `${text}\n`;
527+
fs.writeSync(1, output);
528+
}
529+
530+
async function handleStopCommand(commandArgs: string[]): Promise<void> {
531+
const state = readState();
532+
if (!state) {
533+
await writeStdout('Server not running');
534+
return;
535+
}
536+
537+
if (await isServerHealthy(state.port)) {
538+
await sendCommand(state, 'stop', commandArgs);
539+
return;
540+
}
541+
542+
if (state.pid && isProcessAlive(state.pid)) {
543+
await killServer(state.pid);
544+
await writeStdout('Server stopped');
545+
} else {
546+
await writeStdout('Server not running');
547+
}
548+
safeUnlinkQuiet(config.stateFile);
549+
}
550+
516551
// Module-level reference to the resolved global flags from main(). Used by
517552
// sendCommand's crash-retry path so a daemon restart after ECONNRESET doesn't
518553
// silently drop --proxy / --headed.
@@ -1220,6 +1255,15 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
12201255
process.exit(0);
12211256
}
12221257

1258+
// stop must never auto-start a daemon. The generic command path calls
1259+
// ensureServer(), which is correct for normal browser commands but wrong for
1260+
// shutdown: `browse stop` from a clean state should be a no-op, not a
1261+
// start-then-stop cycle that can leave a detached Windows process behind.
1262+
if (command === 'stop') {
1263+
await handleStopCommand(commandArgs);
1264+
process.exit(0);
1265+
}
1266+
12231267
// Special case: chain reads from stdin
12241268
if (command === 'chain' && commandArgs.length === 0) {
12251269
const stdin = await Bun.stdin.text();
@@ -1228,6 +1272,18 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
12281272

12291273
let state = await ensureServer(globalFlags);
12301274

1275+
if (startedServerThisRun && process.env.BROWSE_SKIP_REEXEC_AFTER_START !== '1') {
1276+
const result = spawnSync(process.execPath, process.argv.slice(2), {
1277+
stdio: ['ignore', 'pipe', 'pipe'],
1278+
encoding: 'utf8',
1279+
env: { ...process.env, BROWSE_SKIP_REEXEC_AFTER_START: '1' },
1280+
});
1281+
if (result.error) throw result.error;
1282+
if (result.stdout) fs.writeSync(1, result.stdout);
1283+
if (result.stderr) fs.writeSync(2, result.stderr);
1284+
process.exit(result.status ?? 1);
1285+
}
1286+
12311287
// ─── Pair-Agent (post-server, pre-dispatch) ──────────────
12321288
if (command === 'pair-agent') {
12331289
// Ensure headed mode — the user should see the browser window

browse/test/commands.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ beforeAll(async () => {
3333

3434
bm = new BrowserManager();
3535
await bm.launch();
36-
});
36+
}, 30000);
3737

3838
afterAll(() => {
3939
// Force kill browser instead of graceful close (avoids hang)

browse/test/config.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,30 @@ describe('startup error log', () => {
315315
});
316316
});
317317

318+
describe('cli command dispatch', () => {
319+
const cliSource = fs.readFileSync(path.resolve(__dirname, '../src/cli.ts'), 'utf-8');
320+
321+
test('handles stop before ensureServer so shutdown never auto-starts a daemon', () => {
322+
const stopDispatch = cliSource.indexOf('await handleStopCommand(commandArgs)');
323+
const ensureServerCall = cliSource.indexOf('let state = await ensureServer(globalFlags)');
324+
325+
expect(stopDispatch).toBeGreaterThan(-1);
326+
expect(ensureServerCall).toBeGreaterThan(-1);
327+
expect(stopDispatch).toBeLessThan(ensureServerCall);
328+
});
329+
330+
test('cold-start re-exec preserves command stdout on stdout', () => {
331+
expect(cliSource).toContain('if (result.stdout) fs.writeSync(1, result.stdout)');
332+
expect(cliSource).not.toContain('IS_WINDOWS ? 2 : 1, result.stdout');
333+
});
334+
335+
test('default headless cold-start does not print a delayed startup banner', () => {
336+
expect(cliSource).not.toContain("console.error('[browse] Starting server...')");
337+
expect(cliSource).toContain('Starting server in headed mode');
338+
expect(cliSource).toContain('Starting server with proxy');
339+
});
340+
});
341+
318342
describe('resolveGstackHome', () => {
319343
test('honors GSTACK_HOME env var when set', () => {
320344
const orig = process.env.GSTACK_HOME;
@@ -367,7 +391,7 @@ describe('resolveChromiumProfile', () => {
367391
delete process.env.CHROMIUM_PROFILE;
368392
process.env.GSTACK_HOME = '/tmp/fallback-gstack';
369393
try {
370-
expect(resolveChromiumProfile()).toBe('/tmp/fallback-gstack/chromium-profile');
394+
expect(resolveChromiumProfile()).toBe(path.join('/tmp/fallback-gstack', 'chromium-profile'));
371395
} finally {
372396
if (origEnv !== undefined) process.env.CHROMIUM_PROFILE = origEnv;
373397
if (origHome === undefined) delete process.env.GSTACK_HOME;

setup

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,39 @@ create_agents_sidecar() {
668668
done
669669
}
670670

671+
link_browse_runtime_assets() {
672+
local gstack_dir="$1"
673+
local runtime_root="$2"
674+
675+
mkdir -p "$runtime_root/browse" "$runtime_root/node_modules"
676+
677+
if [ -d "$gstack_dir/browse/dist" ]; then
678+
_link_or_copy "$gstack_dir/browse/dist" "$runtime_root/browse/dist"
679+
fi
680+
# The compiled Windows CLI still resolves browse/src/server.ts at startup
681+
# before it delegates to the Node-compatible server bundle.
682+
if [ -d "$gstack_dir/browse/src" ]; then
683+
_link_or_copy "$gstack_dir/browse/src" "$runtime_root/browse/src"
684+
fi
685+
if [ -d "$gstack_dir/browse/bin" ]; then
686+
_link_or_copy "$gstack_dir/browse/bin" "$runtime_root/browse/bin"
687+
fi
688+
689+
# server-node.mjs intentionally externalizes these runtime packages.
690+
for dep in playwright playwright-core diff; do
691+
if [ -d "$gstack_dir/node_modules/$dep" ]; then
692+
_link_or_copy "$gstack_dir/node_modules/$dep" "$runtime_root/node_modules/$dep"
693+
fi
694+
done
695+
if [ -d "$gstack_dir/node_modules/@ngrok" ]; then
696+
mkdir -p "$runtime_root/node_modules/@ngrok"
697+
for scoped_dep in "$gstack_dir/node_modules/@ngrok"/*; do
698+
[ -e "$scoped_dep" ] || continue
699+
_link_or_copy "$scoped_dep" "$runtime_root/node_modules/@ngrok/$(basename "$scoped_dep")"
700+
done
701+
fi
702+
}
703+
671704
# ─── Helper: create a minimal ~/.codex/skills/gstack runtime root ───────────
672705
# Codex scans ~/.codex/skills recursively. Exposing the whole repo here causes
673706
# duplicate skills because source SKILL.md files and generated Codex skills are
@@ -693,12 +726,7 @@ create_codex_runtime_root() {
693726
if [ -d "$gstack_dir/bin" ]; then
694727
_link_or_copy "$gstack_dir/bin" "$codex_gstack/bin"
695728
fi
696-
if [ -d "$gstack_dir/browse/dist" ]; then
697-
_link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist"
698-
fi
699-
if [ -d "$gstack_dir/browse/bin" ]; then
700-
_link_or_copy "$gstack_dir/browse/bin" "$codex_gstack/browse/bin"
701-
fi
729+
link_browse_runtime_assets "$gstack_dir" "$codex_gstack"
702730
if [ -f "$agents_dir/gstack-upgrade/SKILL.md" ]; then
703731
_link_or_copy "$agents_dir/gstack-upgrade/SKILL.md" "$codex_gstack/gstack-upgrade/SKILL.md"
704732
fi

test/gen-skill-docs.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2224,6 +2224,23 @@ describe('setup script validation', () => {
22242224
expect(setupContent).toContain('create_agents_sidecar "$SOURCE_GSTACK_DIR"');
22252225
});
22262226

2227+
test('Codex runtime root includes browse source and externalized Node deps', () => {
2228+
const buildScript = fs.readFileSync(path.join(ROOT, 'browse', 'scripts', 'build-node-server.sh'), 'utf-8');
2229+
const externals = [...buildScript.matchAll(/--external\s+"?([^"\\\s]+)"?/g)]
2230+
.map((match) => match[1])
2231+
.filter((dep) => dep !== 'bun:sqlite');
2232+
const fnStart = setupContent.indexOf('link_browse_runtime_assets()');
2233+
const fnEnd = setupContent.indexOf('# ─── Helper: create a minimal ~/.codex/skills/gstack runtime root', fnStart);
2234+
const fnBody = setupContent.slice(fnStart, fnEnd);
2235+
2236+
expect(fnBody).toContain('browse/src');
2237+
for (const dep of externals) {
2238+
const root = dep.startsWith('@') ? dep.split('/')[0] : dep;
2239+
expect(fnBody).toContain(root);
2240+
}
2241+
expect(setupContent).toContain('link_browse_runtime_assets "$gstack_dir" "$codex_gstack"');
2242+
});
2243+
22272244
test('link_codex_skill_dirs reads from .agents/skills/', () => {
22282245
// The Codex link function must reference .agents/skills for generated Codex skills
22292246
const fnStart = setupContent.indexOf('link_codex_skill_dirs()');

0 commit comments

Comments
 (0)