1111
1212import * as fs from 'fs' ;
1313import * as path from 'path' ;
14- import { spawn as nodeSpawn } from 'child_process' ;
14+ import { spawn as nodeSpawn , spawnSync } from 'child_process' ;
1515import { safeUnlink , safeUnlinkQuiet , safeKill , isProcessAlive } from './error-handling' ;
1616import { writeSecureFile , mkdirSecure } from './file-permissions' ;
1717import { resolveConfig , ensureStateDir , readVersionHash } from './config' ;
@@ -21,7 +21,9 @@ import { spawnTerminalAgent } from './terminal-agent-control';
2121
2222const config = resolveConfig ( ) ;
2323const 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
2628export 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
0 commit comments