Skip to content

bb-server crash-loops every ~10s: bodyless marketplace response leaves a socket attached to an uncancellable AbortSignal.timeout (Node 26.9.0) #3961

Description

@dillonzq

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:

  • The response is never consumed for bodyless statuses: marketplace-http.ts#L177-L182 returns new Response(null, …) and only wraps the body for statuses with content.
  • The caller arms an uncancellable timeout: marketplace-source.ts#L202 (marketplace-icons.ts, marketplace-stats.ts, managed-plugin-artifacts.ts do the same). publicMarketplaceFetch forwards it straight into https.request (marketplace-http.ts#L146-L188).
  • The server exits on uncaught exceptions rather than keeping the process alive: start-server.ts#L414.

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions