← reports

#2993 · Supervisor does not stop after a server-port takeover

Bug High Effort: Low host cli open on GitHub 2026-09-03 · base 99c0ad7

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

ClaimStatusEvidence
The supervisor continues after a healthy server takes the URL.VerifiedThe focused test received timeout, not stopped.
The supervisor starts a replacement server.VerifiedThe focused test recorded one restart attempt before it blocked the next retry.
The original daemon stays active.VerifiedThe focused test recorded no daemon termination signal before cleanup.
Retries continue without a bound.VerifiedThe retry loop only stops after a shutdown request or a successful start.
A foreign healthy server makes a real replacement start fail.VerifiedThe existing health-identity suite passed its occupied-port case on the same commit.

3. Environment

4. Minimal reproduction

  1. 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
  2. Save the reproduction test as packages/bb-app/test/supervisor-port-takeover.test.ts.
  3. 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.