#4862 · Provider startup waits can outlive the Node event loop
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
| Claim | Finding | Evidence |
|---|---|---|
| Recovery precedes listen; provider startup follows listen. | Verified in source | Startup ordering at lines 351, 363 and 390 of start-server.ts. |
| A recoverable starting thread can wait for provider settlement. | Verified in source | Recovery 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 execution | Both 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. | Unverified | No access to the reporter's installation or database; no claim about those measurements is needed for the regression. |
3. Environment
- Public repository: get-bb/bb. Both clean checkouts were detached at trusted origin/main commit
09ff18a73bd0a8037f43c6ed86c28eb02228089c. - Linux x86_64; Node v22.19.0; pnpm 9.15.0; frozen lockfile installation; no new dependency.
- Normal dependency-aware server build and tests use Turbo.
- No provider binaries, credentials, existing bb instance, user database, HTTP listener or network port are needed. Each reproduction launches fresh Node children; no persisted data directory is used by the registry.
4. Minimal reproduction
- 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
- Save the regression test at
apps/server/test/providers/provider-registry-liveness.test.tsand run:pnpm exec turbo run test --filter=@bb/server -- test/providers/provider-registry-liveness.test.ts
- Expected: child processes print
wait completedbefore 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. - 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
- First clean reproduction: failing regression
- Second clean reproduction: failing regression
- Clean child-process observations
- Fixed regression and relevant suites: 26 passing tests
- Fixed child-process observations
- Passing server typecheck
- Proposed fix and regression test
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.