Skip to content

bug: browse stop exits 1 and retries after successful shutdown #2254

Closed

Description

@GoloCatMash

Reproduction\n\nEnvironment: macOS arm64, origin/main 83238f9ba28b9a5dfa3405fa625b70f7b5b99d39.\n\n1. Run the generated browse binary with status. It starts and reports healthy.\n2. Run the same binary with stop.\n\nObserved output:\n\n [browse] Server connection lost. Restarting...\n [browse] [browse] Server crashed twice in a row — aborting\n\nThe stop command exits 1, although the daemon is actually gone afterward and no matching process remains.\n\n### Expected\n\nAn intentional stop should not enter the crash-restart path. It should stop the daemon and exit 0.

Activity

  1. time-attack commented on Jul 15, 2026

    @time-attack
    Contributor

    @16francej Close this because it duplicates or has been superseded by the selected issue covering the same shutdown retry behavior.

  2. p20240440-cpu commented on Aug 6, 2026

    @p20240440-cpu

    Confirming this on Windows 11 (gstack v1.60.1.0, fresh ./setup install), so it isn't macOS-specific. One difference from the original report: on Windows stop exits 0, not 1, while printing the same two lines. The daemon is genuinely gone afterward and no chrome-headless-shell processes are left behind.

    Root cause

    stop is dispatched as an ordinary command, and the handler exits the process before the response is ever written.

    browse/src/meta-commands.ts:

    case 'stop': {
      await shutdown();
      return 'Server stopped';
    }

    shutdown() ends with process.exit(exitCode) (browse/src/server.ts, end of the async function shutdown body). So return 'Server stopped' is unreachable and the HTTP response is never sent — the client always sees the socket drop.

    The CLI has no special case for stop (grepping cli.ts turns up only comments and help text), so the dead socket falls into the generic crash-recovery branch in sendCommand, which is guarded on ECONNREFUSED | ECONNRESET | 'fetch failed':

    1. stop → server exits → client sees the connection drop
    2. isProcessAlive(oldState.pid) is now false, so the busy-vs-dead probe from Headed browser "keeps crashing": HTTP-unresponsive daemon → orphaned Chromium → SingletonLock → restart crash-loop, and silent headless respawns #1781 correctly concludes "dead" and takes the restart path
    3. Client prints Server connection lost. Restarting... and launches a fresh daemon + Chromium
    4. It re-sends stop to the new daemon, which exits the same way
    5. retries >= 1 → Server crashed twice in a row — aborting

    So the CLI resurrects the exact daemon the user asked it to kill, then kills it again. Beyond the misleading message, the practical cost is a full Chromium cold start on every stop.

    Note restart has the same shape but wants this — its comment says "Signal that we want a restart — the CLI will detect exit and restart". stop inherits that behavior without wanting it, which is probably why this went unnoticed.

    Possible fixes

    1. Client-side (most robust): special-case stop in sendCommand so a connection drop after dispatching stop counts as success. The server is guaranteed to die, so a dropped socket is the expected outcome rather than an error.
    2. Server-side: write the response before tearing down — e.g. schedule shutdown() on the next tick / after flush so 'Server stopped' reaches the client first. Riskier, since it introduces a window where the daemon is alive but committed to exiting.

    Option 1 avoids depending on shutdown ordering at all.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions