Skip to content

Fix marketplace fetch crash loop: consume bodyless responses and cancel the request timeout - #3962

Merged
SawyerHood merged 1 commit into
get-bb:mainfrom
dillonzq:fix/marketplace-fetch-abort-crash
Sep 21, 2026
Merged

SawyerHood merged 1 commit into
get-bb:mainfrom
dillonzq:fix/marketplace-fetch-abort-crash

Conversation

@dillonzq

Copy link
Copy Markdown
Contributor

What was wrong

refreshMarketplaces arms each marketplace request with signal: AbortSignal.timeout(10_000), which cannot be cancelled. When the manifest fetch returns a bodyless status (304 on a warm cache, 204, or 205), publicMarketplaceFetch returned new Response(null) without consuming the underlying IncomingMessage, so the socket stayed bound to the request. About 10s later the timeout fired, Node destroyed that socket, and its unhandled 'error' exited the server; the launcher restarted it into the same 304, producing a ~12s crash loop on Node 26.9.0.

The issue inferred a 200 from a bare curl, but requestManifest sends if-none-match whenever a stored catalog has an etag, so every boot after the first one exercises the empty-body (304) path. Verified on main at c1a64f4b4; see #3961 for the instrumentation.

What changed

  • publicMarketplaceFetch (apps/server/src/services/plugin-catalog/marketplace-http.ts) consumes bodyless responses (incoming.resume()), so the request completes and the socket returns to the agent pool.
  • It owns a cancellable timeout (AbortController + setTimeout, cleared when the response closes) instead of forwarding an uncancellable AbortSignal.timeout. A caller-provided init.signal is still honored through AbortSignal.any.
  • The four call sites that passed signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS) (marketplace-source.ts, marketplace-icons.ts, marketplace-stats.ts, managed-plugin-artifacts.ts) no longer pass their own signal; publicMarketplaceFetch owns the deadline for its callers. plugin-updates.ts keeps its own timeout because it uses global fetch.
  • Extracted createPublicMarketplaceFetch({ request, lookup }) with a production-injected publicMarketplaceFetch, so the request path can be exercised against a local server without weakening the public-address policy.
  • Regression test in apps/server/test/services/plugin-catalog/marketplace-http.test.ts.

No HOST_DAEMON_PROTOCOL_VERSION change and no CLI/config/wire changes.

How you verified

  • New test settles a bodyless response so a late timeout cannot abort the socket fails before the fix (the socket receives AbortError once the caller timeout fires) and passes after; confirmed by temporarily removing only the incoming.resume() line.
  • pnpm exec turbo run test --filter=@bb/server -- test/services/plugin-catalog/marketplace-http.test.ts → 21 passed.
  • pnpm exec turbo run typecheck --filter=@bb/server → passed.
  • pnpm exec turbo run test --filter=@bb/server -- test/services/plugin-catalog → 148 passed. The one failure (third-party-marketplaces.test.ts "resolves a third-party git range to its current tag and commit", a 5s git timeout) reproduces on base c1a64f4b4 with this change stashed.
  • Manual check on c1a64f4b4 with an AbortSignal.timeout probe: with the old bodyless handling the reported stack appears (stream.<computed>.AbortError.cause (node:internal/streams/add-abort-signal:47:22)); with the fix the timeout fires with no unhandled error and the process stays up.

Fixes #3961

AGENT GENERATED

refreshMarketplaces arms each marketplace request with
AbortSignal.timeout(10_000), which cannot be cancelled. When the manifest
fetch returns a bodyless status (304 on a warm cache, 204, or 205),
publicMarketplaceFetch returned `new Response(null)` without consuming the
IncomingMessage, so the socket stayed bound to the request. About 10s later
the timeout fired, Node destroyed that socket, and its unhandled 'error'
exited the server; the launcher restarted it into the same 304, producing a
~12s crash loop on Node 26.9.0.

- Consume bodyless responses so the request completes and the socket returns
  to the agent pool.
- Own a cancellable timeout in publicMarketplaceFetch and clear it when the
  response settles, so no abort outlives the request.
- Drop the caller-side AbortSignal.timeout from the four
  publicMarketplaceFetch call sites; the fetch owns the deadline now.
- Add an injected-request regression test against a local server.
Copilot AI lite review requested due to automatic review settings September 20, 2026 14:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

No unresolved blocking issues were identified.

Review effort: Lite
Findings: None

What changed in this PR

Fixes marketplace crash loops by draining bodyless responses and centralizing cancellable request timeouts.

Changes:

  • Consumes 204/205/304 response streams.
  • Centralizes timeout and abort handling.
  • Removes redundant caller timeouts.
  • Adds regression coverage.
File Description
apps/​server/​test/​services/​plugin-catalog/​marketplace-http.test.ts Adds bodyless-response regression coverage.
apps/​server/​src/​services/​plugins/​managed-plugin-artifacts.ts Removes redundant timeout handling.
apps/​server/​src/​services/​plugin-catalog/​marketplace-stats.ts Removes redundant timeout handling.
apps/​server/​src/​services/​plugin-catalog/​marketplace-source.ts Removes redundant timeout handling.
apps/​server/​src/​services/​plugin-catalog/​marketplace-icons.ts Removes redundant timeout handling.
apps/​server/​src/​services/​plugin-catalog/​marketplace-http.ts Implements response draining and cancellable timeouts.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@SawyerHood
SawyerHood merged commit 33bad65 into get-bb:main Sep 21, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants