Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.32.0.0
1.32.0.1
11 changes: 8 additions & 3 deletions browse/src/meta-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...';
}

Expand Down
36 changes: 36 additions & 0 deletions browse/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading