Skip to content

Bun.serve: keep the connection-close mark across a pipelined request - #38343

Closed
robobun wants to merge 1 commit into
mainfrom
farm/eaa2cdf9/serve-latch-connection-close
Closed

robobun wants to merge 1 commit into
mainfrom
farm/eaa2cdf9/serve-latch-connection-close

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.serve leaves a connection open that it had already marked for close when a well-formed HTTP/1.1 request is pipelined behind the request that marked it, in the same TCP segment. Affected first requests: GET / HTTP/1.0 (with or without a Connection header), an HTTP/1.1 request with Connection: close, and an HTTP/1.1 request whose Response carries Connection: close. A request sent on that connection later is still served.
  • Regression for the HTTP/1.0 shapes since Bun.serve: reject Transfer-Encoding on an HTTP/1.0 request (RFC 9112 6.1) #35864 (bdb7382): HttpParser.h used to latch isAncientHTTP for the rest of the recv buffer, so the pipelined HTTP/1.1 request was itself treated as HTTP/1.0 and re-marked the connection for close. That PR made the flag per-request (needed for its Transfer-Encoding check), which exposed the bug below. The two Connection: close shapes were already broken the same way in released builds (checked on 1.3.14, where the three HTTP/1.0 shapes still close).
  • Cause: HttpResponseData::resetResponseState() (packages/bun-uws/src/HttpResponseData.h) runs for every request dispatched on a connection (HttpContext.h, the request handler lambda) and clears the whole state word except HTTP_CONNECTION_SCOPED. HTTP_CONNECTION_CLOSE was not in that set, so dispatching the pipelined request wiped the close mark set by the previous request, and the post-parse shouldCloseConnection() gate in HttpContext::onData found nothing to act on.
import net from "node:net";
const s = Bun.serve({ port: 0, fetch: req => new Response(new URL(req.url).pathname) });
const c = net.connect(s.port, "127.0.0.1", () =>
  c.write("GET /first HTTP/1.0\r\nHost: x\r\n\r\nGET /second HTTP/1.1\r\nHost: x\r\n\r\n"));
c.on("data", d => process.stdout.write(d));
c.on("end", () => console.log("\n-- server closed"));
// released (1.3.14 / 1.4.0): /first, /second, then the server closes
// current main:              /first, /second, connection stays open (a request sent later is served)

