Skip to content

Bun.serve: close the connection after an async handler's Connection: close response - #35555

Closed
robobun wants to merge 4 commits into
mainfrom
farm/16ca6c5b/serve-connection-close-v2
Closed

robobun wants to merge 4 commits into
mainfrom
farm/16ca6c5b/serve-connection-close-v2

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

What

An async handler (any await before returning the Response) dropped both connection-lifetime signals:

  • a request's Connection: close was not echoed and did not close the socket; a second request on the same connection was answered
  • a Response carrying connection: close put the header on the wire but kept the socket open

RFC 9112 section 9.6 makes both a MUST. The sync request-header path already closed via onData's tail; every other path kept the socket alive until idleTimeout.

// bun repro.mjs
import net from "node:net";
for (const mode of ["sync", "await", "await+resp-close-hdr"]) {
  const s = Bun.serve({ port: 0, hostname: "127.0.0.1",
    fetch: mode === "sync" ? () => new Response("hi")
      : mode === "await" ? async () => { await Bun.sleep(1); return new Response("hi"); }
      : async () => { await Bun.sleep(1); return new Response("hi", { headers: { connection: "close" } }); } });
  const r = await new Promise(res => { let buf = "", sent2 = false;
    const c = net.connect(s.port, "127.0.0.1", () => c.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"));
    c.on("data", d => { buf += d; if (!sent2 && buf.includes("hi")) { sent2 = true; setTimeout(() => c.write("GET /two HTTP/1.1\r\nHost: x\r\n\r\n"), 300); } });
    c.on("close", () => res({ mode, closedByServer: true, responses: (buf.match(/200 OK/g) || []).length }));
    setTimeout(() => { c.destroy(); res({ mode, closedByServer: false, responses: (buf.match(/200 OK/g) || []).length }); }, 3000);
  });
  s.stop(true); console.log(JSON.stringify(r));
}
// main:
// {"mode":"sync","closedByServer":true,"responses":1}
// {"mode":"await","closedByServer":false,"responses":2}
// {"mode":"await+resp-close-hdr","closedByServer":false,"responses":2}

Why

The async response ends inside HttpResponse::cork(). internalEnd is therefore corked, takes its else if (!keepCorked) this->uncork() branch, and returns without a close check. cork()'s own close check at the tail would have caught it, but it sits behind the findCorkSlot(this) == INVALID_CORK_SLOT early return that internalEnd's uncork just triggered. So nothing ever reaches shutdown()/close().

The Connection: close response header was also gated on (state & HTTP_CONNECTION_CLOSE) == 0, which skips it whenever the close came from the request (the bit is already set), so the SHOULD-echo was only written when neither side had asked for the close.

Fix

internalEnd now routes both end paths through a new uncorkAndCloseIfNeeded(), which uncorks and then closes when flagged unless the parser is running on THIS socket (so a sync end inside onData still defers to onData's tail and request-smuggling.test.ts's body-validation cases keep 400ing). The flag is per socket rather than per server so a close-flagged response that resolves inside another socket's request handler (its microtask drain) still closes; upgrade() now reads the same per-socket flag, captured before the HttpResponseData destructor.

The response header check now tests HTTP_WROTE_CONNECTION_HEADER (set by writeFetchHeadersToUWSResponse and by node:http's writeHead, which always writes its own) instead of HTTP_CONNECTION_CLOSE, so the header is echoed whenever the server is closing and the application did not write a Connection header itself.

uws_res_end_without_body gets the same close-after-end, with keepCorked=true so node:http's destroy path (which corks, calls it, then force-closes) keeps discarding the buffered bytes rather than flushing them.

This is the minimal async-face of #33005, rebased onto main's uint32_t state word and the resetResponseState/shouldCloseConnection helpers that landed since; #33005 additionally replaces the length() == 5 request-side detection with a token-list scan and discards pipelined bytes behind a close-flagged request.

Verification

New Connection: close on an async handler describe block in test/js/bun/http/serve.test.ts (5 tests, raw net socket):

  • sync and async, request with Connection: close: one response, one Connection: close header, server FIN, second request refused
  • sync and async, Response with connection: close: same
  • async response that resolves inside another socket's onData: still closes

On main 4 of the 5 fail (sync+response-header already works); with this change all 5 pass.

No regressions in bun-serve-headers.test.ts, request-smuggling.test.ts (81 pass), serve.test.ts, websocket-server.test.ts, or test/js/node/http/* (failure sets identical to a main debug build).


no test proof · iteration 4 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/http/serve.test.ts

…close response

An async handler that awaits before returning a Response dropped both
connection-lifetime signals: a request's Connection: close was not
echoed and did not close the socket, and a Response carrying
connection: close put the header on the wire but kept the socket open.
RFC 9112 9.6 makes both a MUST; the sync request-header path was the
only one that already closed.

Mechanism: the async response ends inside cork(), so internalEnd takes
its corked branch and uncorks without a close check, and cork()'s own
close check is behind a findCorkSlot early return that the same uncork
triggers. Synchronous handlers end inside onData, whose tail has its
own close check.

internalEnd now routes both end paths through uncorkAndCloseIfNeeded,
which uncorks and then closes when flagged unless the parser is
running on THIS socket (a new per-socket isParsingHttp, replacing the
per-server flag so a response that resolves inside another socket's
request handler still closes). The Connection: close response header
is now written whenever the server closes unless the application
already wrote a Connection header (HTTP_WROTE_CONNECTION_HEADER, set
by Bun.serve's header writer and by node:http's writeHead, which
always writes its own). uws_res_end_without_body gets the same
close-after-end.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

HTTP response parsing state now lives on HttpResponseData, response completion centralizes uncork-and-close behavior, and explicit Connection headers suppress automatic emission. Node HTTP paths and socket-level tests cover synchronous and asynchronous close handling.

Changes

HTTP close flow

Layer / File(s) Summary
Parser state ownership
packages/bun-uws/src/HttpContext.h, packages/bun-uws/src/HttpContextData.h, packages/bun-uws/src/HttpResponseData.h, packages/bun-uws/src/HttpResponse.h
Parsing state moved to HttpResponseData, is cleared around parse errors and cleanup, and is captured before upgrades.
Response end and socket closure
packages/bun-uws/src/HttpResponse.h, src/uws_sys/libuwsockets.cpp
Uncork-and-close behavior is centralized, and automatic Connection: close emission uses write-state flags across SSL and non-SSL paths.
Connection header integration and validation
src/jsc/bindings/NodeHTTP.cpp, test/js/bun/http/serve.test.ts
Node HTTP responses record application-written Connection headers, with socket tests covering sync, async, and cross-socket promise scenarios.

Possibly related PRs

  • oven-sh/bun#34296: Both adjust HTTP parser state and client-error handling around socket ownership.
  • oven-sh/bun#35036: Both modify response ending, state flags, and connection teardown behavior.

Suggested reviewers: cirospaciari, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: fixing connection closure for async Bun.serve handlers.
Description check ✅ Passed The description covers what changed and how it was verified, even though it uses different headings than the template.

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. ERR_INCOMPLETE_CHUNKED_ENCODING with Next.js app using Bun in Docker behind NGINX proxy #19789 - NGINX sends Connection: close as a reverse proxy; Bun wrote the header but never sent TCP FIN, causing ERR_INCOMPLETE_CHUNKED_ENCODING
  2. Segfault in uWS::HttpContext<false>::onClose during socket teardown (Bun 1.3.14, Linux, standalone executable) #31467 - Segfault in uWS::HttpContext<false>::onClose during socket teardown on keep-alive connections — the crash is in the exact close-handling code path this PR modifies
  3. Server crashed with segmentation fault #15281 - Segfault at HttpContext.h:130 in the close callback of a long-running HTTP server, consistent with use-after-free from improper async close lifecycle

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #19789
Fixes #31467
Fixes #15281

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

On the suggested issues:

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:20 PM PT - Jul 25th, 2026

⏳ @robobun, your commit 7d10e2b is still building in Build #81289, but has 1 failures so far (All Failures):

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Bun.serve: stop dispatching pipelined requests after Connection: close (RFC 9112 9.6) #33005 - Broader PR to honor Connection: close in Bun.serve (RFC 9112 9.6); Bun.serve: close the connection after an async handler's Connection: close response #35555 describes itself as the "minimal async-face" of this PR
  2. fix: close connection after async chunked response with Connection: close #28390 - Fixes the same bug: async chunked responses fail to close the connection when Connection: close is set

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

On the duplicate-PR flags: both are real overlaps.

  • Bun.serve: stop dispatching pipelined requests after Connection: close (RFC 9112 9.6) #33005 is acknowledged in the PR body; it is the broader fix (also replaces the request-side length() == 5 detection with a token-list scan and discards pipelined bytes behind a close-flagged request) but has merge conflicts against main's current uint32_t state word and resetResponseState refactor. This PR is that change's async-end path only, rewritten against current main.
  • fix: close connection after async chunked response with Connection: close #28390 adds a close check after uncork() in both internalEnd branches without the parser gate. This PR subsumes it (same two branches, via the shared uncorkAndCloseIfNeeded helper, plus the per-socket isParsingHttp gate so a sync end inside onData still defers to onData's tail; without the gate the chunked-size 400s in request-smuggling.test.ts lose their body validation).

Comment thread packages/bun-uws/src/HttpContext.h Outdated
… serverClosed from 'close without client destroy' in the test

The clientError early-return path leaves the socket alive with
HTTP_NODE_PARSING_STOPPED set; without clearing isParsingHttp there, a
later close-flagged response end on that socket would defer its close
to an onData tail that never runs.

The test's on('end') detection of serverClosed is not portable: on
Windows the second write can draw an RST that pre-empts the FIN, so the
socket emits 'close' without 'end'. Track whether the client destroyed
instead; the socket only closes without that when the server closed it.
Comment thread src/jsc/bindings/NodeHTTP.cpp Outdated
Comment thread src/jsc/bindings/NodeHTTP.cpp Outdated
Comment thread src/uws_sys/libuwsockets.cpp Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/http/serve.test.ts`:
- Around line 3759-3776: Replace the polling loop around the /a request in the
fetch handler test with a readiness Promise. Resolve it immediately when armed
is assigned, then await that promise before requesting /b, preserving the
existing request and response behavior without using Bun.sleep.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 020a9266-f49c-40ab-b2b3-81e277c42d47

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and 8ecd52f.

📒 Files selected for processing (7)
  • packages/bun-uws/src/HttpContext.h
  • packages/bun-uws/src/HttpContextData.h
  • packages/bun-uws/src/HttpResponse.h
  • packages/bun-uws/src/HttpResponseData.h
  • src/jsc/bindings/NodeHTTP.cpp
  • src/uws_sys/libuwsockets.cpp
  • test/js/bun/http/serve.test.ts
💤 Files with no reviewable changes (1)
  • packages/bun-uws/src/HttpContextData.h

Comment thread test/js/bun/http/serve.test.ts
Comment thread src/uws_sys/libuwsockets.cpp
Comment thread packages/bun-uws/src/HttpContext.h
Comment thread packages/bun-uws/src/HttpResponse.h
…aths; early-return after close in the content-length branch

- uws_res_end_sendfile now routes through uncorkAndCloseIfNeeded like the
  other end paths (it had the identical markDone+resetTimeout shape and
  its close_connection parameter was already ignored).
- Clear isParsingHttp on onData's fall-through (returnedData nullptr
  without close/upgrade: the HTTP_NODE_PARSING_STOPPED and
  us_socket_is_shut_down lambda early-returns), guarded by
  !us_socket_is_closed.
- Early-return from internalEnd's content-length branch when
  uncorkAndCloseIfNeeded closes, matching the chunked branch.
- Cross-socket test awaits a readiness promise instead of polling.
- Trimmed the flagged multi-line comments to match the surrounding
  one-line-per-header pattern.
Comment thread src/jsc/bindings/NodeHTTP.cpp

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The findings from my earlier passes were addressed in e2dd58e and no new issues surfaced on this revision, but this reworks connection-close/teardown ordering across every uWS end path (internalEnd, sendfile, end-without-body, upgrade) and moves isParsingHttp from per-context to per-socket state — worth a human look before landing.

What was reviewed:

  • Re-traced every onData exit path for the per-socket isParsingHttp clear after e2dd58e: the clientError branch, the returnedData != nullptr block, and the new fall-through guard now cover all live-socket returns.
  • Confirmed the content-length branch of internalEnd now early-returns after uncorkAndCloseIfNeeded closes, matching the chunked branch; the remaining hasResponded() read on the pre-existing uncorked path was left as-is per the author's reply.
  • Checked HTTP_WROTE_CONNECTION_HEADER is set on both the FetchHeaders write path and node:http's writeHead, and is included in resetResponseState's per-response clear (it is not in HTTP_CONNECTION_SCOPED).
  • The new test's exchange helper wires 'close' (not just 'end') to resolution and destroys on a second response, so the broken case cannot hang.
Extended reasoning...

Overview

Fixes RFC 9112 §9.6 compliance for async Bun.serve handlers: a request or response carrying Connection: close now actually closes the socket after the response, instead of keeping it alive until idleTimeout. Mechanically: (1) isParsingHttp moves from the per-server HttpContextData::flags to the per-socket HttpResponseData, so a close-flagged response resolving inside another socket's onData (microtask drain) can still close; (2) both branches of internalEnd, plus uws_res_end_sendfile and uws_res_end_without_body, route through a new uncorkAndCloseIfNeeded() that uncorks and then runs the same close check the uncorked path already had; (3) the automatic Connection: close header is now gated on a new HTTP_WROTE_CONNECTION_HEADER bit (set by writeFetchHeadersToUWSResponse and unconditionally by node:http's writeHead) rather than HTTP_CONNECTION_CLOSE, so it is echoed to the client even when the close came from the request. Five raw-net tests cover sync/async × request-header/response-header plus the cross-socket resolution case.

Security risks

None new. This is response-end control flow, not input parsing; the isParsingHttp gate exists specifically to preserve onData's tail body-validation on the sync path (request-smuggling.test.ts was verified). The change surface is close-earlier, not accept-more.

Level of scrutiny

High. packages/bun-uws/src/HttpResponse.h / HttpContext.h are the hot path for every Bun.serve and node:http response, and the change interacts with C++ object lifetime: us_socket_close synchronously runs ~HttpResponseData, so any code that keeps touching httpResponseData after a close is [basic.life] UB even though usockets defers the free. My earlier passes on this PR found four such edge cases (the clientError early-return, two more nullptr-without-close lambda exits, the missing sendfile sibling, and the missing early-return after close in the content-length branch); all were addressed across 8ecd52f and e2dd58e, but the density of subtle exit paths here is exactly why a maintainer familiar with the uWS close/cork state machine should sign off.

Other factors

  • All prior inline findings from this reviewer are resolved; the author declined only the uws_res_try_end reorder with a correct justification (moving clearOnWritableAndAborted before tryEnd would drop onWritable on backpressure).
  • One comment-cop inline (NodeHTTP.cpp:944, 3-line comment) is still open — cosmetic.
  • The PR body references sibling PRs #33005 and #28390 that this subsumes/rebases; someone with context on those should confirm the intended scope split.
  • Tests cover the fix well but the PR body's own verification note says node:http failure sets are "identical to a main debug build", not zero — CI results on e2dd58e should be checked.

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status for reviewers (7d10e2b, identical to e2dd58e): the change is complete and every review thread is resolved.

CI: neither build for the final diff reached the test lanes.

  • 80597 (e2dd58e): build-bun timed out on linux-x64, darwin-x64, and darwin-aarch64 (step-failed-outside-runner); main's own builds at the time were in the same state.
  • 81289 (re-roll): build-bun failed on x64-musl, freebsd-x64, freebsd-aarch64 (step-failed-outside-runner, [pre-existing]); every other lane expired waiting on those builds (169 waiting_failed, 22 expired, 2 passed).

The test lanes that did run on this diff are in 80255 (8ecd52f, two commits back): the five new serve.test.ts tests pass on every lane including Windows aarch64, and the only failures are unrelated flakes (complex-workspace.test.ts install, cpu-prof.test.ts, test-fastutf8stream-reopen.js, and siblings), none touching HTTP.

The delta since 80255 (e2dd58e) is review-driven: uws_res_end_sendfile wrapped the same way as uws_res_end_without_body, one more isParsingHttp clear on onData's fall-through, an early-return after close in internalEnd's content-length branch, and comment trims. serve.test.ts -t "Connection: close on an async handler" (5/5), bun-serve-file.test.ts, bun-serve-headers.test.ts, and request-smuggling.test.ts (184 pass) all pass locally on it.

Not pushing further re-rolls while the build fleet is unstable; the diff is ready.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Noting one more case this fixes that is still broken on main and not exercised by the current tests (or by the bun-serve-headers.test.ts "streaming body" case, whose synchronous start() stream collapses to a Blob before render):

const upstream = Bun.serve({
  port: 0,
  fetch() {
    let left = 3;
    return new Response(
      new ReadableStream({
        async pull(c) {
          if (left-- === 0) return c.close();
          await 1;
          c.enqueue(new TextEncoder().encode("hi"));
        },
      }),
    );
  },
});

const proxy = Bun.serve({
  port: 0,
  idleTimeout: 0,
  async fetch() {
    const r = await fetch(upstream.url);
    return new Response(r.body, { headers: { Connection: "close" } });
  },
});

// net.connect to proxy, GET / HTTP/1.1 (no request Connection header):
// main    -> header and 0-chunk arrive, socket stays open forever
// this PR -> socket closes after the 0-chunk

The path is RequestContext::on_pipe writing each ByteStream chunk via uws_res_write, which implicitly AsyncSocket::cork()s the socket for writes under 16 KB and never uncorks. on_pipe's end_stream then hits sendTerminatingChunk while corked, so main's internalEnd takes the else if (!keepCorked) this->uncork() branch and skips the close. uncorkAndCloseIfNeeded uncorks and then closes, so it is already covered by the change here.

A request-side Connection: close on the same body shape hits the same skip. Might be worth adding a proxy-body case alongside the string-body ones so the streaming end path is locked too; happy to push one if useful.

Comment thread packages/bun-uws/src/HttpResponse.h
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-25, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants