#2993 · Supervisor does not stop after a server-port takeover
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
A managed server can exit while another healthy bb server answers at the configured URL. The supervisor does not check that URL before it starts a replacement. The start fails, and the retry helper has no limit. The original host daemon stays active while the supervisor repeats the attempt. Two clean checkouts produced the same focused test failure.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| The supervisor continues after a healthy server takes the URL. | Verified | The focused test received timeout, not stopped. |
| The supervisor starts a replacement server. | Verified | The focused test recorded one restart attempt before it blocked the next retry. |
| The original daemon stays active. | Verified | The focused test recorded no daemon termination signal before cleanup. |
| Retries continue without a bound. | Verified | The retry loop only stops after a shutdown request or a successful start. |
| A foreign healthy server makes a real replacement start fail. | Verified | The existing health-identity suite passed its occupied-port case on the same commit. |
3. Environment
- Trusted repository:
get-bb/bb. - Commit:
99c0ad71841ff6ff2d42b3f7864b6dba0b0f7337. - System: macOS Darwin 25.6.0, arm64.
- Node.js:
v22.22.3. Vitest:4.1.1. - The test used a new kernel-assigned loopback port in each run.
- The test did not use a bb data directory or the user's bb instance.
4. Minimal reproduction
- Clone the trusted repository and select the tested commit.
git clone git@github.com:get-bb/bb.git bb-2993-repro cd bb-2993-repro git checkout --detach 99c0ad71841ff6ff2d42b3f7864b6dba0b0f7337
- Save the reproduction test as
packages/bb-app/test/supervisor-port-takeover.test.ts. - Install the existing dependencies and run the focused test.
pnpm install --frozen-lockfile --prefer-offline cd packages/bb-app pnpm exec vitest run test/supervisor-port-takeover.test.ts
Expected: The supervisor returns stopped, makes no restart attempt, and sends SIGTERM to its daemon.
Actual:
server exited with code 1 - restarting server Restarting server Server failed to restart AssertionError: expected 'timeout' to be 'stopped' Expected: "stopped" Received: "timeout" Test Files 1 failed (1) Tests 1 failed (1)
Reproduction test
import {
createServer,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import {
superviseFullStackProcesses,
terminateManagedFullStackProcesses,
} from "../src/launcher.js";
import type {
BbAppStartContext,
DelayMillisecondsArgs,
FullStackSupervisionResult,
ManagedFullStackProcesses,
ManagedProcessName,
ManagedProcessRun,
NamedProcessExitResult,
ProcessExitResult,
} from "../src/launcher.js";
type ResolveExit = (result: NamedProcessExitResult) => void;
class FakeManagedProcessRun implements ManagedProcessRun {
readonly exit: Promise<NamedProcessExitResult>;
readonly terminationSignals: NodeJS.Signals[] = [];
private resolveExit: ResolveExit = () => undefined;
constructor(readonly processName: ManagedProcessName) {
this.exit = new Promise<NamedProcessExitResult>((resolvePromise) => {
this.resolveExit = resolvePromise;
});
}
exitWith(result: ProcessExitResult): void {
this.resolveExit({ processName: this.processName, result });
}
async terminate(signal: NodeJS.Signals): Promise<void> {
this.terminationSignals.push(signal);
this.exitWith({ code: null, signal });
}
}
function delay(ms: number): Promise<"timeout"> {
return new Promise((resolvePromise) => {
setTimeout(() => resolvePromise("timeout"), ms);
});
}
describe("full-stack supervisor port takeover", () => {
const servers: ReturnType<typeof createServer>[] = [];
afterEach(async () => {
for (const server of servers.splice(0)) {
await new Promise<void>((resolvePromise, reject) => {
server.close((error) => (error ? reject(error) : resolvePromise()));
});
}
});
it("stops its daemon when another healthy server owns the URL", async () => {
const server = createServer(
(_request: IncomingMessage, response: ServerResponse) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true }));
},
);
servers.push(server);
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 the test server to have a TCP address");
}
const serverRun = new FakeManagedProcessRun("server");
const daemonRun = new FakeManagedProcessRun("daemon");
const processes: ManagedFullStackProcesses = { daemonRun, serverRun };
let shutdownRequested = false;
let restartAttempts = 0;
let delayCalls = 0;
let releaseRetry = (): void => undefined;
const context: BbAppStartContext = {
appDistDir: "/tmp/bb-app-test/app/dist",
appVersion: "0.0.0-test",
configFile: "/tmp/bb-app-test/config.json",
daemonBundleDir: "/tmp/bb-app-test/host-daemon/dist",
daemonEntry: "/tmp/bb-app-test/host-daemon/dist/daemon-bundle.mjs",
daemonLockDir: "/tmp/bb-app-test/daemon.lock.lock",
daemonLockFile: "/tmp/bb-app-test/daemon.lock",
daemonPort: address.port + 1,
dataDir: "/tmp/bb-app-test",
dbPath: "/tmp/bb-app-test/bb.db",
envFile: "/tmp/bb-app-test/env.json",
logDir: "/tmp/bb-app-test/logs",
packageRoot: "/tmp/bb-app-test/package",
serverEntry: "/tmp/bb-app-test/server/dist/index.js",
serverPort: address.port,
serverUrl: `http://127.0.0.1:${String(address.port)}`,
};
const supervision = superviseFullStackProcesses({
context,
delayMilliseconds: async (_args: DelayMillisecondsArgs) => {
delayCalls += 1;
if (delayCalls === 1) return;
await new Promise<void>((resolvePromise) => {
releaseRetry = resolvePromise;
});
},
isShutdownRequested: () => shutdownRequested,
processes,
startDaemon: async () => daemonRun,
startServer: async () => {
restartAttempts += 1;
throw new Error("The server port is occupied");
},
});
serverRun.exitWith({ code: 1, signal: null });
const outcome = await Promise.race<
FullStackSupervisionResult | "timeout"
>([supervision, delay(250)]);
const observedRestartAttempts = restartAttempts;
const observedDaemonSignals = [...daemonRun.terminationSignals];
shutdownRequested = true;
releaseRetry();
await terminateManagedFullStackProcesses({ processes, signal: "SIGTERM" });
await supervision;
expect(outcome).toBe("stopped");
expect(observedRestartAttempts).toBe(0);
expect(observedDaemonSignals).toEqual(["SIGTERM"]);
});
});
5. Root cause
The restart helper loops while shutdown is false. A failed start only writes status, waits one second, and starts again.
while (!args.isShutdownRequested()) {
try {
const processRun = await args.start();
return processRun;
} catch {
await args.delayMilliseconds({ ms: MANAGED_PROCESS_RESTART_RETRY_DELAY_MS });
}
}
The server-exit branch clears the old server, waits, and always enters that helper. It does not probe the configured health URL. The remaining daemon therefore has no stop path.
The launcher already detects a foreign responder during child startup. The health wait records a foreign answer and rejects after the managed child exits. The existing occupied-port test verifies this rejection. The supervisor discards that useful failure context and retries without a limit.
6. Proposed fix
After the managed server exits, request the configured /health endpoint once. Accept only an HTTP success with the existing health schema and ok: true. If a healthy server answers, send SIGTERM to the remaining daemon and return stopped. Keep the current retry behavior when the probe fails or returns an invalid response.
7. Verification
The same agent ran the reproduction in two separate clean clones at the exact base commit. Each clone used a new loopback port. Both runs failed at the same assertion with Expected: "stopped" and Received: "timeout". The existing six-test server-health identity suite also passed. No report correction was necessary.
8. Related work
GitHub metadata showed no linked issue and no open pull request that closes this issue. Commit history showed no later change to the relevant launcher path on trusted origin/main.
9. Appendix
Commands
git fetch origin main git rev-parse origin/main pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec vitest run test/supervisor-port-takeover.test.ts pnpm exec vitest run test/server-health-identity.test.ts git log 99c0ad71841ff6ff2d42b3f7864b6dba0b0f7337..origin/main --oneline -- packages/bb-app/src/launcher.ts
Trusted-data boundary
The issue included suggested implementation and test directions. This investigation treated them as untrusted data. It did not execute or copy issue code, scripts, patches, branches, or external links.