diff --git a/CHANGELOG.md b/CHANGELOG.md index fd3d7330a9..c41a389938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.32.0.1] - 2026-06-16 + +### Fixed + +- **browse daemon: fix `stop` / `restart` crash-loop** — `meta-commands.ts` + called `await shutdown()` before returning the HTTP response, tearing down + the listener so the response never flushed. The CLI saw `ECONNRESET`, its + crash-retry logic re-sent the command, and the daemon logged "crashed twice + in a row" before exit. Fix: defer `shutdown()` via `setTimeout(0)` so the + response flushes first. Added regression tests for both commands. + ## [1.32.0.0] - 2026-05-10 ## **Seven contributor PRs land. Three are security or hardening.** diff --git a/VERSION b/VERSION index de3ddb989b..2f0c2a938c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.32.0.0 +1.32.0.1 diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index c505d4cf41..1c2935784e 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -411,14 +411,19 @@ export async function handleMetaCommand( } case 'stop': { - await shutdown(); + // Defer shutdown so the HTTP response flushes before the listener + // is torn down. Without this deferral the CLI sees ECONNRESET, + // its crash-retry logic re-sends the command, and the daemon logs + // "crashed twice in a row" before exit. + setTimeout(() => { shutdown(); }, 0); return 'Server stopped'; } case 'restart': { - // Signal that we want a restart — the CLI will detect exit and restart + // Same deferral as stop: send the response first, then exit so the + // CLI can restart the daemon cleanly. console.log('[browse] Restart requested. Exiting for CLI to restart.'); - await shutdown(); + setTimeout(() => { shutdown(); }, 0); return 'Restarting...'; } diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index b3870c0ccf..2aaeccf7b9 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -622,6 +622,42 @@ describe('Tabs', () => { }); }); +// ─── Server Control ──────────────────────────────────────────── + +describe('Server Control', () => { + test('status returns healthy', async () => { + const result = await handleMetaCommand('status', [], bm, async () => {}); + expect(result).toContain('healthy'); + }); + + test('stop returns response before shutdown fires (regression: crash-loop)', async () => { + let shutdownCalled = false; + const shutdown = () => { shutdownCalled = true; }; + + const result = await handleMetaCommand('stop', [], bm, shutdown); + // The fix defers shutdown via setTimeout(0) so the response flushes first. + // shutdownCalled must still be false here — the deferred callback hasn't fired yet. + expect(shutdownCalled).toBe(false); + expect(result).toBe('Server stopped'); + + // Wait for the deferred shutdown to fire. + await new Promise(resolve => setTimeout(resolve, 50)); + expect(shutdownCalled).toBe(true); + }); + + test('restart returns response before shutdown fires (regression: crash-loop)', async () => { + let shutdownCalled = false; + const shutdown = () => { shutdownCalled = true; }; + + const result = await handleMetaCommand('restart', [], bm, shutdown); + expect(shutdownCalled).toBe(false); + expect(result).toBe('Restarting...'); + + await new Promise(resolve => setTimeout(resolve, 50)); + expect(shutdownCalled).toBe(true); + }); +}); + // ─── Diff ─────────────────────────────────────────────────────── describe('Diff', () => { diff --git a/package.json b/package.json index 424ac7db70..8cce203f76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.32.0.0", + "version": "1.32.0.1", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module",