Fix

  • Adds HTTP_CONNECTION_CLOSE to HTTP_CONNECTION_SCOPED, so resetResponseState() keeps it.
  • Correct because every setter of the bit means "close this connection once the response in flight has completed and flushed" (request was HTTP/1.0 or Connection: close in HttpContext.h; end(..., closeConnection) and close-delimited bodies in HttpResponse.h; a Connection: close response header in NodeHTTP.cpp; node:http's deferred socket.end() in JSNodeHTTPServerSocket.cpp), and nothing ever clears it on purpose: a connection that has been marked can not legitimately become persistent again. A fresh connection starts with a zeroed state word, so nothing leaks between connections.
  • Behaviour with the fix matches released builds for the HTTP/1.0 shapes: the pipelined request is still answered and the existing shouldCloseConnection() gates (onData tail, internalEnd, onWritable) close the socket once that response has drained. Whether the pipelined request should be dispatched at all (RFC 9112 9.6) is a separate question, tracked in Bun.serve: stop dispatching pipelined requests after Connection: close (RFC 9112 9.6) #33005 for Bun.serve and node:http: do not answer a request pipelined behind a response that closes the connection #35054 for node:http's socket.end(); both compose with this change.
  • Test: test/js/bun/http/serve.test.ts, describe a request pipelined behind a non-persistent request does not keep the connection alive. Five first-request shapes (HTTP/1.0, HTTP/1.0 + Connection: keep-alive, HTTP/1.0 + Connection: close, HTTP/1.1 + Connection: close, HTTP/1.1 answered with Connection: close) each followed by a pipelined HTTP/1.1 request; the client sends a probe request once it has the pipelined response and the test settles on whichever comes first, the server closing or the probe being answered. All five fail on current main with probe answered and pass with the fix; on 1.3.14 the three HTTP/1.0 shapes pass and the two Connection: close shapes fail; the keep-alive control (probe answered) passes everywhere.
  • Also run with the fix: the new describe 25x under --rerun-each (150/150), serve.test.ts (288 pass; the 4 failures, requestIP v6, privileged port, #6583, /bun:info loopback, fail identically on an unmodified build in this environment), request-smuggling.test.ts (87 pass), bun-server.test.ts (3 failures, same environment-only set as on an unmodified build), bun-serve-headers/-file/-routes/-static, http-server-chunking, proxy-stress-protocol, serve-directory-routes, serve-direct-readable-stream and 8 HTTP regression files (396 pass), node-http.test.ts (1 failure, request via http proxy, environment-only), 6 node:http connection-handling files (52 pass), and 55 vendored Node http keep-alive / pipeline / close / client-error tests (all pass).

Background

  • uWS keeps one HttpResponseData per socket and reuses it for every request on a keep-alive connection. Its state word mixes per-response bits (status written, Content-Length written, response pending, ...) with per-connection bits. resetResponseState() starts a new response by clearing the word down to HTTP_CONNECTION_SCOPED, the bits that describe the connection (parsing stopped, reads paused, peer FIN received, close when idle); that set is what this PR extends.
  • Pipelining: fenceAndConsumePostPadded (HttpParser.h) loops over every request present in one recv buffer and dispatches each one through the HttpContext.h lambda. Bun.serve supports this only when the previous response completed synchronously (otherwise it closes the connection), which is why the test's handler returns a Response directly.
  • Close gates: nothing closes a marked connection immediately. shouldCloseConnection() is consulted after the parse loop (HttpContext::onData), when a response ends (internalEnd) and when buffered output drains (onWritable), and each only acts once HTTP_RESPONSE_PENDING is clear and the socket has fully drained, so the response to the pipelined request is delivered before the FIN.
  • HTTP/1.0 persistence (RFC 9112 9.3): a connection is persistent after an HTTP/1.0 request only if the response says Connection: keep-alive; Bun.serve never does, so it always marks such connections for close (HttpContext.h, isAncient()), independent of what the request's Connection header says.

no test proof · iteration 0 · 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

HttpResponseData::resetResponseState() runs for every request dispatched on
a connection and cleared HTTP_CONNECTION_CLOSE along with the per-response
framing bits. A well-formed HTTP/1.1 request pipelined behind an HTTP/1.0
request, a Connection: close request, or a response that carried
Connection: close therefore turned the connection persistent again and the
close recorded for the earlier request never happened. Until bdb7382 the
HTTP/1.0 case was masked by the parser latching its ancient flag for the
rest of the recv buffer, which re-marked the connection on the pipelined
request; that flag is per-request now.

The mark describes the connection, not the response in flight, so add it to
HTTP_CONNECTION_SCOPED, the set of bits resetResponseState() preserves. The
pipelined request is still answered and the existing shouldCloseConnection()
gates close the socket once that response has flushed.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a1ab4387-49bb-44b1-9208-8b338ac6cedc

📥 Commits

Reviewing files that changed from the base of the PR and between 54f0271 and 790a5bc.

📒 Files selected for processing (2)
  • packages/bun-uws/src/HttpResponseData.h
  • test/js/bun/http/serve.test.ts

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

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:05 AM PT - Aug 14th, 2026

❌ @robobun, your commit 790a5bc has some failures in Build #95379 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38343

That installs a local version of the PR into your bun-38343 executable, so you can run:

bun-38343 --bun

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on current main and fixed here; ready for a maintainer.

Reproduced with the raw-socket repro in the description (HTTP/1.0 request + pipelined HTTP/1.1 request in one segment): on main (b7a043103, includes #35864) the connection stays open and a later request on it is served; on released 1.3.14 it closes after the two responses. The new serve.test.ts describe fails 5/5 on main (probe answered) and passes with this change.

CI (build 95379): 177 of 179 jobs passed, including every other darwin lane; the remaining 2 are darwin 14 aarch64 - test-bun jobs that expired waiting for an agent (that lane is currently expiring on about half of all builds), and the only test annotations are retry-passed flakes in unrelated files (install, napi, inspect-error-leak). Nothing touches this change, so it is not being re-run until that queue recovers.

@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.

LGTM — one-bit addition to HTTP_CONNECTION_SCOPED so resetResponseState() no longer wipes a pending close mark when a pipelined request is dispatched.

What was reviewed:

  • Grepped every reader/writer of HTTP_CONNECTION_CLOSE across packages/bun-uws/ and src/: all setters are |=-only, nothing intentionally clears it, and fresh connections zero-init state, so making it sticky is safe.
  • Checked both resetResponseState() callers (HttpContext.h request lambda, JSNodeHTTPServerSocket::startPipelinedResponse) — the latter's manual connectionClose re-apply is now redundant but harmless.
  • Verified all shouldCloseConnection() gates still guard on HTTP_RESPONSE_PENDING cleared + hasFullyDrained(), so the pipelined response is still delivered before FIN.
  • Test: event-driven (no sleeps), port: 0, using cleanup, wires error/end/close, includes a keep-alive control case; net already imported at serve.test.ts:27.
Extended reasoning...

Overview

Single-line native change in packages/bun-uws/src/HttpResponseData.h: adds HTTP_CONNECTION_CLOSE to the HTTP_CONNECTION_SCOPED mask, plus a clarifying comment on the enum member. resetResponseState() masks the state word down to HTTP_CONNECTION_SCOPED at the start of every request dispatched on a connection, so before this change a pipelined HTTP/1.1 request in the same recv buffer would erase the close mark set by an HTTP/1.0 or Connection: close request ahead of it. A new 6-case describe block in test/js/bun/http/serve.test.ts covers the five non-persistent first-request shapes plus a keep-alive control.

Security risks

None. This tightens behaviour toward RFC 9112 §9.3/9.6 (a connection marked non-persistent stays non-persistent) and restores released-build behaviour for the HTTP/1.0 shapes regressed by #35864. No new input parsing, no allocation, no untrusted data handling — one bit added to a compile-time mask.

Level of scrutiny

Medium: the flag word is shared connection-lifetime state on a hot path (Bun.serve and node:http compat). I verified the PR's central claim by grepping every use of HTTP_CONNECTION_CLOSE: HttpContext.h:440/442, HttpResponse.h:156/584, libuwsockets.cpp:1414/1440, NodeHTTP.cpp:838, and JSNodeHTTPServerSocket.cpp:254/541 all only |= the bit; nothing ever &= ~ it. The only place that could clear it was resetResponseState() itself, which is exactly the bug. The one read that gates a side effect on the bit being unset (HttpResponse.h:152, deciding whether to emit a Connection: close header) is inside if (closeConnection) and only skips a redundant header write — the connection still closes via the existing shouldCloseConnection() gates, all of which check HTTP_RESPONSE_PENDING == 0 && hasFullyDrained() before shutting down, so the pipelined response is still delivered.

Other factors

The node:http pipelining path (startPipelinedResponseImpl) already worked around this by re-applying a captured connectionClose after resetResponseState(); that re-apply is now a no-op OR of an already-set bit, which is harmless. The Rust-side readers (NodeHTTPResponse.rs is_http_connection_close()) feed the bit back into end(..., closeConnection), which composes correctly. The test is well-constructed per repo guidelines: no sleeps (races "server closed" vs "probe answered" via socket events), port: 0, using server, wires error to a no-op so the following close still resolves, destroys the socket after settling, and includes a positive control. The PR description documents that all five failing cases fail on current main and pass with the fix, and that the surrounding HTTP suites (serve, request-smuggling, node-http, 55 vendored Node keep-alive/pipeline tests) still pass.

Jarred-Sumner pushed a commit that referenced this pull request Sep 17, 2026
…connection (#42986)

### Problem

- A `Bun.serve` handler answers with a `Connection: close` header. The
requests behind it in the same read still run and the connection stays
open (RFC 9112 9.6 forbids both). Regression from #42762 for the middle
of a read: 1.4.2 answers `R:0 R:1` and closes, `main` da60a4b answers
`R:0 R:1 R:2 R:3`.
- #33005 fixed the request side with a parser latch that reads the
request head. A close from the response only sets
`HTTP_CONNECTION_CLOSE`. The next dispatch calls `resetResponseState()`
(`packages/bun-uws/src/HttpContext.h`) and clears it before the close
gate at the end of `onData`.

### Fix

- The dispatch lambda in `onData` tests the bit before the reset. If the
connection is marked close and its response is complete, it sets the
latch of #33005, sends the cork buffer, runs the close gate and stops
the parse.
- A socket that has not drained stays open until `onWritable` closes it.
The latch makes the parser discard later bytes on it.
- Correct because every setter of the bit means "close after this
response". `node:http` is not changed.
- Verified: `test/js/bun/http/serve.test.ts`, "does not run the requests
behind a response that closes the connection": 6 cases fail on `main`,
pass here.

### Background

- `onData` parses every request of one read and calls the dispatch
lambda for each. `resetResponseState()` starts a new response in the
socket's `state` word.
- Close gate: `closeIfDoneAndMarked()` closes a socket that is marked
close, has no pending response and no unsent bytes.
- Cork: uWS collects a socket's writes and sends them with one `send()`.

<details><summary>Notes</summary>

**Repro** (one file). The handler of request 1 sets the header:

```js
import net from "node:net";
const server = Bun.serve({
  port: 0,
  fetch(req) {
    const n = new URL(req.url).searchParams.get("n");
    return new Response("R:" + n, n === "1" ? { headers: { Connection: "close" } } : undefined);
  },
});
const r = n => `GET /?n=${n} HTTP/1.1\r\nHost: x\r\n\r\n`;
const s = net.connect(server.port, "127.0.0.1", () => s.write(r(0) + r(1) + r(2) + r(3)));
let buf = "";
s.on("data", d => (buf += d));
s.on("end", () => console.log("the server closed the connection"));
setTimeout(() => {
  console.log("answered:", buf.match(/R:\d/g).join(" "));
  process.exit(0);
}, 500);
```

1.4.2 and this branch: `the server closed the connection`, `answered:
R:0 R:1`. `main` da60a4b and canary c6b7fcb: `answered: R:0 R:1 R:2
R:3`, no close.

**How 1.4.2 closed.** The first response of a read released the cork. A
later response ended on a socket that was not corked, and `internalEnd`
ran the close gate inside the handler. Since #42762 the socket that
`onData` parses leaves the gate to the end of `onData`. The first
response of a read always ended corked, so a close at the start of a
read was never honoured.

**Why the check is at the next dispatch and not in `internalEnd`.** A
close inside the handler skips the validation of the rest of that
request's body. `request-smuggling.test.ts` ("chunk size strict hex
digit validation") sends an invalid chunk size to a handler that answers
at once, and expects the 400. At the next request boundary the body of
the closing request is consumed and validated.

**Shapes.** One write per row. `rc` is a request that the handler
answers with `Connection: close`. Handlers that ran, then the state of
the connection:

| payload | 1.4.2 | `main` da60a4b | this branch |
|---|---|---|---|
| `a, rc, b, c` | a rc, closed | a rc b c, open | a rc, closed |
| `static, rc, static, b` | rc, closed | rc b, open | rc, closed |
| `rc, b, c` | rc b c, open | rc b c, open | rc, closed |
| `POST rc` + body (handler awaits `req.text()`), `b` | rc b, open | rc
b, open | rc, closed |
| `rc` with a stream body, `b` | rc b, open | rc b, open | rc, closed |
| `a, rc` and `rc` alone | closed | closed | closed |
| `rc`, then bytes that are not HTTP | 200, 400, closed | same | same |
| `POST rc` with an invalid chunk size | 200, 400, closed | same | same
|
| `a, b, c` (keep-alive) | a b c, open | same | same |

**A socket that has not drained.** Checked by hand with an `LD_PRELOAD`
shim that cuts the `send()` of the `rc` response in half and returns
`EAGAIN` until a file appears. The suite has no test for this. It needs
the shim.

| first write, then a later write | `main` da60a4b | this branch |
|---|---|---|
| `a, rc, b`, then `c` | runs `b`, then `c` resets the connection with
the `rc` response cut short (the close for a request behind a pending
response) | runs `a rc`, sends the rest of `rc` after the unblock,
closes |
| `a, rc, POST b` + body, then `d` | same as above | same as above |
| `a, rc`, half of the head of `b`, then the rest of the head and `d`,
then `e` | | same as above |

The second row is why the early exit sets `sawConnectionClose`. The
parser records the `Content-Length` of a request before it calls the
dispatch lambda, and it keeps a split head in its fallback buffer.
Without the latch the later read was parsed against that state, failed,
and the error path closed the socket with the `rc` response cut short (a
review finding on the first push). With the latch those bytes go to a
null body callback or to the discard at the top of the parse loop.

**Randomized probe.** 400 pipelined batches of 1 to 8 requests (GET,
static route, POST with `Content-Length`, chunked POST), a closer at a
random position in three of four batches (request side: `Connection:
close`, HTTP/1.0; response side: plain, after `await req.text()`, stream
body), plain and TLS. The model: every request up to the closer runs and
is answered, then the server closes. This branch: 0 mismatches in 700
batches. `main`: 27 of 80, all on the response side.

**Not changed.**

- Bytes that are not HTTP behind an `rc` response still get a 400 before
the close. The parser rejects them before any dispatch. #33005 discards
them for the request side.
- A request behind a response that is still pending closes the
connection at once, as before.
- A static route whose `Response` carries `Connection: close` sends the
header and does not close (1.4.2 too). That path never sets the bit. It
is a separate bug and is tracked separately.
- A graceful `stop()` in the middle of a read still answers the rest of
the read and then closes.

**Related.** #38343 keeps `HTTP_CONNECTION_CLOSE` across
`resetResponseState()`. With it the requests behind still run and the
connection closes at the end of the read. After #33005 and this change
no request is dispatched on a marked connection with a complete
response, so the reset no longer drops a live mark.

**Suites run with the debug build.** `serve.test.ts` (320 pass),
`request-smuggling.test.ts` (89), `bun-server.test.ts` (80),
`http-server-chunking.test.ts`, `bun-serve-static.test.ts`,
`bun-serve-routes.test.ts`, `bun-serve-file.test.ts`,
`bun-serve-headers.test.ts`, `serve-close-delimited-framing.test.ts`,
`serve-direct-readable-stream.test.ts`, `hspec.test.ts`,
`proxy.test.ts`, `fetch-keepalive.test.ts`, `node-http.test.ts` (162).
Two tests of `serve.test.ts` (`root range port`, `/bun:info` loopback)
fail the same way on a debug build of `main` in this container.

</details>
@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as superseded. #33005 and #42986 are on main, and no request is dispatched on a connection that is marked for close. The parser latch from #33005 discards the requests behind an HTTP/1.0 request or a Connection: close request. When a complete response marked the connection, the check from #42986 stops the parse before resetResponseState(). For Bun.serve, the reset now runs only when HTTP_CONNECTION_CLOSE is clear, so it cannot drop a live close mark.

I checked the five first-request shapes from this PR against a debug build of main (630e921). Each shape had GET /second HTTP/1.1 in the same write, over plain TCP and over TLS. In each shape the server answers /first and closes the connection. The handler for /second does not run, and a request written later on the same socket is not served. A handler that awaits before it answers also ends with a closed connection. The repro from the description now prints the /first response and then -- server closed. A canary build from before the two merges (c6b7fcb) still answers /second and keeps the connection open.

The tests in this PR expect a response to /second. main no longer sends one (RFC 9112 9.6), so they do not carry over. The node:http variant is tracked in #35054.

@robobun robobun closed this Sep 17, 2026
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