← reports

#4862 · Provider startup waits can outlive the Node event loop

BugHighEffort: Lowhost · providersIssue2026-10-04 · base 09ff18a73bd0a8037f43c6ed86c28eb02228089c

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

A server recovering a pending thread can reach a provider-registration wait before opening its HTTP listener. The provider registry offers a 30-second fallback, but its timer is detached from Node's process-liveness accounting. In two clean checkouts, the real registry's pending waits let a child process exit successfully with no output in roughly half a second rather than finish the fallback. A one-line fix keeps the fallback timer referenced; the same children then complete after approximately 30 seconds. This directly reproduces the process-liveness defect on trusted main; the reporter's particular macOS desktop bundle and persisted database were not used.

2. Claims vs findings

ClaimFindingEvidence
Recovery precedes listen; provider startup follows listen.Verified in sourceStartup ordering at lines 351, 363 and 390 of start-server.ts.
A recoverable starting thread can wait for provider settlement.Verified in sourceRecovery selects starting, nondeleted threads, advances provisioning, then constructs the start command after awaiting settlement.
The fallback permits a successful exit with the awaited work unfinished.Verified by executionBoth registry APIs exit 0 with empty stdout and stderr, reproduced in two clean checkouts. Fixed code prints the completion marker.
The specific desktop versions, crash-loop count, existing database integrity and workaround timing.UnverifiedNo access to the reporter's installation or database; no claim about those measurements is needed for the regression.

3. Environment

4. Minimal reproduction

  1. Clone the public target repository, fetch trusted main and check out the recorded commit:
    git clone https://github.com/get-bb/bb.git bb-repro
    cd bb-repro
    git fetch origin main
    git checkout --detach 09ff18a73bd0a8037f43c6ed86c28eb02228089c
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build --filter=@bb/server
  2. Save the regression test at apps/server/test/providers/provider-registry-liveness.test.ts and run:
    pnpm exec turbo run test --filter=@bb/server -- test/providers/provider-registry-liveness.test.ts
  3. Expected: child processes print wait completed before exiting. Actual on unchanged main: the children exit 0 without printing it; Vitest fails the completion assertion. The test uses real timers and the actual registry implementation, not fake timers or a mocked registry.
  4. For explicit child exit codes and timing, download reproduce.mjs outside the checkout and run node /path/to/reproduce.mjs /path/to/bb-repro. Observed output from the second clean checkout:
    {"wait":"whenRegistrationsSettled()","exitCode":0,"elapsedMs":469,"stdout":"","stderr":""}
    {"wait":"whenProviderRegistered(\"missing\")","exitCode":0,"elapsedMs":466,"stdout":"","stderr":""}
    

Regression test

import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { expect, it } from "vitest";

const execFileAsync = promisify(execFile);
const registryUrl = new URL(
  "../../src/services/providers/provider-registry.ts",
  import.meta.url,
).href;

it("finishes pending provider boot waits before an otherwise idle process exits", async () => {
  await Promise.all(
    ["whenRegistrationsSettled()", 'whenProviderRegistered("missing")'].map(
      async (wait) => {
        const { stdout, stderr } = await execFileAsync(
          process.execPath,
          [
            "--conditions=source",
            "--import",
            "tsx",
            "--input-type=module",
            "--eval",
            `
              import { createProviderRegistryService } from ${JSON.stringify(registryUrl)};
              const registry = createProviderRegistryService({ deferRegistrationsSettled: true });
              registry.${wait}.then(() => console.log("wait completed"));
            `,
          ],
          { timeout: 40_000 },
        );
        expect(stderr).toBe("");
        expect(stdout.trim()).toBe("wait completed");
      },
    ),
  );
}, 45_000);

5. Root cause

Registry construction defers registration settlement. The pre-listen recovery sweep is awaited before the server can acquire a listening socket. Plugin startup runs later, and its finally handler signals settlement.

Recovery enters the thread lifecycle sweep. Starting-thread selection advances provisioning for starting, nondeleted threads. A ready workspace reaches requestThreadStart, which reaches command construction. buildThreadStartCommand first awaits whenRegistrationsSettled().

The shared waiting helper races readiness, settlement and a 30-second timer, then calls timer.unref?.(). An unresolved promise alone does not keep Node running. Without another referenced handle, the fallback cannot fulfill its contract because Node exits before it fires. This also affects the provider-specific waiter, which uses the same helper.

timer = setTimeout(resolve, REGISTRATIONS_SETTLED_TIMEOUT_MS);
timer.unref?.();

The startup ordering also means an affected recovery can still consume the entire fallback interval before providers start. Keeping the timer referenced fixes the silent process exit without changing startup ordering or provider readiness policy.

6. Proposed fix

Remove the unref call from the shared registry wait helper. Keep its existing timeout duration, readiness races and timer cleanup. This is one existing server subsystem, with no protocol, schema, permission, packaging or stored-data change. The timer is cleared when readiness wins, and no new long-lived interval is introduced.

After the fix, both waiters complete. The provider registry and periodic sweep suites pass: 26 tests across three files. Server typechecking passes. The patch changes two files, with 36 added test lines and one deleted production line: 37 changed text lines.

pnpm exec turbo run test --filter=@bb/server -- test/providers/provider-registry-liveness.test.ts test/providers/provider-registry.test.ts test/services/periodic-sweeps.test.ts
pnpm exec turbo run typecheck --filter=@bb/server
git diff --check
git diff --numstat origin/main
{"wait":"whenProviderRegistered(\"missing\")","exitCode":0,"elapsedMs":30457,"stdout":"wait completed\n","stderr":""}
{"wait":"whenRegistrationsSettled()","exitCode":0,"elapsedMs":30467,"stdout":"wait completed\n","stderr":""}

7. Verification

The same investigator cloned a second clean repository into a separate temporary directory, fetched the target origin, checked out the exact recorded commit, installed the frozen lockfile and built the server. Only the authored reproduction test was copied into that checkout; production code remained unchanged. The same Turbo command failed with expected wait completed and received an empty string. An additional direct runner observed both clean children exit 0 after 466–469 ms with empty stdout and stderr. The fixed checkout passes the same regression after about 30.5 seconds. No independent investigator is claimed.

No report correction was necessary. Scope limitation: this is a direct execution reproduction of the registry lifecycle failure on Linux, with the application startup path verified in source. A complete bundled macOS boot against a crash-recovery database was not executed.

8. Related issues and PRs

GitHub cross-reference metadata contained no linked pull request, and an open-PR search for issue 4862 returned none before implementation. Repository searches did not reveal another tracked report of this precise silent-exit mechanism. Superficially related provider-startup issues do not establish this event-loop failure.

9. Appendix

Trust boundary: issue text and references were treated only as untrusted claims. No issue-supplied script, patch, binary, branch, command or external link was executed or fetched. The reproduction was authored from trusted origin/main source. No secrets or user runtime data were accessed.