#1558 · bb-app can enroll a host against the wrong server after its server child fails with EADDRINUSE
TL;DR
Plain-language framing. npx bb-app (the "launcher") starts two child processes: the bb server (HTTP API + SQLite database under BB_DATA_DIR) and the host daemon (the process that runs agents on this machine). On first start the daemon has no credentials, so the launcher asks the server for a one-time enroll key, the daemon exchanges it for a permanent host key, and the daemon writes that key to <BB_DATA_DIR>/auth.json. The server remembers the enrolled host in its own bb.db. From then on the daemon authenticates with that host key.
The launcher decides that its server child is "up" by polling GET http://127.0.0.1:<port>/health until it gets a 200. It never checks who answered. If some other bb server already owns the port (for example a second bb-app run with a different BB_DATA_DIR but default ports), the very first poll succeeds against that foreign server, long before the launcher's own child has finished booting and died with EADDRINUSE. The launcher prints ✓ Server listening, mints an enroll key from the foreign server, and its daemon enrolls there. The daemon then persists the resulting host key into the second data directory. Later, when the first bb is gone and the second one starts on its own database, the daemon presents a host key that only exists in the other database and is rejected with 401 Unauthorized (server log: Better Auth INVALID_API_KEY; daemon log: "Server rejected host credentials — this host is not registered with the server"). The second data dir is now permanently broken until auth.json is deleted.
Reproduced end to end on 16ceb3a54 with the built packages/bb-app/dist/bb-app.js (identical launcher code to 0.35.1 and 0.38.0), on non-default ports so the user's real instance was not touched. It is not a narrow race: the foreign 200 wins on the first 100 ms poll every time because a bb server takes seconds to boot. A one-file vitest repro at the exact function (waitForHealth) fails on main. In a variant where only the server port collides, the launcher even reports bb is ready and then loops ✓ Server restarted forever while its own server dies with EADDRINUSE each time.
Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| Second launcher (different data dir, same ports) can mistake the first launcher's server for its own | Verified | Repro step 2: launcher two prints ✓ Server listening on http://127.0.0.1:45886 while its own server child prints listen EADDRINUSE a moment later (launcher-two-first-run.log). |
| Launcher requests an enroll key from the existing server and saves host credentials in the second data dir | Verified | /tmp/bb1558-two/auth.json and host-id = host_jqdf8jbftp; sqlite3 /tmp/bb1558-one/bb.db "select id from hosts" lists it, /tmp/bb1558-two/bb.db has an empty hosts table. |
After the first launcher stops, the second one fails with "Server rejected host credentials — this host is not registered with the server" / INVALID_API_KEY | Verified | Repro step 3 (launcher-two-restart.log): both strings appear verbatim; INVALID_API_KEY comes from the Better Auth apikey plugin log inside the server, the other from logFatalConnectError in the daemon. |
Host ID from bb-two/auth.json present in bb-one/bb.db, absent from bb-two/bb.db | Verified | See above. |
Suspected cause: race in waitForHealth() between the child's exit and the unrelated server's 200 | Verified, and stronger than a race | launcher.ts#L2164-L2186: the loop checks exitCode then fetches. The first fetch happens ~0 ms after spawn; the child needs seconds to reach listen(). So the foreign 200 is accepted deterministically, not occasionally. Unit repro fails 1/1 runs. |
| "A 2xx only proves that a server is listening, not that it is the child just spawned" | Verified | /health returns a constant {"ok":true} (server.ts#L331). The launcher already does an identity check for the daemon (waitForHostDaemonStatus compares hostId and serverUrl, added by #1155) but not for the server. |
| Environment: bb-app 0.35.1 | Consistent | waitForHealth at tag desktop-v0.35.1 is byte-identical to 16ceb3a54. |
| Launcher "may still consider startup successful" | Verified | Variant with a distinct daemon port: launcher two prints ● bb is ready, then loops ! server exited with code 1 - restarting server / ✓ Server restarted (log). With the daemon port also colliding, the daemon fails its readiness check after ~60 s and the launcher exits 1 — but auth.json is already written. |
| Error-message suggestion (INVALID_API_KEY is misleading; unrecoverable auth failures should stop the restart loop) | Opinion, partially moot | The daemon already treats 401/403 as fatal (fatalConnectError, server-connection.ts#L402-L432) and exits with reason: startup-failed; the launcher then exits 1. The INVALID_API_KEY line is a Better Auth internal log, not a bb message. Adding the "remove auth.json" hint to the daemon's fatal message would be cheap. |
Environment
- bb
16ceb3a54(main, 2026-08-18). Worktree/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21(HEADa108fa7efon origin/main;git diff 16ceb3a54 HEAD -- packages/bb-app apps/host-daemon/src/start-host-daemon.ts apps/server/src/start-server.ts apps/server/src/server.tsis empty, so all excerpts and line numbers below are those of the base commit).git log 16ceb3a54..origin/main -- packages/bb-appis empty: not fixed on main. - Linux 7.0.0-29-generic x86_64, node v24.18.0, pnpm workspace built with
pnpm exec turbo run build. Launcher used:packages/bb-app/dist/bb-app.js(package version 0.38.0), which spawnspackages/bb-app/server/dist/index.jsandpackages/bb-app/host-daemon/dist/daemon-bundle.mjs: the same artifactsnpx bb-appships. - No dev instance (
scripts/bb-dev-app) was needed. Two throw-away launcher instances: data dirs/tmp/bb1558-oneand/tmp/bb1558-two, server port45886, daemon port45887(variant:45889). Ports were chosen to stay clear of the real instance on 38886/38887. No providers were invoked.
Minimal reproduction
Scripts are in 1558/repro/. They hardcode the launcher path of my worktree in BBAPP=; point it at your own packages/bb-app/dist/bb-app.js (or replace node "$BBAPP" with npx bb-app@0.38.0, which reproduces identically with default ports if nothing else is on 38886).
- Start instance one and wait for
bb is ready(start-one.sh; output launcher-one.log):$ bash 1558/repro/start-one.sh # BB_DATA_DIR=/tmp/bb1558-one BB_SERVER_PORT=45886 BB_HOST_DAEMON_PORT=45887 node dist/bb-app.js $ grep -A8 "bb is ready" /tmp/bb1558-one/launcher.log ● bb is ready app http://127.0.0.1:45886 daemon 45887 data /tmp/bb1558-one db /tmp/bb1558-one/bb.db - Start instance two with a different data dir and the same ports (start-two.sh). Expected (issue's "Expected behavior"): the launcher fails startup because its server child cannot bind the port, and does not create host authentication state. Actual (launcher-two-first-run.log, verbatim):
$ bash 1558/repro/start-two.sh # BB_DATA_DIR=/tmp/bb1558-two, same ports $ cat /tmp/bb1558-two/launcher.log bb ○ Starting server ✓ Server listening on http://127.0.0.1:45886 ○ Starting host daemon node:events:487 throw er; // Unhandled 'error' event ^ Error: listen EADDRINUSE: address already in use 127.0.0.1:45886 at Server.setupListenHandle [as _listen2] (node:net:2009:16) at listenInCluster (node:net:2066:12) at node:net:2275:7 at process.processTicksAndRejections (node:internal/process/task_queues:90:21) Emitted 'error' event on Server instance at: at emitErrorNT (node:net:2045:8) at process.processTicksAndRejections (node:internal/process/task_queues:90:21) { code: 'EADDRINUSE', errno: -98, syscall: 'listen', address: '127.0.0.1', port: 45886 } Node.js v24.18.0 Error: Host daemon local API port 45887 is already in use on 127.0.0.1. Choose another port with --host-daemon-port <port>. at Cqr (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:720:21269) at process.processTicksAndRejections (node:internal/process/task_queues:104:5) at async v$r (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:743:30124) at async Object.qUo (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:752:3705) at async QUo (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:753:62) ✗ Host daemon failed to start lock: /tmp/bb1558-two/daemon.lock.lock logs: /tmp/bb1558-two/logs/ [07:40:07] INFO: [server] Server listening {"bindHost":"127.0.0.1","port":45886,"dataDir":"/tmp/bb1558-two"} ● Shutting downNote the order:✓ Server listeningis printed first (answered by instance one), then the launcher moves on toStarting host daemon, and only afterwards does its own server child die withEADDRINUSE. The daemon then fails on its own port collision, but not before enrolling:$ ls /tmp/bb1558-two auth-secret auth.json bb-app-runtime.json bb.db bb.db-shm bb.db-wal daemon.lock host-id launcher.log logs skills telemetry-id thread-storage $ cat /tmp/bb1558-two/auth.json { "hostId": "host_jqdf8jbftp", "hostKey": "bbdh_<redacted>", "hostType": "persistent" } $ sqlite3 /tmp/bb1558-one/bb.db "select id, name from hosts" host_yej96dezaf|bee host_jqdf8jbftp|bee <-- instance two's host, enrolled in instance ONE's database $ sqlite3 /tmp/bb1558-two/bb.db "select id, name from hosts" <-- empty - Stop instance one, start instance two again on its (now free) ports, keeping its data dir (restart-two.sh). Expected: instance two starts. Actual (launcher-two-restart.log):
$ kill <launcher one pid>; bash 1558/repro/restart-two.sh $ cat /tmp/bb1558-two/launcher-restart.log bb ○ Starting server ✓ Server listening on http://127.0.0.1:45886 ○ Starting host daemon 2026-08-18T07:41:47.238Z ERROR [Better Auth]: Failed to validate API key: [APIError6: Invalid API key.] { status: 'UNAUTHORIZED', body: { message: 'Invalid API key.', code: 'INVALID_API_KEY' }, headers: {}, statusCode: 401 } ServerResponseError: Failed to open session: 401 Unauthorized - Unauthorized at i (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:701:35325) at process.processTicksAndRejections (node:internal/process/task_queues:104:5) at async Object.openSession (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:701:36219) at async wle.openSession (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:742:8095) at async n.createWebSocket.minReconnectionDelay (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:742:9464) at async Promise.all (index 0) ✗ Host daemon failed to start lock: /tmp/bb1558-two/daemon.lock.lock logs: /tmp/bb1558-two/logs/ [07:41:46] INFO: [server] Server listening {"bindHost":"127.0.0.1","port":45886,"dataDir":"/tmp/bb1558-two"} [07:41:46] INFO: [server] plugin automations@0.1.0 loaded [07:41:46] INFO: [server] plugin connect@0.1.0 loaded [07:41:46] INFO: [server] plugin custom-instructions@0.1.0 loaded [07:41:46] INFO: [server] plugin inline-vis@0.1.0 loaded [07:41:46] INFO: [server] plugin keep-awake@0.1.0 loaded [07:41:46] INFO: [server] plugin provider-acp@0.1.0 loaded [07:41:46] INFO: [server] plugin provider-claude-code@0.1.0 loaded [07:41:46] INFO: [server] plugin provider-codex@0.1.0 loaded [07:41:46] INFO: [server] plugin provider-pi@0.1.0 loaded [07:41:46] INFO: [server] plugin secrets@0.1.0 loaded [07:41:46] INFO: [server] plugin side-chat@0.1.0 loaded [07:41:47] INFO: [host-daemon] Host daemon connecting {"serverUrl":"http://127.0.0.1:45886","dataDir":"/tmp/bb1558-two"} [07:41:47] ERROR: [host-daemon] Server rejected host credentials — this host is not registered with the server. {"serverUrl":"http://127.0.0.1:45886","hostId":"host_jqdf8jbftp","status":401,"code":"unauthorized","detail":"Unauthorized"} [07:41:47] INFO: [host-daemon] Shutting down host daemon {"serverUrl":"http://127.0.0.1:45886","mode":"shutdown","reason":"startup-failed"} ● Shutting downInstance two is now unusable untilauth.json(andhost-id) are removed, exactly as the issue's workaround says.
Variant: only the server port collides → "bb is ready" and an endless restart loop
start-two-daemon-port-differs.sh uses BB_HOST_DAEMON_PORT=45889 for instance two. Its daemon now binds fine, enrolls with instance one, connects to instance one, and passes the launcher's daemon identity check (the expected host id came from instance one's enroll-key response). The launcher declares victory and then restarts its doomed server child every ~2 s, each time "successfully" (full log):
bb
○ Starting server
✓ Server listening on http://127.0.0.1:45886
○ Starting host daemon
node:events:487
throw er; // Unhandled 'error' event
^
Error: listen EADDRINUSE: address already in use 127.0.0.1:45886
at Server.setupListenHandle [as _listen2] (node:net:2009:16)
at listenInCluster (node:net:2066:12)
at node:net:2275:7
at process.processTicksAndRejections (node:internal/process/task_queues:90:21)
Emitted 'error' event on Server instance at:
at emitErrorNT (node:net:2045:8)
at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
code: 'EADDRINUSE',
errno: -98,
syscall: 'listen',
address: '127.0.0.1',
port: 45886
}
Node.js v24.18.0
✓ Host daemon running
● bb is ready
app http://127.0.0.1:45886
daemon 45889
data /tmp/bb1558-two
db /tmp/bb1558-two/bb.db
logs /tmp/bb1558-two/logs/
lock /tmp/bb1558-two/daemon.lock
Press Ctrl+C to stop
[07:44:11] INFO: [server] Server listening {"bindHost":"127.0.0.1","port":45886,"dataDir":"/tmp/bb1558-two"}
! server exited with code 1 - restarting server
[07:44:11] INFO: [host-daemon] Host daemon connecting {"serverUrl":"http://127.0.0.1:45886","dataDir":"/tmp/bb1558-two"}
[07:44:11] INFO: [host-daemon] Connected to server {"serverUrl":"http://127.0.0.1:45886","sessionId":"hses_3yj4izin5i"}
[07:44:11] INFO: [host-daemon] Host daemon started {"serverUrl":"http://127.0.0.1:45886","identity":{"hostId":"host_2t69e294wf","hostName":"bee","instanceId":"457dd088-30ee-45de-bf1d-a05653bdd965"}}
[07:44:11] INFO: [host-daemon] Host plugin worker ready {"serverUrl":"http://127.0.0.1:45886","pluginId":"keep-awake","startupDurationMs":41}
○ Restarting server
✓ Server restarted
[07:44:13] INFO: [server] Server listening {"bindHost":"127.0.0.1","port":45886,"dataDir":"/tmp/bb1558-two"}
node:events:487
throw er; // Unhandled 'error' event
^
Error: listen EADDRINUSE: address already in use 127.0.0.1:45886
at Server.setupListenHandle [as _listen2] (node:net:2009:16)
at listenInCluster (node:net:2066:12)
at node:net:2275:7
at process.processTicksAndRejections (node:internal/process/task_queues:90:21)
Emitted 'error' event on Server instance at:
at emitErrorNT (node:net:2045:8)
at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
code: 'EADDRINUSE',
errno: -98,
syscall: 'listen',
address: '127.0.0.1',
port: 45886
}
Node.js v24.18.0
…(repeats until Ctrl+C)
Unit-level repro at the exact code path
File: 1558/repro/issue-1558-foreign-health.test.ts (copy to packages/bb-app/test/). waitForHealth is module-private, so the test needs this one-line change to src/launcher.ts (export-waitForHealth.diff):
diff --git a/packages/bb-app/src/launcher.ts b/packages/bb-app/src/launcher.ts
index bfd962194..d4508fab3 100644
--- a/packages/bb-app/src/launcher.ts
+++ b/packages/bb-app/src/launcher.ts
@@ -2161,7 +2161,7 @@ export async function maybeAddAutoJoinEnv(
};
}
-async function waitForHealth(args: WaitForHealthArgs): Promise<void> {
+export async function waitForHealth(args: WaitForHealthArgs): Promise<void> {
const timeoutMs = args.timeoutMs ?? HEALTH_CHECK_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
The test starts a plain node:http server that answers /health with 200 (instance one), spawns a child that exits with code 1 after 400 ms (instance two's server child dying on EADDRINUSE), and asks waitForHealth whether that child became healthy. On main it resolves ("healthy") on the first poll; the final assertion fails:
// Repro for get-bb/bb#1558: waitForHealth() accepts a 200 from an UNRELATED
// server on the configured port and reports the managed server child healthy,
// even though that child never bound the port (it exits shortly after with
// EADDRINUSE). On main this test FAILS at the final assertion:
// waitForHealth resolves on the very first poll because the foreign server
// answers before the child has even finished booting.
//
// Requires `waitForHealth` to be exported from src/launcher.ts (a one-line
// `export` was added for this repro; see the report).
import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { describe, expect, it } from "vitest";
import { waitForHealth } from "../src/launcher.js";
async function listen(handler: Parameters<typeof createServer>[1]) {
const server = createServer(handler);
await new Promise<void>((resolvePromise, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolvePromise);
});
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("expected TCP address");
}
return {
port: address.port,
close: () =>
new Promise<void>((resolvePromise, reject) => {
server.close((error) => (error ? reject(error) : resolvePromise()));
}),
};
}
describe("issue #1558", () => {
it("does not treat an unrelated server's /health as the managed child becoming healthy", async () => {
// "Instance one": some other bb server already owning the port.
let healthHits = 0;
const foreign = await listen((request, response) => {
if (request.url === "/health") healthHits += 1;
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true }));
});
// "Instance two's server child": boots for a moment, then dies the way
// apps/server does on EADDRINUSE (non-zero exit, never listens).
const child = spawn(
process.execPath,
["-e", "setTimeout(() => process.exit(1), 400)"],
{ stdio: "ignore" },
);
try {
const outcome = await waitForHealth({
childProcess: child,
url: `http://127.0.0.1:${foreign.port}/health`,
timeoutMs: 5_000,
}).then(
() => "healthy" as const,
(error: unknown) =>
error instanceof Error ? error.message : String(error),
);
// Wait for the child to actually exit so the assertion below is about
// what waitForHealth decided, not about timing.
await new Promise<void>((resolvePromise) => {
if (child.exitCode !== null) return resolvePromise();
child.once("exit", () => resolvePromise());
});
expect(child.exitCode).toBe(1);
expect(healthHits).toBeGreaterThan(0);
// BUG (main): outcome === "healthy" — the foreign 200 satisfied the check
// before the doomed child even exited.
expect(outcome).not.toBe("healthy");
} finally {
if (child.exitCode === null) child.kill("SIGKILL");
await foreign.close();
}
});
});
$ cd packages/bb-app && pnpm exec vitest run test/issue-1558-foreign-health.test.ts
RUN v4.1.1 /home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app
❯ test/issue-1558-foreign-health.test.ts (1 test | 1 failed) 430ms
× does not treat an unrelated server's /health as the managed child becoming healthy 429ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL test/issue-1558-foreign-health.test.ts > issue #1558 > does not treat an unrelated server's /health as the managed child becoming healthy
AssertionError: expected 'healthy' not to be 'healthy' // Object.is equality
❯ test/issue-1558-foreign-health.test.ts:74:27
72| // BUG (main): outcome === "healthy" — the foreign 200 satisfied…
73| // before the doomed child even exited.
74| expect(outcome).not.toBe("healthy");
| ^
75| } finally {
76| if (child.exitCode === null) child.kill("SIGKILL");
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed (1)
Start at 07:43:08
Duration 931ms (transform 244ms, setup 0ms, import 415ms, tests 430ms, environment 0ms)
(raw output.) The assertion that fails is expect(outcome).not.toBe("healthy"): waitForHealth returned success for a child that never listened and exited 1.
Root cause
1. The server readiness probe has no identity check. launcher.ts#L2164-L2186:
async function waitForHealth(args: WaitForHealthArgs): Promise<void> {
const timeoutMs = args.timeoutMs ?? HEALTH_CHECK_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
if (args.childProcess && (args.childProcess.exitCode !== null || args.childProcess.signalCode !== null)) {
throw new Error("Process exited before becoming healthy");
}
try {
const response = await fetch(args.url);
if (response.ok) {
return; // <-- any 200 from anyone on this port
}
} catch {}
await new Promise<void>((resolvePromise) => { setTimeout(resolvePromise, HEALTH_CHECK_INTERVAL_MS); });
}
throw new Error(`Timed out waiting for health at ${args.url}`);
}
It is called from startFullStackServerProcess immediately after spawn (launcher.ts#L2898-L2927) with url = <serverUrl>/health, where /health is a constant { ok: true } (server.ts#L331). Because the child needs seconds to initDb, migrate, and load plugins before it reaches listen(), and the poll fires at t≈0 and every 100 ms, a foreign server on the port always wins. The child-exit guard only helps if the child dies before the first successful fetch, which it never does here. The issue's "there appears to be a race" is therefore too generous: it is the common case, not a window.
2. Success of that probe gates enrollment against the same URL. Right after ✓ Server listening, runBbApp calls maybeAddAutoJoinEnv (launcher.ts#L3349-L3354), which — when <dataDir>/auth.json is absent — POSTs /internal/hosts/enroll-key to context.serverUrl (launcher.ts#L2130-L2162, #L2105-L2128). That route is unauthenticated for loopback callers by design (internal/hosts.ts#L54-L80), so instance one happily mints a key and a fresh hostId in its database. The launcher passes them to the daemon as BB_HOST_ENROLL_KEY/BB_HOST_ID.
3. The daemon persists credentials before it binds anything and before the launcher's daemon identity check runs. start-host-daemon.ts#L159-L186: enrollDaemonHost(...) against serverUrl (instance one), then persistHostId + writeHostAuthState into its own dataDir (instance two). Only afterwards does it resolve the local API port (#L190-L198) and possibly fail with "port already in use". So even when the launcher ultimately exits 1 (both ports colliding), the poison auth.json is already on disk. The launcher's daemon check (waitForHostDaemonStatus, which verifies hostId/serverUrl) cannot catch this: in the variant it passes legitimately because the daemon really did connect, to the wrong server, with the host id the launcher was told to expect.
4. Why the later 401. Instance two's bb.db never saw the enrollment. On the next start its own server validates the daemon's bbdh_… key with the Better Auth apikey plugin, which logs INVALID_API_KEY and yields 401 unauthorized from /internal/hosts/session; the daemon treats 401 as non-retryable (server-connection.ts#L402-L432), logs "Server rejected host credentials — this host is not registered with the server", and shuts down with reason: startup-failed; the launcher prints ✗ Host daemon failed to start and exits 1. Every subsequent start does the same, because maybeAddAutoJoinEnv sees auth.json and skips re-enrollment.
Contributing/adjacent defects observed while reproducing. (a) apps/server logs Server listening before the socket is actually bound: startHttpListener returns synchronously from @hono/node-server's serve() and the log follows immediately (start-server.ts#L219-L232); the EADDRINUSE arrives later as an unhandled 'error' event and crashes the process with a raw Node stack, no bb-authored diagnostic (compare the daemon's "Host daemon local API port … already in use" message from #1155). In the logs above you can see INFO: [server] Server listening {… dataDir: /tmp/bb1558-two} for a server that never listened. (b) The launcher's supervision loop restarts the server unconditionally and re-runs the same identity-free probe, so a permanently un-bindable server child produces an infinite ✓ Server restarted loop instead of a fatal error. (c) The pre-flight warnings in runBbApp (warnExistingDaemonLock, warnExistingRuntimeRecord) are all keyed on the data dir, so a port collision across data dirs is invisible to them.
Proposed fix (first principles)
- Make the server probe prove identity, not liveness. The launcher owns the server child, so it can hand it a per-launch secret: generate
BB_SERVER_LAUNCH_ID = randomUUID()instartFullStackServerProcess, pass it in the child env, and have the server expose it — either as a field on/health({ ok: true, launchId }, only when the env var is set) or on a small loopback-only/internal/launch-identity.waitForHealth(server flavour) then accepts only a 200 whoselaunchIdmatches, exactly likewaitForHostDaemonStatusalready requires the expectedhostId/serverUrl. A foreign server returns no/other id → the loop keeps polling → the child exits with EADDRINUSE →"Process exited before becoming healthy"→ launcher prints✗ Server failed to startand, crucially, never reachesmaybeAddAutoJoinEnv. This is launcher↔server, not server↔daemon, so noHOST_DAEMON_PROTOCOL_VERSIONbump is needed; the launcher and server always ship together in the same package. Risk: the desktop app or other tooling that reads/healthmust tolerate the extra field (additive, fine); if using a dedicated route, gate it withassertLoopbackRequest. Also fix the misleading server log by usingserve(..., listeningListener)(or the'listening'event) and add an'error'handler that turns EADDRINUSE into a bb-authored message and exit code. - Cheap belt-and-braces: before spawning the server, probe
/healthonce; if it already answers, refuse to start with "port <n> is already served by another bb server (data dir? see itsbb-app-runtime.json); useBB_SERVER_PORT/--port". This is not sufficient alone (a race with a concurrently booting instance one remains), which is why 1 is the real fix. - Give the doomed restart loop an exit: in
superviseFullStackProcesses, stop restarting the server after N consecutive restarts that ended in exit-before-listen (or once the identity probe never matched), and exit 1 with the log-dir hint. Independent of 1 this turns the infinite✓ Server restartedloop into a visible failure. - Recovery hint (optional): extend the daemon's fatal 401/403 message to say the persisted
auth.jsonin<dataDir>is not known to<serverUrl>and can be removed to re-enroll. Do not auto-delete: a wrongBB_SERVER_URLpointing at a legitimate remote server would then silently re-enroll a duplicate host.
Test to add with fix 1: the vitest above (asserting rejection), plus a positive one where a fake server answers /health with the expected launchId. Test for 3: drive superviseFullStackProcesses with a startServer stub whose child exits immediately and assert it gives up.
PR review
No open PRs are linked to this issue.
Related issues
- #1121 (closed by #1155): enrolled host daemon had no collision handling for the default daemon port. #1155 added the
/statusidentity check for the daemon; this issue is the missing counterpart for the server probe. - #1524: desktop startup against a server target that never answers has no deadline/recovery screen (adjacent: launcher/desktop trust of a port).
Appendix
Commands run
# worktree /home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build # 14 tasks, 13 cached git fetch origin main; git log 16ceb3a54..origin/main --oneline -- packages/bb-app apps/host-daemon/src/start-host-daemon.ts apps/server/src/start-server.ts # empty git show desktop-v0.35.1:packages/bb-app/src/launcher.ts | grep -n "async function waitForHealth" -A 22 # identical to base bash 1558/repro/start-one.sh # instance one, /tmp/bb1558-one, ports 45886/45887 bash 1558/repro/start-two.sh # instance two, /tmp/bb1558-two, same ports cat /tmp/bb1558-two/auth.json; cat /tmp/bb1558-two/host-id sqlite3 /tmp/bb1558-one/bb.db "select id,name from hosts"; sqlite3 /tmp/bb1558-two/bb.db "select id,name from hosts" kill <launcher one pid>; bash 1558/repro/restart-two.sh # instance two alone -> 401 / INVALID_API_KEY bash 1558/repro/start-one.sh; bash 1558/repro/start-two-daemon-port-differs.sh; bash 1558/repro/wait-restarts.sh <pid> # variant: "bb is ready" + restart loop kill <both launcher pids>; ss -ltn | grep -E "4588[6-9]" # nothing left listening # unit repro sed -i 's/^async function waitForHealth(/export async function waitForHealth(/' packages/bb-app/src/launcher.ts cp 1558/repro/issue-1558-foreign-health.test.ts packages/bb-app/test/ cd packages/bb-app && pnpm exec vitest run test/issue-1558-foreign-health.test.ts # 1 failed (expected on main)
Repro scripts
# start-one.sh #!/usr/bin/env bash # Instance ONE: data dir /tmp/bb1558-one, ports 45886 (server) / 45887 (daemon) set -u BBAPP=/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/dist/bb-app.js rm -rf /tmp/bb1558-one; mkdir -p /tmp/bb1558-one cd /tmp BB_DATA_DIR=/tmp/bb1558-one BB_SERVER_PORT=45886 BB_HOST_DAEMON_PORT=45887 \ nohup node "$BBAPP" > /tmp/bb1558-one/launcher.log 2>&1 & echo "one pid=$!" # start-two.sh #!/usr/bin/env bash # Instance TWO: DIFFERENT data dir /tmp/bb1558-two, SAME ports 45886 / 45887 set -u BBAPP=/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/dist/bb-app.js rm -rf /tmp/bb1558-two; mkdir -p /tmp/bb1558-two cd /tmp BB_DATA_DIR=/tmp/bb1558-two BB_SERVER_PORT=45886 BB_HOST_DAEMON_PORT=45887 \ nohup node "$BBAPP" > /tmp/bb1558-two/launcher.log 2>&1 & echo "two pid=$!" # restart-two.sh #!/usr/bin/env bash # Instance TWO again, keeping its data dir (and the mis-enrolled auth.json) set -u BBAPP=/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/dist/bb-app.js cd /tmp BB_DATA_DIR=/tmp/bb1558-two BB_SERVER_PORT=45886 BB_HOST_DAEMON_PORT=45887 \ nohup node "$BBAPP" > /tmp/bb1558-two/launcher-restart.log 2>&1 & echo "two pid=$!"
Instance one launcher log (for reference)
bb
○ Starting server
✓ Server listening on http://127.0.0.1:45886
○ Starting host daemon
✓ Host daemon running
● bb is ready
app http://127.0.0.1:45886
daemon 45887
data /tmp/bb1558-one
db /tmp/bb1558-one/bb.db
logs /tmp/bb1558-one/logs/
lock /tmp/bb1558-one/daemon.lock
Press Ctrl+C to stop
[07:39:29] INFO: [server] Server listening {"bindHost":"127.0.0.1","port":45886,"dataDir":"/tmp/bb1558-one"}
[07:39:31] INFO: [host-daemon] Host daemon connecting {"serverUrl":"http://127.0.0.1:45886","dataDir":"/tmp/bb1558-one"}
[07:39:31] INFO: [server] plugin automations@0.1.0 loaded
[07:39:32] INFO: [server] plugin connect@0.1.0 loaded
[07:39:32] INFO: [server] Session opened {"sessionId":"hses_72p5ybgsvf","hostId":"host_yej96dezaf","replacedSessionId":null}
[07:39:33] INFO: [server] plugin custom-instructions@0.1.0 loaded
[07:39:33] INFO: [server] Daemon WebSocket opened {"sessionId":"hses_72p5ybgsvf","hostId":"host_yej96dezaf"}
[07:39:33] INFO: [host-daemon] Connected to server {"serverUrl":"http://127.0.0.1:45886","sessionId":"hses_72p5ybgsvf"}
[07:39:33] INFO: [host-daemon] Host daemon started {"serverUrl":"http://127.0.0.1:45886","identity":{"hostId":"host_yej96dezaf","hostName":"bee","instanceId":"b2c0f361-dc4b-404b-a18d-a851826155f6"}}
[07:39:34] INFO: [server] plugin inline-vis@0.1.0 loaded
[07:39:35] INFO: [server] plugin keep-awake@0.1.0 loaded
[07:39:35] WARN: [server] marketplace bb-community entry "cascade" icon https://getbb.app/marketplace/v1/icons/cascade-7b651515.svg was rejected:
[07:39:35] WARN: [server] marketplace bb-community entry "ntfy" icon https://getbb.app/marketplace/v1/icons/ntfy-2ef77317.svg was rejected:
[07:39:35] WARN: [server] marketplace bb-community entry "slopcop" icon https://getbb.app/marketplace/v1/icons/slopcop-35963518.svg was rejected:
[07:39:35] WARN: [server] marketplace bb-community entry "sticky-notes" icon https://getbb.app/marketplace/v1/icons/sticky-notes-5dc1948a.svg was rejected:
[07:39:35] INFO: [server] Event loop stalled {"intervalMs":5000,"maxDelayMs":1461.7,"meanDelayMs":476.3,"p99DelayMs":1461.7,"resolutionMs":20,"thresholdMs":500,"currentWork":null,"lastWork":"GET /internal/ws","lastWorkMs":2.1,"slowestWork":"POST /internal/hosts/enroll-key","slowestWorkMs":32.5}
[07:39:35] INFO: [server] plugin provider-acp@0.1.0 loaded
[07:39:35] INFO: [server] plugin provider-claude-code@0.1.0 loaded
[07:39:35] INFO: [server] plugin provider-codex@0.1.0 loaded
[07:39:35] INFO: [server] plugin provider-pi@0.1.0 loaded
[07:39:35] INFO: [server] plugin secrets@0.1.0 loaded
[07:39:35] INFO: [host-daemon] Host plugin worker ready {"serverUrl":"http://127.0.0.1:45886","pluginId":"keep-awake","startupDurationMs":47}
[07:39:35] INFO: [server] plugin side-chat@0.1.0 loaded
[07:39:40] INFO: [server] Event loop stalled {"intervalMs":5000,"maxDelayMs":644.3,"meanDelayMs":23.1,"p99DelayMs":24.3,"resolutionMs":20,"thresholdMs":500,"currentWork":null,"lastWork":"sweep:database-maintenance","lastWorkMs":0.3,"slowestWork":"GET /internal/plugins/keep-awake/host/75222f025051f4b98f6adc862e86f65033d3564769383e59ff1e1b694d039b34","slowestWorkMs":4.1}