Skip to content

Commit 57c9a50

Browse files
committed
Fix Codex browse runtime on Windows
1 parent b03cd1a commit 57c9a50

5 files changed

Lines changed: 146 additions & 20 deletions

File tree

browse/src/cli.ts

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

1212
import * as fs from 'fs';
1313
import * as path from 'path';
14+
import { spawnSync } from 'node:child_process';
1415
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
1516
import { writeSecureFile, mkdirSecure } from './file-permissions';
1617
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
@@ -19,7 +20,9 @@ import { redactProxyUrl } from './proxy-redact';
1920

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

2427
export function resolveServerScript(
2528
env: Record<string, string | undefined> = process.env,
@@ -229,16 +232,15 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
229232

230233
if (IS_WINDOWS && NODE_SERVER_SCRIPT) {
231234
// 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.
235+
// when the CLI exits, the server dies with it. Use a tiny Node launcher
236+
// with { detached: true }, which is the reliable Windows detach path.
235237
const extraEnvStr = JSON.stringify({ BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...(extraEnv || {}) });
236238
const launcherCode =
237239
`const{spawn}=require('child_process');` +
238240
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
239241
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
240242
`${extraEnvStr})}).unref()`;
241-
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
243+
spawnSync('node', ['-e', launcherCode], { stdio: 'ignore' });
242244
} else {
243245
// macOS/Linux: Bun.spawn + unref works correctly
244246
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
@@ -255,6 +257,7 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
255257
while (Date.now() - start < MAX_START_WAIT) {
256258
const state = readState();
257259
if (state && await isServerHealthy(state.port)) {
260+
startedServerThisRun = true;
258261
return state;
259262
}
260263
await Bun.sleep(100);
@@ -384,7 +387,10 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
384387
const start = Date.now();
385388
while (Date.now() - start < MAX_START_WAIT) {
386389
const freshState = readState();
387-
if (freshState && await isServerHealthy(freshState.port)) return freshState;
390+
if (freshState && await isServerHealthy(freshState.port)) {
391+
startedServerThisRun = true;
392+
return freshState;
393+
}
388394
await Bun.sleep(200);
389395
}
390396
throw new Error('Timed out waiting for another instance to start the server');
@@ -394,6 +400,7 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
394400
// Re-read state under lock in case another process just started the server
395401
const freshState = readState();
396402
if (freshState && await isServerHealthy(freshState.port)) {
403+
startedServerThisRun = true;
397404
return freshState;
398405
}
399406

@@ -405,8 +412,6 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
405412
console.error(`[browse] Starting server with proxy ${flags.redactedProxyUrl}${flags.headed ? ' (headed)' : ''}...`);
406413
} else if (flags?.headed) {
407414
console.error('[browse] Starting server in headed mode...');
408-
} else {
409-
console.error('[browse] Starting server...');
410415
}
411416
return await startServer(extraEnv);
412417
} finally {
@@ -469,10 +474,8 @@ async function sendCommand(state: ServerState, command: string, args: string[],
469474
}
470475

471476
const text = await resp.text();
472-
473477
if (resp.ok) {
474-
process.stdout.write(text);
475-
if (!text.endsWith('\n')) process.stdout.write('\n');
478+
await writeStdout(text);
476479
} else {
477480
// Try to parse as JSON error
478481
try {
@@ -489,8 +492,15 @@ async function sendCommand(state: ServerState, command: string, args: string[],
489492
console.error('[browse] Command timed out after 30s');
490493
process.exit(1);
491494
}
492-
// Connection error — server may have crashed
495+
// `stop` intentionally tears the daemon down. On Windows/Node the socket
496+
// can close before the response body reaches the CLI; treat that as a
497+
// successful stop instead of triggering the generic crash-restart path.
493498
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
499+
if (command === 'stop' && !(await isServerHealthy(state.port))) {
500+
safeUnlinkQuiet(config.stateFile);
501+
await writeStdout('Server stopped');
502+
return;
503+
}
494504
if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting');
495505
console.error('[browse] Server connection lost. Restarting...');
496506
// Kill the old server to avoid orphaned chromium processes
@@ -513,6 +523,32 @@ async function sendCommand(state: ServerState, command: string, args: string[],
513523
}
514524
}
515525

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

1183+
// stop must never auto-start a daemon. The generic command path calls
1184+
// ensureServer(), which is correct for normal browser commands but wrong for
1185+
// shutdown: `browse stop` from a clean state should be a no-op, not a
1186+
// start-then-stop cycle that can leave a detached Windows process behind.
1187+
if (command === 'stop') {
1188+
await handleStopCommand(commandArgs);
1189+
process.exit(0);
1190+
}
1191+
11471192
// Special case: chain reads from stdin
11481193
if (command === 'chain' && commandArgs.length === 0) {
11491194
const stdin = await Bun.stdin.text();
@@ -1152,6 +1197,18 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
11521197

11531198
let state = await ensureServer(globalFlags);
11541199

1200+
if (startedServerThisRun && process.env.BROWSE_SKIP_REEXEC_AFTER_START !== '1') {
1201+
const result = spawnSync(process.execPath, process.argv.slice(2), {
1202+
stdio: ['ignore', 'pipe', 'pipe'],
1203+
encoding: 'utf8',
1204+
env: { ...process.env, BROWSE_SKIP_REEXEC_AFTER_START: '1' },
1205+
});
1206+
if (result.error) throw result.error;
1207+
if (result.stdout) fs.writeSync(1, result.stdout);
1208+
if (result.stderr) fs.writeSync(2, result.stderr);
1209+
process.exit(result.status ?? 1);
1210+
}
1211+
11551212
// ─── Pair-Agent (post-server, pre-dispatch) ──────────────
11561213
if (command === 'pair-agent') {
11571214
// 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
@@ -648,6 +648,39 @@ create_agents_sidecar() {
648648
done
649649
}
650650

651+
link_browse_runtime_assets() {
652+
local gstack_dir="$1"
653+
local runtime_root="$2"
654+
655+
mkdir -p "$runtime_root/browse" "$runtime_root/node_modules"
656+
657+
if [ -d "$gstack_dir/browse/dist" ]; then
658+
_link_or_copy "$gstack_dir/browse/dist" "$runtime_root/browse/dist"
659+
fi
660+
# The compiled Windows CLI still resolves browse/src/server.ts at startup
661+
# before it delegates to the Node-compatible server bundle.
662+
if [ -d "$gstack_dir/browse/src" ]; then
663+
_link_or_copy "$gstack_dir/browse/src" "$runtime_root/browse/src"
664+
fi
665+
if [ -d "$gstack_dir/browse/bin" ]; then
666+
_link_or_copy "$gstack_dir/browse/bin" "$runtime_root/browse/bin"
667+
fi
668+
669+
# server-node.mjs intentionally externalizes these runtime packages.
670+
for dep in playwright playwright-core diff; do
671+
if [ -d "$gstack_dir/node_modules/$dep" ]; then
672+
_link_or_copy "$gstack_dir/node_modules/$dep" "$runtime_root/node_modules/$dep"
673+
fi
674+
done
675+
if [ -d "$gstack_dir/node_modules/@ngrok" ]; then
676+
mkdir -p "$runtime_root/node_modules/@ngrok"
677+
for scoped_dep in "$gstack_dir/node_modules/@ngrok"/*; do
678+
[ -e "$scoped_dep" ] || continue
679+
_link_or_copy "$scoped_dep" "$runtime_root/node_modules/@ngrok/$(basename "$scoped_dep")"
680+
done
681+
fi
682+
}
683+
651684
# ─── Helper: create a minimal ~/.codex/skills/gstack runtime root ───────────
652685
# Codex scans ~/.codex/skills recursively. Exposing the whole repo here causes
653686
# duplicate skills because source SKILL.md files and generated Codex skills are
@@ -673,12 +706,7 @@ create_codex_runtime_root() {
673706
if [ -d "$gstack_dir/bin" ]; then
674707
_link_or_copy "$gstack_dir/bin" "$codex_gstack/bin"
675708
fi
676-
if [ -d "$gstack_dir/browse/dist" ]; then
677-
_link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist"
678-
fi
679-
if [ -d "$gstack_dir/browse/bin" ]; then
680-
_link_or_copy "$gstack_dir/browse/bin" "$codex_gstack/browse/bin"
681-
fi
709+
link_browse_runtime_assets "$gstack_dir" "$codex_gstack"
682710
if [ -f "$agents_dir/gstack-upgrade/SKILL.md" ]; then
683711
_link_or_copy "$agents_dir/gstack-upgrade/SKILL.md" "$codex_gstack/gstack-upgrade/SKILL.md"
684712
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)