diff --git a/browse/src/server.ts b/browse/src/server.ts index 65377a91f..80553e7fb 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -186,10 +186,6 @@ async function shutdown() { clearInterval(idleCheckInterval); flushBuffers(); // Final flush - // Remove the state file before closing Chromium so CLI stop/restart polling - // can observe shutdown progress even if browser.close() takes a moment. - try { fs.unlinkSync(STATE_FILE); } catch {} - try { // Graceful close is best-effort here. If Chromium hangs, still exit so // stop/restart cannot wedge forever. @@ -199,6 +195,11 @@ async function shutdown() { ]); } catch {} + // Keep the state file until exit. Other CLI processes treat it as proof that + // this PID still owns the port, so deleting it early can trigger a bogus + // replacement start while the old daemon is still shutting down. + try { fs.unlinkSync(STATE_FILE); } catch {} + process.exit(0); } diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index 79cdb06c3..884384876 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -107,6 +107,15 @@ async function cleanupCliState(stateFile: string) { try { fs.unlinkSync(`${stateFile}.settings.json`); } catch {} } +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + // ─── Navigation ───────────────────────────────────────────────── describe('Navigation', () => { @@ -800,6 +809,59 @@ describe('CLI lifecycle', () => { await cleanupCliState(stateFile); } }, 20000); + + test('status during shutdown reuses the live daemon instead of racing a replacement', async () => { + const root = fs.mkdtempSync('/tmp/gstack-browse-shutdown-window-'); + const stateFile = path.join(root, 'browse-state.json'); + const shutdownMarker = path.join(root, 'shutdown-started'); + const wrapperPath = path.join(root, 'server-wrapper.ts'); + const realServerPath = path.resolve(__dirname, '../src/server.ts'); + const browserManagerPath = path.resolve(__dirname, '../src/browser-manager.ts'); + const port = reservePort(); + + fs.writeFileSync(wrapperPath, ` + import * as fs from 'fs'; + import { BrowserManager } from ${JSON.stringify(browserManagerPath)}; + + const originalClose = BrowserManager.prototype.close; + BrowserManager.prototype.close = async function (...args) { + fs.writeFileSync(${JSON.stringify(shutdownMarker)}, 'closing'); + await Bun.sleep(1500); + return await originalClose.apply(this, args); + }; + + await import(${JSON.stringify(realServerPath)}); + `); + + const env = { + BROWSE_STATE_FILE: stateFile, + BROWSE_PORT: String(port), + BROWSE_SERVER_SCRIPT: wrapperPath, + }; + + try { + const started = await runCliCommand(['status'], env); + const startedState = readJson<{ pid: number }>(stateFile); + expect(started.code).toBe(0); + expect(startedState?.pid).toBeTruthy(); + + const stopPromise = runCliCommand(['stop'], env, 15000); + const shutdownStarted = await waitFor(() => fs.existsSync(shutdownMarker) ? true : null, 5000); + expect(shutdownStarted).toBe(true); + expect(isPidAlive(startedState!.pid)).toBe(true); + + const status = await runCliCommand(['status'], env, 12000); + const stop = await stopPromise; + + expect(status.code).toBe(0); + expect(status.stdout).toContain('Status: healthy'); + expect(stop.code).toBe(0); + expect(stop.stdout).toContain('Server stopped'); + } finally { + await cleanupCliState(stateFile); + fs.rmSync(root, { recursive: true, force: true }); + } + }, 30000); }); // ─── Buffer bounds ──────────────────────────────────────────────