Summary
On every server boot with a stored marketplace catalog, refreshMarketplaces fetches the HTTPS manifest with signal: AbortSignal.timeout(10_000). The fetch succeeds (HTTP 304 on a warm cache), but the response has no body, so publicMarketplaceFetch returns new Response(null) and never consumes the underlying IncomingMessage. The socket stays bound to the request; ~10s later the timeout (which cannot be cancelled) fires, Node destroys that socket, and its 'error' event has no listener, so the process dies with an uncaught AbortError. The launcher restarts the server, the same request 304s again, and the server crash-loops every ~10–12s. Node 26.9.0 surfaces this as a fatal uncaught exception; Node 26.8.2 keeps serving.
Versions and environment
- bb-app 0.43.3 (
/opt/homebrew/lib/node_modules/bb-app, server/dist/start-server.js), macOS 27.0 (26A428), arm64.
- Node v26.9.0 (crash), v26.8.2 (no crash). Same bb build, same data dir, same plist, same network.
- Data dir
~/.bb with a stored bb-community catalog (231 entries), so refresh requests carry if-none-match and the marketplace answers 304.
- Reproduced on
main at c1a64f4b4 with a fresh data dir and a network fetch to https://getbb.app/marketplace/v2/marketplace.json.
Steps to reproduce
The crash needs a bodyless response plus a timeout that outlives the request. Without a stored catalog the first refresh is a 200 whose body is read, which is why a clean data dir hides it.
Minimal repro against main at c1a64f4b4 (run from the repo root, Node 26.9.0):
// repro.mts — node --import tsx repro.mts
import { publicMarketplaceFetch } from "./apps/server/src/services/plugin-catalog/marketplace-http.ts";
process.on("uncaughtException", (error) => {
console.error("uncaughtException:", (error as Error).name, (error as Error).message);
process.exit(1);
});
const url = "https://getbb.app/marketplace/v2/marketplace.json";
const first = await publicMarketplaceFetch(url, {
method: "GET",
headers: new Headers({ accept: "application/json" }),
});
const etag = first.headers.get("etag") ?? "";
await first.body?.cancel();
// This is the requestManifest path on any boot that has a stored catalog.
const response = await publicMarketplaceFetch(url, {
method: "GET",
headers: new Headers({ accept: "application/json", "if-none-match": etag }),
redirect: "error",
signal: AbortSignal.timeout(10_000),
});
await response.body?.cancel();
console.log("status", response.status, "- waiting for the late abort");
// ~10s later: uncaughtException: AbortError The operation was aborted
On a warm data dir, just restart the packaged app with Node 26.9.0 and watch ~/.bb/logs/process-server-uncaughtException-*.json:
# 1. run with a Node that has the change in behavior
# 2. observe the server restart ~10-12s later
tail -f ~/.bb/logs/process-server-uncaughtException-*.json
Does not reproduce with: Node 26.8.2 (same build, same fetch, same 304); a 200 response whose body is read to completion; an abort before the response headers arrive.
Expected vs actual
Actual (verbatim, from the instrumented probe): the 304 succeeds, then ~10s after the signal was created the process dies.
[2026-09-20T12:12:55.734Z] 34469 dist/index.js ABORT ms=10000 reason=TimeoutError origin stack:
Error: AbortSignal.timeout(10000) created here
at requestManifest (.../server/dist/start-server.js:340326:27)
at materializeHttps (.../server/dist/start-server.js:340330:24)
at materializeMarketplace (.../server/dist/start-server.js:340287:12)
at performRefresh (.../server/dist/start-server.js:340910:32)
...
at async refreshMarketplaces (.../server/dist/start-server.js:341005:20)
[2026-09-20T12:12:55.735Z] 34469 dist/index.js uncaughtException: AbortError ABORT_ERR The operation was aborted
AbortError: The operation was aborted
at stream.<computed>.AbortError.cause (node:internal/streams/add-abort-signal:47:22)
at AbortSignal.dispatchEvent (node:internal/event_target:813:26)
at runAbort (node:internal/abort_controller:554:10)
at Timeout._onTimeout (node:internal/abort_controller:208:7)
Expected: the marketplace refresh finishes, the socket is released, and the server keeps serving. The process must not exit because a completed marketplace request left a socket attached to an uncancellable timeout.
Evidence
Code, base c1a64f4b4:
Instrumented measurements on c1a64f4b4, Node v26.9.0, wrapping AbortSignal.timeout to log creation stacks on abort:
| scenario |
result |
| marketplace refresh returns 304, body not consumed |
ABORT ms=10000 + uncaught AbortError 1 ms later; process exits |
same 304, but response is consumed (incoming.resume()) |
ABORT ms=10000, 0 uncaught exceptions |
| marketplace refresh returns 200, body read to completion |
ABORT ms=10000, 0 uncaught exceptions |
| abort before response headers (timeout during connect) |
request rejects with AbortError, 0 uncaught exceptions |
https.request with the same 304 + timeout, socket listener attached |
socket error: AbortError on the TLS socket |
The unhandled error is on the request's socket, not on the ClientRequest (which has outgoing.once("error", reject)) and not on the IncomingMessage (adding a listener there does not help). Consuming the bodyless response is what releases the socket and prevents the late destroy.
One correction to the original report: the crashing fetch is the 304 on the cached-catalog path, not a 200. A bare curl (no if-none-match) returns 200, but requestManifest sends if-none-match whenever a stored catalog has an etag, so every boot after the first one exercises the empty-body path.
What you ruled out
- Not a duplicate: searched open and closed issues for
AbortError, AbortSignal, marketplace crash loops; nothing matches this signature.
- Still reproduces on
main at c1a64f4b4 (not fixed since 0.43.3).
- Not the fetch failing: the 304 arrives, the catalog stays valid,
bb marketplace list --json shows lastError: null.
- Not DNS/network/TLS: the same request completes; only the late abort kills the process.
- Not a Node-only bug: 26.8.2 runs the same abort with 0 crashes, but the bb-side defect (a request whose socket outlives the response it already returned) is real on both versions.
Suggested priority and effort
High — a bodyless marketplace response plus a cached catalog takes the whole local server down in a restart loop on Node 26.9.0, dropping in-flight threads/terminals and re-running all plugin boot work every cycle. Workaround: pin Node 26.8.2. Effort: Low; the fix is local to marketplace-http.ts and the four call sites that pass AbortSignal.timeout.
AGENT GENERATED
Summary
On every server boot with a stored marketplace catalog,
refreshMarketplacesfetches the HTTPS manifest withsignal: AbortSignal.timeout(10_000). The fetch succeeds (HTTP 304 on a warm cache), but the response has no body, sopublicMarketplaceFetchreturnsnew Response(null)and never consumes the underlyingIncomingMessage. The socket stays bound to the request; ~10s later the timeout (which cannot be cancelled) fires, Node destroys that socket, and its'error'event has no listener, so the process dies with an uncaughtAbortError. The launcher restarts the server, the same request 304s again, and the server crash-loops every ~10–12s. Node 26.9.0 surfaces this as a fatal uncaught exception; Node 26.8.2 keeps serving.Versions and environment
/opt/homebrew/lib/node_modules/bb-app,server/dist/start-server.js), macOS 27.0 (26A428), arm64.~/.bbwith a storedbb-communitycatalog (231 entries), so refresh requests carryif-none-matchand the marketplace answers 304.mainatc1a64f4b4with a fresh data dir and a network fetch tohttps://getbb.app/marketplace/v2/marketplace.json.Steps to reproduce
The crash needs a bodyless response plus a timeout that outlives the request. Without a stored catalog the first refresh is a 200 whose body is read, which is why a clean data dir hides it.
Minimal repro against
mainatc1a64f4b4(run from the repo root, Node 26.9.0):On a warm data dir, just restart the packaged app with Node 26.9.0 and watch
~/.bb/logs/process-server-uncaughtException-*.json:Does not reproduce with: Node 26.8.2 (same build, same fetch, same 304); a 200 response whose body is read to completion; an abort before the response headers arrive.
Expected vs actual
Actual (verbatim, from the instrumented probe): the 304 succeeds, then ~10s after the signal was created the process dies.
Expected: the marketplace refresh finishes, the socket is released, and the server keeps serving. The process must not exit because a completed marketplace request left a socket attached to an uncancellable timeout.
Evidence
Code, base
c1a64f4b4:marketplace-http.ts#L177-L182returnsnew Response(null, …)and only wraps the body for statuses with content.marketplace-source.ts#L202(marketplace-icons.ts,marketplace-stats.ts,managed-plugin-artifacts.tsdo the same).publicMarketplaceFetchforwards it straight intohttps.request(marketplace-http.ts#L146-L188).start-server.ts#L414.Instrumented measurements on
c1a64f4b4, Node v26.9.0, wrappingAbortSignal.timeoutto log creation stacks on abort:ABORT ms=10000+ uncaughtAbortError1 ms later; process exitsincoming.resume())ABORT ms=10000, 0 uncaught exceptionsABORT ms=10000, 0 uncaught exceptionsAbortError, 0 uncaught exceptionshttps.requestwith the same 304 + timeout, socket listener attachedsocket error: AbortErroron the TLS socketThe unhandled error is on the request's socket, not on the
ClientRequest(which hasoutgoing.once("error", reject)) and not on theIncomingMessage(adding a listener there does not help). Consuming the bodyless response is what releases the socket and prevents the late destroy.One correction to the original report: the crashing fetch is the 304 on the cached-catalog path, not a 200. A bare
curl(noif-none-match) returns 200, butrequestManifestsendsif-none-matchwhenever a stored catalog has an etag, so every boot after the first one exercises the empty-body path.What you ruled out
AbortError,AbortSignal, marketplace crash loops; nothing matches this signature.mainatc1a64f4b4(not fixed since 0.43.3).bb marketplace list --jsonshowslastError: null.Suggested priority and effort
High — a bodyless marketplace response plus a cached catalog takes the whole local server down in a restart loop on Node 26.9.0, dropping in-flight threads/terminals and re-running all plugin boot work every cycle. Workaround: pin Node 26.8.2. Effort: Low; the fix is local to
marketplace-http.tsand the four call sites that passAbortSignal.timeout.