#2480 · “Could not start bb” most of the time
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 issue | Status | Evidence |
|---|---|---|
| The app shows the quoted config abort. | Verified | The test produced the same reason text after one delayed config response. |
| Port 38886 was not open when the error occurred. | Refuted | The message requires a successful /health request. The desktop app then stops its server before a later port check. |
| The failure is a timing issue. | Verified | A 75 ms config delay reproduced the error with a 20 ms request limit. A second response would have succeeded. |
| Repeated restarts sometimes succeed. | Unverified | The timing mechanism makes this outcome possible. This investigation did not run repeated AppImage restarts. |
| The supplied host-daemon failure log explains this attempt. | Unverified | Its timestamps precede the supplied successful server log by about 19 hours. |
| The failure occurs most of the time on Linux Mint. | Unverified | This investigation did not use the reporter's machine or AppImage. |
3. Environment
- Base commit:
ad79bbb5ec909524f8f281e62d860c588a86f332. - Package version at the base commit:
bb-app 0.40.0and desktop0.40.0. - Reporter environment: bb 0.48 desktop, Linux Mint 22.2 Cinnamon.
- Test environment: Linux 7.0.0-29-generic x86_64, Node 24.18.0, pnpm 9.15.0, Turbo 2.8.3.
- The test used an operating-system-selected loopback port. It used no bb data directory and no provider.
- No dev instance ran. The test did not touch ports 38886 or 38887.
4. Minimal reproduction
- 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
- 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
-foption 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
- 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
- #2091 reports healthy startup failures during long event-loop stalls. It identifies the same short request-limit risk.
- #1119 reports the same desktop message. Its fix improved host-daemon error details, not this probe classification.
- #1186 has the same title only. A database migration failure caused that case.
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)