1111
1212import * as fs from 'fs' ;
1313import * as path from 'path' ;
14+ import { spawnSync } from 'node:child_process' ;
1415import { safeUnlink , safeUnlinkQuiet , safeKill , isProcessAlive } from './error-handling' ;
1516import { writeSecureFile , mkdirSecure } from './file-permissions' ;
1617import { resolveConfig , ensureStateDir , readVersionHash } from './config' ;
@@ -19,7 +20,9 @@ import { redactProxyUrl } from './proxy-redact';
1920
2021const config = resolveConfig ( ) ;
2122const 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
2427export 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
0 commit comments