← reports

#2480 · “Could not start bb” most of the time

Bug High Effort: Low desktop open on GitHub 2026-08-27 · base ad79bbb5ec90

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

The desktop app checks /health and then checks /api/v1/system/config. Each request has a 250 ms limit during startup. A short delay aborts the config request. The probe incorrectly marks this temporary network error as an incompatible server. The wait loop then stops, shows the false port message, and stops the server.

2. Claims vs findings

Claim from the issueStatusEvidence
The app shows the quoted config abort.VerifiedThe test produced the same reason text after one delayed config response.
Port 38886 was not open when the error occurred.RefutedThe message requires a successful /health request. The desktop app then stops its server before a later port check.
The failure is a timing issue.VerifiedA 75 ms config delay reproduced the error with a 20 ms request limit. A second response would have succeeded.
Repeated restarts sometimes succeed.UnverifiedThe timing mechanism makes this outcome possible. This investigation did not run repeated AppImage restarts.
The supplied host-daemon failure log explains this attempt.UnverifiedIts timestamps precede the supplied successful server log by about 19 hours.
The failure occurs most of the time on Linux Mint.UnverifiedThis investigation did not use the reporter's machine or AppImage.

3. Environment

4. Minimal reproduction

  1. Check out the report base commit and install its dependencies.
    git clone https://github.com/get-bb/bb.git
    cd bb
    git checkout ad79bbb5ec909524f8f281e62d860c588a86f332
    pnpm install --frozen-lockfile
  2. Before report publication, copy the test from the local reports checkout.
    reports_checkout=/tmp/bb-reports
    cp "$reports_checkout/issues/2480/repro/issue-2480-repro.test.ts" \
      apps/desktop/test/issue-2480-repro.test.ts

    After report publication, use this download command instead. The -f option stops on an HTTP error.

    curl -fL https://get-bb.github.io/reports/issues/2480/repro/issue-2480-repro.test.ts \
      -o apps/desktop/test/issue-2480-repro.test.ts
  3. Run the test.
    cd apps/desktop
    pnpm exec vitest run test/issue-2480-repro.test.ts

Expected: The startup wait retries the temporary abort. The second config response returns a compatible result.

Actual: The first abort returns an incompatible result. The wait stops immediately.

RUN  v4.1.1 /tmp/bb-report-2480-revise-mqyMju/apps/desktop

FAIL  |@bb/desktop| test/issue-2480-repro.test.ts > retries a transient config abort while the new bb server starts
AssertionError: expected { kind: 'incompatible', …(2) } to deeply equal { dataDir: null, …(2) }

- Expected
+ Received

  {
-   "dataDir": null,
-   "kind": "compatible",
+   "kind": "incompatible",
+   "reason": "/api/v1/system/config returned This operation was aborted",
    "serverUrl": "http://127.0.0.1:43409",
  }

Test Files  1 failed (1)
Tests       1 failed (1)
Duration    207ms

Repro files: test source, base failure, tested fix, and fix validation.

Test source

import {
  createServer,
  type IncomingMessage,
  type ServerResponse,
} from "node:http";
import { afterAll, beforeAll, expect, it } from "vitest";
import { waitForCompatibleServer } from "../src/server-probe.js";

let closeServer: (() => Promise<void>) | undefined;
let serverUrl = "";

function writeJson(response: ServerResponse, body: object): void {
  response.writeHead(200, { "content-type": "application/json" });
  response.end(JSON.stringify(body));
}

beforeAll(async () => {
  let configRequests = 0;
  const server = createServer(
    (request: IncomingMessage, response: ServerResponse) => {
      if (request.url === "/health") {
        writeJson(response, { ok: true });
        return;
      }
      if (request.url === "/api/v1/system/config") {
        configRequests += 1;
        if (configRequests === 1) {
          setTimeout(() => {
            writeJson(response, {
              hostDaemonPort: 38887,
              voiceTranscriptionEnabled: false,
            });
          }, 75);
          return;
        }
        writeJson(response, {
          hostDaemonPort: 38887,
          voiceTranscriptionEnabled: false,
        });
        return;
      }
      response.writeHead(404);
      response.end();
    },
  );
  await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
  const address = server.address();
  if (address === null || typeof address === "string") {
    throw new Error("The test server did not bind to a TCP port");
  }
  serverUrl = `http://127.0.0.1:${address.port}`;
  closeServer = () =>
    new Promise<void>((resolve, reject) => {
      server.close((error) => (error ? reject(error) : resolve()));
    });
});

