(extraEnv?: Record<string, string>)
| 293 | |
| 294 | // ─── Server Lifecycle ────────────────────────────────────────── |
| 295 | async function startServer(extraEnv?: Record<string, string>): Promise<ServerState> { |
| 296 | ensureStateDir(config); |
| 297 | |
| 298 | // Clean up stale state file and error log |
| 299 | safeUnlink(config.stateFile); |
| 300 | safeUnlink(path.join(config.stateDir, 'browse-startup-error.log')); |
| 301 | |
| 302 | // #1781: clear a stale Chromium profile lock (and kill the orphan still |
| 303 | // holding it) before launch, so an auto-restart after an abrupt kill isn't |
| 304 | // blocked by the previous Chromium's SingletonLock — the self-inflicted |
| 305 | // crash-loop. Previously only the manual connect preamble did this. |
| 306 | await killOrphanChromium(); |
| 307 | cleanChromiumProfileLocks(); |
| 308 | |
| 309 | // Allow the caller to opt out of the parent-process watchdog by setting |
| 310 | // BROWSE_PARENT_PID=0 in the environment. Useful for CI, non-interactive |
| 311 | // shells, and short-lived Bash invocations that need the server to outlive |
| 312 | // the spawning CLI. Defaults to the current process PID (watchdog active). |
| 313 | // Parse as int so stray whitespace ("0\n") still opts out — matches the |
| 314 | // server's own parseInt at server.ts:760. |
| 315 | const parentPid = parseInt(process.env.BROWSE_PARENT_PID || '', 10) === 0 ? '0' : String(process.pid); |
| 316 | |
| 317 | if (IS_WINDOWS && NODE_SERVER_SCRIPT) { |
| 318 | // Windows: Bun.spawn() + proc.unref() doesn't truly detach on Windows — |
| 319 | // when the CLI exits, the server dies with it. Use Node's child_process.spawn |
| 320 | // with { detached: true } instead, which is the gold standard for Windows |
| 321 | // process independence. Credit: PR #191 by @fqueiro. |
| 322 | const extraEnvStr = JSON.stringify({ BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...(extraEnv || {}) }); |
| 323 | const launcherCode = |
| 324 | `const{spawn}=require('child_process');` + |
| 325 | `spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` + |
| 326 | `{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` + |
| 327 | `${extraEnvStr})}).unref()`; |
| 328 | Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] }); |
| 329 | } else { |
| 330 | // macOS/Linux: Bun.spawn().unref() only removes the child from Bun's event |
| 331 | // loop — it does NOT call setsid(), so the spawned server stays in the |
| 332 | // parent's process session. When the CLI runs inside a session-managed |
| 333 | // shell (e.g. Claude Code's per-command Bash sandbox, Conductor, CI |
| 334 | // step runners), the session leader's exit sends SIGHUP to every PID in |
| 335 | // the session, killing the bun server (and its Chromium grandchildren). |
| 336 | // Even with BROWSE_PARENT_PID=0 disabling the watchdog, SIGHUP still |
| 337 | // reaps the server. Use Node's child_process.spawn with detached:true, |
| 338 | // which calls setsid() so the server becomes its own session leader |
| 339 | // (PPID=1, STAT=Ss) and survives the spawning shell's exit. Mirrors |
| 340 | // the Windows path's rationale — same root cause, different OS API. |
| 341 | nodeSpawn('bun', ['run', SERVER_SCRIPT], { |
| 342 | detached: true, |
| 343 | stdio: ['ignore', 'ignore', 'ignore'], |
| 344 | env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv }, |
| 345 | }).unref(); |
| 346 | } |
| 347 | |
| 348 | // Wait for server to become healthy. |
| 349 | // Use HTTP health check (not isProcessAlive) — it's fast (~instant ECONNREFUSED) |
| 350 | // and works reliably on all platforms including Windows. |
| 351 | const start = Date.now(); |
| 352 | while (Date.now() - start < MAX_START_WAIT) { |
no test coverage detected