afterAll(async () => {
  await closeServer?.();
});

it("retries a transient config abort while the new bb server starts", async () => {
  const result = await waitForCompatibleServer({
    intervalMs: 20,
    serverUrl,
    timeoutMs: 1_000,
  });

  expect(result).toEqual({
    dataDir: null,
    kind: "compatible",
    serverUrl,
  });
});

5. Root cause

The probe creates an abort timer for each request. See server-probe.ts lines 112–118.

const controller = new AbortController();
const timeout = setTimeout(() => {
  controller.abort();
}, args.timeoutMs);

The startup poll interval also sets the request limit. The production value is 250 ms. See types.ts lines 7–9.

export const STARTUP_POLL_INTERVAL_MS = 250;
export const STARTUP_TIMEOUT_MS = 60_000;

The code correctly treats a health network error as unavailable. It treats every config failure as incompatible. See server-probe.ts lines 161–208.

if (healthResult.kind === "network-error") {
  return { kind: "unavailable", ... };
}

if (configResult.kind !== "success") {
  return {
    kind: "incompatible",
    reason: `/api/v1/system/config returned ${formatFetchFailure(configResult)}`,
    ...
  };
}

The wait loop retries unavailable results. It returns the first incompatible result. See server-probe.ts lines 218–242.

if (lastResult.kind === "incompatible") {
  return lastResult;
}

The server listens before plugin startup begins. Slow startup work can overlap this probe. See start-server.ts lines 226–249.

The desktop app converts the result into the false incompatibility message. It then stops the server. See main.ts lines 1877–1886.

raceResult.result.kind === "incompatible"
  ? `Port ${args.serverUrl} is responding, but it does not look like bb: ...`
  : ...
await stopOwnedRuntime();

This final stop explains the reporter's later closed-port result. The port was open when /health succeeded.

6. Proposed fix (first principles)

Treat a config network error as unavailable. Keep HTTP and schema errors incompatible. This change lets the existing wait loop retry temporary aborts. It also preserves fast rejection for a different service on the port.

Use a request limit that does not depend on the poll interval. Add a test for one aborted config request followed by a valid response.

The narrow classification fix passed the new test. It also passed all six existing server-probe tests. This desktop-only change does not change the host-daemon protocol.

7. Related issues

8. Appendix

Verification results

Base commit:
Test Files  1 failed (1)
Tests       1 failed (1)

With the narrow classification fix:
Test Files  2 passed (2)
Tests       7 passed (7)

Important commands

gh issue view 2480 --comments
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build --filter=@bb/desktop
reports_checkout=/tmp/bb-reports
cp "$reports_checkout/issues/2480/repro/issue-2480-repro.test.ts" apps/desktop/test/issue-2480-repro.test.ts
pnpm exec vitest run test/issue-2480-repro.test.ts
pnpm exec vitest run test/server-probe.test.ts test/issue-2480-repro.test.ts
git fetch origin main
git log ad79bbb5ec90..origin/main -- apps/desktop/src/server-probe.ts apps/desktop/src/main.ts

The initial install failed because /tmp had no free inodes. The desktop build used the initial checkout dependency tree.

The desktop Turbo build completed 12 of 12 tasks.

origin/main still equals the base commit. No later fix exists there. No open pull request links to issue 2480.

This investigation did not run the AppImage or capture a screenshot. The deterministic test reproduces the probe result and abort reason.

Verification

The verifier first tested the unpublished download URL. It returned HTTP 404 and saved HTML, so the test did not start.

This revision added curl -fL and a local copy command. The local command copied the exact saved artifact and reproduced the failure.

The revised unpublished download command stopped with curl status 22. It did not save the 404 response as test source.

This revision also marks repeated restart success as unverified. The inline source now matches the saved test source.

copy_match=yes test_exit_status=1
unpublished_curl_exit_status=22
Test Files  1 failed (1)
Tests       1 failed (1)

With the proposed fix:
Test Files  2 passed (2)
Tests       7 passed (7)