Skip to content

node:http: don't leave the server socket Duplex corked across kept-alive requests - #35664

Open
robobun wants to merge 8 commits into
mainfrom
farm/9db0935b/fix-connect-corked-socket
Open

robobun wants to merge 8 commits into
mainfrom
farm/9db0935b/fix-connect-corked-socket

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

What

The node:http server dispatch path corked the connection's socket Duplex after every 'request' emit and never uncorked it. Bun's ServerResponse writes through the native response handle rather than the Duplex, so the cork was a no-op for response bytes but left socket._writableState.corked incrementing on every kept-alive request. When a CONNECT or Upgrade later arrived on the same connection and the handler wrote to the raw socket, those writes sat in the Writable's cork buffer and never reached the wire until socket.end() force-uncorked.

Repro

import http from "node:http";
import net from "node:net";

const server = http.createServer((req, res) => res.end("body"));
server.on("connect", (req, socket) => {
  socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
  socket.write("tunnel-data"); // stays open: proxy tunnels pipe both ways
  console.log("corked:", socket.writableCorked, "buffered:", socket.writableLength);
});
server.listen(0, () => {
  const sock = net.connect(server.address().port);
  sock.on("connect", () => sock.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"));
  let buf = "";
  sock.on("data", d => {
    buf += d;
    if (buf.includes("body")) {
      buf = "";
      sock.write("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n");
    }
  });
});

Node prints corked: 0 buffered: 0 and the client receives tunnel-data. Bun printed corked: 1 buffered: 50 and the client never saw the writes (a later socket.end() would flush them, but an open tunnel never ends).

This is the failure behind #12213 (proxy-chain + puppeteer): anonymizeProxy starts a local http.Server; the first request on a connection goes through forward(), and chain()'s outgoing CONNECT reuses the kept-alive socket to the upstream proxy. The upstream's 'connect' handler writes 200 Connection Established and then pipes, but the Duplex is corked so nothing reaches the client and Chrome reports net::ERR_TUNNEL_CONNECTION_FAILED. It is also the root of the "connection reset" write-side failure in #26553.

Fix

  • Drop the stray socket.cork() in onNodeHTTPRequest. Bun's ServerResponse writes through the native handle (handle.writeHeadAndEnd / handle.end), not the socket Duplex, so this cork was not coalescing any response bytes; it only leaked a cork count per request.
  • In ServerResponse.prototype.end, fully uncork the connection like Node's OutgoingMessage.prototype.end does (_writableState.corked = 1; uncork()), reading the socket via the response's own this[fakeSocketSymbol] (the storage res.cork() corked), so a user-issued res.cork() is cleared when the response completes even if req.socket was nulled by the stream destroyer.

With this change Bun matches Node: req.socket.writableCorked is 0 at the start of every request and inside 'connect' / 'upgrade' handlers, and a proxy-chain-style CONNECT tunnel over a kept-alive socket delivers its writes immediately.

Fixes #12213
Addresses the write-side portion of #26553


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

…ive requests

The dispatch path corked the connection's socket Duplex after every
'request' emit but never uncorked it. ServerResponse writes through the
native response handle (not the Duplex), so the cork was a no-op for
response bytes but left the Duplex's writableState.corked incrementing on
every kept-alive request. When a CONNECT or Upgrade later arrived on the
same connection and the handler wrote to the raw socket, those writes sat
in the Writable's cork buffer forever and never reached the wire.

This is the proxy-chain (puppeteer-with-proxy) failure in #12213:
proxy-chain's upstream chain() sends a CONNECT over the kept-alive
socket the prior forward() request left corked; the '200 Connection
Established' it writes never reaches the client, so Chrome sees
net::ERR_TUNNEL_CONNECTION_FAILED.

Drop the stray dispatch cork, uncork the connection in
ServerResponse.prototype.end like Node's OutgoingMessage.end does, and
fully uncork before handing the socket to 'connect'/'upgrade'.
@coderabbitai

coderabbitai Bot commented Jul 25, 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: 11 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: 8470547e-4769-4d90-84e2-8711380bbdbf

📥 Commits

Reviewing files that changed from the base of the PR and between 04bb5c4 and 62d7e04.

📒 Files selected for processing (2)
  • src/js/node/_http_server.ts
  • test/js/node/http/node-http-connect.test.ts

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

@github-actions

Copy link
Copy Markdown
Contributor

Found 5 issues this PR may fix:

  1. Playwright connectOverCDP() not working #9911 - Playwright connectOverCDP() uses WebSocket upgrade over HTTP; the corked socket prevents the upgrade handshake response from reaching the wire
  2. node:http is broken for proxies — 4 PRs fixing createConnection, upgrade sockets, connection close, and socket cleanup #28396 - Umbrella issue for node:http proxy bugs, including "Upgrade socket.write() silently drops data" which is directly caused by the cork leak
  3. Support undici ProxyAgent #4474 - undici ProxyAgent's CONNECT-based proxy code fails because the 'connect' event handler never receives data due to corked socket
  4. ERR_INCOMPLETE_CHUNKED_ENCODING with Next.js app using Bun in Docker behind NGINX proxy #19789 - ERR_INCOMPLETE_CHUNKED_ENCODING behind NGINX proxy; chunked responses stuck in cork buffer on keep-alive connections
  5. Likely memoryleak inside bun runtime on service http requests #14065 - Memory leak serving millions of HTTP requests; the stray cork() increments _writableState.corked on every kept-alive request without decrementing

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

Fixes #9911
Fixes #28396
Fixes #4474
Fixes #19789
Fixes #14065

🤖 Generated with Claude Code

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

I didn't find any bugs. The removed socket.cork() had no matching uncork() on any path and ServerResponse writes go through the native handle (handle.writeHeadAndEnd/handle.end), not the socket Duplex, so the cork was indeed leaking a count per kept-alive request rather than coalescing anything. The two added uncork blocks mirror Node's OutgoingMessage.prototype.end (corked = 1; uncork()). Deferring only because this touches the node:http server request-dispatch and ServerResponse.prototype.end hot paths — worth a human confirming nothing else was relying on that cork.

What was reviewed:

  • Confirmed no other uncork() in _http_server.ts paired with the removed cork() — it was a genuine leak.
  • Checked ServerResponse.prototype.cork/uncork/writableCorked forward to this.socket, so res.cork() corks the same Duplex the new end() block clears.
  • New end() block guards on req?.socket and _writableState?.corked, so the FakeSocket / no-socket paths fall through unchanged.
  • Tests cover CONNECT × Upgrade × sync/async write plus a 3-request kept-alive writableCorked assertion; all wire error to reject and use port: 0.
Extended reasoning...

Overview

Three edits to src/js/node/_http_server.ts:

  1. Removes a lone socket.cork() in the onNodeHTTPRequest dispatch path (right after the 'request'/'checkContinue'/'checkExpectation' emits). This cork had no matching uncork() anywhere in the file, so socket._writableState.corked incremented once per kept-alive request. Because Bun's ServerResponse writes go through the native handle (writeHeadAndEnd, end) rather than the socket Duplex, the cork was not coalescing response bytes — it only leaked state.
  2. Adds a full-uncork (corked = 1; uncork()) in ServerResponse.prototype.end, matching Node's OutgoingMessage.prototype.end, so a user res.cork() is cleared when the response completes.
  3. Adds the same full-uncork in detachSocketListenersForHandoff so the raw socket handed to 'connect'/'upgrade' listeners has writableCorked === 0.

Five new tests in test/js/node/http/node-http-connect.test.ts: a 2×2 matrix (CONNECT/Upgrade × sync/async write) that first sends a kept-alive GET then hands the socket off, asserting writableCorked === 0 at handoff and that socket.write() bytes reach the client; plus a test that sends 3 kept-alive GETs with res.cork(); res.end() and asserts writableCorked is 0 before and after each.

Security risks

None identified. The change only removes a stray cork and adds guarded uncork calls; no parsing, auth, or resource-limit logic is touched.

Level of scrutiny

High. _http_server.ts is the node:http server implementation — one of Bun's most heavily exercised Node-compat surfaces. ServerResponse.prototype.end runs on every HTTP response. That said, the diff is small (−2, +15 in src) and each addition is gated on _writableState?.corked being nonzero, so the common path is a single falsy check. The removal is the substantive change, and the analysis that it was dead (no matching uncork; native-handle writes bypass the Duplex) checks out against the surrounding code.

Other factors

  • Git history for the removed socket.cork() traces back only to the file's initial import commit, so its original intent can't be recovered from blame. The PR description's reasoning (native handle bypasses the Duplex) is consistent with the code I read.
  • Tests follow harness conventions: port: 0, once() for readiness, error handlers wired to reject, polling on received data rather than sleeps, await using for server cleanup.
  • The new end() block reads req?.socket and _writableState?.corked with optional chaining, then only dereferences _writableState.corked inside the truthy branch — safe when req or the writable state is absent.
  • Fixes a real user-facing failure (#12213, proxy-chain + puppeteer) with a clear repro that Node passes and pre-fix Bun fails.

Deferring to a human because the dispatch path and end() are hot enough that removing a long-standing cork() — even one that appears dead — deserves a maintainer's confirmation.

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:06 PM PT - Jul 25th, 2026

❌ @robobun, your commit 62d7e04 has some failures in Build #81611 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 35664

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

bun-35664 --bun

Comment thread test/js/node/http/node-http-connect.test.ts
@robobun
robobun force-pushed the farm/9db0935b/fix-connect-corked-socket branch from a107fde to ef4471e Compare July 25, 2026 12:30
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
robobun added 2 commits July 25, 2026 17:16
…tunnel write check load-bearing

- ServerResponse.end uncorks the same storage res.cork() corked
  (this[fakeSocketSymbol]); req.socket can be nulled by the stream
  destroyer before end() runs.
- Drop the defensive uncork in detachSocketListenersForHandoff: Node
  does not uncork before emitting 'connect'/'upgrade', and with the
  dispatch cork removed and ServerResponse.end uncorking it was
  unreachable for the scenario it documented.
- Test: write() without end() so socket.writableLength is the
  load-bearing signal (Writable.end's force-uncork masked the leak),
  with the client half-closing so the fail-before case doesn't hang.
…ws FIN-before-write ordering doesn't drop the write
@robobun
robobun force-pushed the farm/9db0935b/fix-connect-corked-socket branch from 90477c1 to 2ce6b6f Compare July 25, 2026 19:16
Comment thread test/js/node/http/node-http-connect.test.ts

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

All prior feedback is addressed at 2ce6b6f — both source hunks are present (the stray socket.cork() removed from the dispatch path, and the this[fakeSocketSymbol] full-uncork in ServerResponse.prototype.end), and no accidental revert this time. Deferring only because this sits in the hot path every server request runs through; worth a human sign-off and a green CI across platforms.

Checked this round: the removed socket.cork() had no paired uncork() anywhere in the dispatch path (confirmed orphan); the new uncork reads the same storage res.cork() writes (fakeSocketSymbol, set by assignSocketInternal via the socket setter); the early returns above the uncork block either delegate to OutgoingMessagePrototype.end (which uncorks itself) or have no handle/socket to uncork.

Extended reasoning...

Overview

Two-file change: src/js/node/_http_server.ts (net +7/-2) removes an orphaned socket.cork() from onNodeHTTPRequest after the 'request' emit, and adds a Node-matching full-uncork of this[fakeSocketSymbol] inside ServerResponse.prototype.end. test/js/node/http/node-http-connect.test.ts (+111) adds a describe with four parameterized tests (CONNECT/Upgrade × sync/async write) that send a keep-alive GET followed by a handoff on the same connection and assert writableCorked === 0, writableLength === 0, and that the tunnel bytes reach the client, plus a fifth test asserting req.socket.writableCorked stays 0 across three keep-alive requests even when the handler calls res.cork().

History on this PR

I reviewed three earlier revisions: two flagged accidental worktree-reset reverts of the source hunks (a107fde, 90477c1 — both force-push-fixed), and one flagged that the uncork read req?.socket instead of the response's own socket storage (fixed in 0edc22f to use this[fakeSocketSymbol], matching Node's OutgoingMessage.end which uncorks this[kSocket]). All three are resolved at HEAD; the net diff against base is 2 files / 118 insertions / 2 deletions with both source hunks intact.

Security risks

None. This is Writable cork-counter bookkeeping on the server-side socket Duplex; no parsing, auth, or trust-boundary changes.

Level of scrutiny

High — onNodeHTTPRequest and ServerResponse.prototype.end execute for every node:http server request. That said, the change is narrow: the removed line was demonstrably orphaned (no paired uncork exists in the file, and Bun's ServerResponse writes bypass the Duplex via the native handle so the cork never coalesced anything), and the added block mirrors Node's OutgoingMessage.prototype.end exactly (corked = 1; uncork()). I confirmed res.cork() (line 2906) writes through this.socket → this[fakeSocketSymbol], so cork and uncork operate on the same object. The early-return at 3120 delegates to OutgoingMessagePrototype.end which has its own uncork; the early-return at 3125 has no handle/socket; the throw e at 3204 is an error path where the response is failing anyway.

Other factors

Test coverage is thorough for the bug class (both handoff kinds, sync and setImmediate-deferred writes, plus the plain keep-alive counter). The tests use write() without end() specifically so Writable.end's force-uncork can't mask the regression, and assert a combined object so failure output shows all three observations at once. The Windows FIN-vs-setImmediate ordering is handled by deferring socket.end() until both the client FIN and the deferred respond have happened. The bug-hunting system found nothing this round. Not approving because per the guidelines this touches a critical code path — a maintainer should confirm removing the dispatch-path cork has no unintended effect on any consumer that writes to res.socket directly during a request, and that CI (build #81303) is green on Windows given the test's platform-sensitive ordering.

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

Beyond the inline nit: verified both source hunks are intact at 5346307 after the earlier accidental reverts. Confirmed the removed socket.cork() had no paired uncork() anywhere in the dispatch path, and that the early-return branches in ServerResponse.prototype.end ahead of the new uncork either delegate to OutgoingMessage.prototype.end (which already uncorks via this[kSocket]) or are aborted / already-finished / pipelined-queued states where a cork cannot leak onto a subsequent kept-alive request.

Extended reasoning...

This is my fourth pass on this PR. The three prior rounds flagged (1) an accidental full source revert in a107fde, (2) the uncork reading req?.socket instead of this[fakeSocketSymbol], and (3) a second accidental revert in 90477c1 — all now resolved at HEAD (5346307). The net diff is 2 files / 118 insertions / 2 deletions with the socket.cork() removal at the dispatch site and the full-uncork block in ServerResponse.prototype.end both present. The one remaining finding is a test-diagnostic nit (poll loop lacks a 'close' escape); it does not affect correctness of the fix. I also checked the ruled-out candidate that early exits in end() skip the uncork: the !handle branch delegates to OutgoingMessagePrototype.end which has its own uncork, and the other early returns (aborted, already-finished, pipelined-queued, closed_or_completed) do not leave a live kept-alive socket corked. Not approving because this removes a call from the per-request node:http server dispatch hot path — small and well-justified, but worth a maintainer eyeball.

Comment thread test/js/node/http/node-http-connect.test.ts Outdated
Comment thread test/js/node/http/node-http-connect.test.ts Outdated

@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 62d7e04 rewrite closes the last hang window I flagged — no findings this pass. Deferring to a human because the two source hunks sit in the node:http server dispatch path and ServerResponse.prototype.end, which is more reach than I'll auto-approve.

Checked: the removed socket.cork() had no matching uncork anywhere in the file and ServerResponse writes go through the native handle, so no response-byte coalescing is lost. The new uncork reads this[fakeSocketSymbol] — the same storage ServerResponse.prototype.cork writes through — so it survives req.socket being nulled by the stream destroyer. The five new tests wire 'error'/'close' before any write and assert writableCorked/writableLength directly, so a regression fails fast rather than timing out.

Extended reasoning...

Overview

Two source hunks in src/js/node/_http_server.ts: (1) delete the stray socket.cork() in onNodeHTTPRequest after the 'request' emit, and (2) add a full-uncork block to ServerResponse.prototype.end that reads the connection via this[fakeSocketSymbol] and mirrors Node's OutgoingMessage.prototype.end (corked = 1; uncork()). Five new tests in test/js/node/http/node-http-connect.test.ts cover CONNECT/Upgrade × sync/async handoff writes on a kept-alive connection plus a three-request cork-count assertion.

Security risks

None identified. No parsing of untrusted input is added; the change only stops leaking a Writable cork count and clears it on res.end(). It does not loosen any validation or expose new surface.

Level of scrutiny

High. onNodeHTTPRequest is the per-request dispatch hot path for every node:http server, and ServerResponse.prototype.end runs on every response. The fix is small and matches Node's documented OutgoingMessage.end behavior, but the blast radius (proxy-chain, undici ProxyAgent, WebSocket upgrade over kept-alive sockets, and the five auto-suggested linked issues) means a human should confirm CI is green across platforms — the tests carry Windows-specific FIN-ordering handling and the PR's own evidence footer defers platform coverage to CI.

Other factors

This PR has been through four prior review rounds from me: two caught accidental worktree-reset reverts of the source hunks (both since force-pushed away), one moved the uncork from req?.socket to this[fakeSocketSymbol] so pipeline/compose's destroyer nulling req.socket can't skip it, and two tightened the kept-alive poll test so a premature close rejects with a diagnostic instead of hanging to timeout. All are addressed at 62d7e04; grep confirms no socket.cork() remains in _http_server.ts and the uncork block is present at ServerResponse.prototype.end. No new issues found this pass.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

The diff is green: node-http-connect.test.ts (including the five new cases added here) passes on every lane across builds 81303/81611, Windows included. Remaining CI red on 81611 is unrelated flakes that each passed on retry (webview-chrome, pglite on win-aarch64, no-orphans, quic callback, fastutf8stream, bun-serve-html, s3-requester-pays, 20144) plus one expired build job. Ready for review.

@robobun

robobun commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Independently hit this from a fuzz ledger (ledger #9800: upgrade/CONNECT write-stalled when not the first request on a keep-alive connection) and arrived at the same root cause and fix. Closed #36482 in favor of this PR. My branch claude/farm/81e56e11/fix-upgrade-corked-socket has a couple of additional test variants (bidirectional echo over the handed-off socket, and a bare writableCorked check without res.cork()) in case they're useful to fold in.

cirospaciari pushed a commit that referenced this pull request Sep 11, 2026
…olicited data (#42128)

### Problem

- A socket parked in `agent.freeSockets` (`keepAlive: true`) keeps
reading while idle, and unsolicited bytes do not retire it. A whole
response written there is attributed to the next request.
- The cause is the Agent's `'free'` handler
(`src/js/node/_http_agent.ts:123`). It pools the socket with nothing
watching the read side, where the parser is already detached. Node fixed
the same hole as CVE-2026-48931 (https://hackerone.com/reports/3582376).
This ports that one fix only.

### Fix

- The Agent marks a pooled socket with `kDestroyOnRead`, and
`reuseSocket` clears it. A freed socket that already holds buffered
readable data is destroyed before it can be pooled or handed to a queued
request.
- In `node:net`, `pushDataToSocket` (the one function through which all
three handler tables feed the stream, from #35347) destroys a marked
socket instead of pushing the bytes.
- Like node's guard, it adds no public stream listener (Notes).
- Verified: `test/js/node/http/node-http-agent-free-socket.test.ts`, 8
`node:test` cases that Bun runs in-process, plus one that runs the same
file under Node.js. 6 fail on released bun. On Node they pass from
v26.4.0, where the upstream fix shipped (Notes). Also
`test/js/node/http/`, `test/js/node/net/`, and the vendored http, https,
net, tls suites. Self-reviewed: 4 concerns, 3 addressed, 1 declined
(Notes, "Scope").

### Background

- `Agent.freeSockets` holds idle keep-alive sockets per origin.
`addRequest` takes one out and calls `reuseSocket`.
- A pooled socket has no parser and no `'data'` listener: what it
receives goes nowhere, or reaches the next response's parser.
- A bun `net.Socket` reads through a native handler table passed at dial
time. One table serves every socket that used it, so a per-socket hook
must live on the socket.

<details><summary>Notes</summary>

**Reproduction** (bun 1.4.3, linux x64). A raw origin answers each
request with its own path, then writes a complete unsolicited response
on the idle pooled connection.

| when the stray response arrives | stock bun | this branch |
| --- | --- | --- |
| the event loop polls before the next request | socket stays pooled,
bytes discarded | socket destroyed, next request dials a fresh
connection |
| the next request is issued in the same tick | next request reads
`poison` | next request reads `poison` |

Node v26.3.0 (before the guard shipped) behaves like stock bun in both
rows.

**Node versions.** The upstream fix first shipped in Node v26.4.0
(`lib/_http_agent.js` at the v26.3.0 tag has no
`installFreeSocketDataGuard`). Forced to run everywhere: Node v26.3.0
passes 2 of 8 (the reuse cases) and fails the 6 guard cases, like stock
Bun; Node v26.4.0 passes 6 and fails the 2 queued-request cases (Node
checks buffered bytes only on the pool path); this branch passes 8. In
the file the guard cases skip on Node < 26.4.0 and the queued-request
case skips on Node, each with the reason.

**No public listener.** Node's first version of this guard used a
`'data'` listener plus `resume()`. node-fetch@2 reads
`socket.listenerCount('data')` while a response closes and started
reporting false `ERR_STREAM_PREMATURE_CLOSE` errors (nodejs/node#63989),
so node reworked the guard onto the stream handle's internal `onread`
hook. Bun has no per-socket equivalent of that hook: the handler table
given to `Bun.connect` is one shared cell, and `socket.reload()` mutates
it for every socket that shares it. Hence the per-socket flag, read in
`pushDataToSocket`. The test asserts `listenerCount('data')` and
`listenerCount('readable')` are still 0 on a free socket, as node's
does. The handler table built for the `onread` socket option keeps its
own `data` callback: it bypasses the stream, and `node:http` cannot use
such a socket.

**The same-tick row is the residual race, and node has it too.** The
stray bytes are still unread in the kernel when `addRequest` hands the
socket out, so no check in JS can see them. Node's own test says as much
("in a real attack, there is always time between the poison arriving and
the next client request"). Closing it needs a peek of the read side at
checkout, which is what #41987 does for `fetch()`'s pool in Rust.
Unrelated to this change: when bytes that win that race are not a valid
response, the client's parse error surfaces as an uncaught exception
instead of an `'error'` event on the request, on stock bun and on this
branch alike.

**The upstream test is not vendored yet.**
`test/parallel/test-http-agent-free-socket-data-guard.js` injects the
stray bytes with `req.socket.write()` on a `node:http` server. Bun's
server leaves that socket corked after the request
(`socket.writableCorked === 1`, the bytes sit in the writable buffer),
which is #35664. Once that lands, the upstream file can be vendored as
is and the bespoke cases trimmed. The new cases drive a raw `net`/`tls`
server instead, which is also how they cover `https.Agent`.

**Scope.** CVE-2026-48931 shipped in a Node security release together
with other advisories. This PR does not examine or claim anything about
the others. The self-review asked for a public tracking issue that lists
them against bun; I left that to the maintainers, since it amounts to
publishing an unverified vulnerability list.

**Already-buffered bytes.** Node's `installFreeSocketDataGuard` destroys
a socket whose `readableLength > 0`, but the caller still pushes it into
`freeSockets`, where `'close'` prunes it a tick later (and `addRequest`
can pop it first under `lifo`), and the check does not run at all when
the freed socket goes straight to a request queued in `agent.requests`.
Here the check runs right after the `writable` check in the `'free'`
handler, so both hand-off paths share it and a destroyed socket is never
pooled. For the queued path, the destroyed socket's `'close'` reaches
`removeSocket`, which dials a new connection for the waiting request.
The "holds unsolicited data" cases cover both paths: they `push()` the
stray bytes in the response's `'end'` handler, which is after the parser
detached and one tick before `'free'`.

**Disarm point.** `reuseSocket` clears the flag, as in Node, where
`Agent.prototype.reuseSocket` is the only caller of
`removeFreeSocketDataGuard` and nothing else restores `_handle.onread`
(`initSocketHandle` runs only for a new or reconnected socket). A
subclass that replaces `reuseSocket` without calling the parent loses
reused sockets on both runtimes.

**TLS.** The guard sees decrypted application data only, so a
post-handshake `NewSessionTicket` on an idle pooled socket does not trip
it. The `https` cases cover a parked TLS socket that is poisoned, one
freed with buffered bytes (pooled and queued paths), and one that is
reused.

**Cost.** One symbol-property load per received chunk. The `src/` diff
is 28 lines. `kDestroyOnRead` is initialized in the `Socket` constructor
so the read path stays monomorphic.

**Pre-existing failures in this container, with and without this diff**
(each one rechecked against main's `src/js` on the same build):
`test-http-agent-keepalive.js` (`agent.sockets[name]` is not cleaned up
after the server closes the socket),
`test-http-client-timeout-option.js`, the `test-http(s)-proxy-request*`
family, one subprocess case of `node-http-syscall-fault.test.ts`, 10
`node-net.test.ts` cases that fail here with `ECONNREFUSED`,
`test-net-server-async-dispose.mjs`,
`test-net-connect-custom-lookup-non-string-address.mjs`,
`test-tls-client-allow-partial-trust-chain.js`.

</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 2 · 4 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/http/node-http-agent-free-socket.test.ts
bun test v1.4.3 (4ff9193)

test/js/node/http/node-http-agent-free-socket.test.ts:
157 |         assert.strictEqual(freeSocket.listenerCount("readable"), 0);
158 | 
159 |         serverSockets[0].write(poisonedResponse);
160 | 
161 |         await pollUntil(() => freeSocket.destroyed && agent.freeSockets[name] === undefined);
162 |         assert.strictEqual(freeSocket.destroyed, true);
                     ^
AssertionError: Expected values to be strictly equal:

false !== true

 generatedMessage: true,
     actual: false,
   expected: true,
   operator: "strictEqual",
       diff: "simple",
       code: "ERR_ASSERTION"

      at /workspace/bun/test/js/node/http/node-http-agent-free-socket.test.ts:162:16
      at withAgent (/workspace/bun/test/js/node/http/node-http-agent-free-socket.test.ts:118:11)
      at /workspace/bun/test/js/node/http/node-http-agent-free-socket.test.ts:150:13
      at node:test:1781:26
      at executeTestNode (node:test:1785:63)
      at processTicksAndRejec
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (9f655b7)

test/js/node/http/node-http-agent-free-socket.test.ts:
(pass) http.Agent free keep-alive socket over http > destroys a free socket that receives unsolicited data [22.64ms]
(pass) http.Agent free keep-alive socket over http > does not pool a socket that holds unsolicited data when it is freed [3.12ms]
(pass) http.Agent free keep-alive socket over http > does not hand a freed socket that holds unsolicited data to a queued request [2.17ms]
(pass) http.Agent free keep-alive socket over http > reuses a free socket that received nothing [1.64ms]
(pass) http.Agent free keep-alive socket over https > destroys a free socket that receives unsolicited data [41.32ms]
(pass) http.Agent free keep-alive socket over https > does not pool a socket that holds unsolicited data when it is freed [5.10ms]
(pass) http.Agent free keep-alive socket over https > does not hand a freed socket that holds unsolicited data to a queued request [4.67ms]
(pass) http.Agent free keep-alive socket over https > reuses a free socket that received nothing [3.29ms]
(pass) Node.js compatibility > all tests pass in Node.js [156.92ms]

 9 pass
 0 fail
Ran 9 tests across 1
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/http/node-http-agent-free-socket.test.ts
bun test v1.4.3 (4ff9193)

test/js/node/http/node-http-agent-free-socket.test.ts:
(pass) http.Agent free keep-alive socket over http > destroys a free socket that receives unsolicited data [1158.89ms]
(pass) http.Agent free keep-alive socket over http > does not pool a socket that holds unsolicited data when it is freed [172.89ms]
(pass) http.Agent free keep-alive socket over http > does not hand a freed socket that holds unsolicited data to a queued request [115.53ms]
(pass) http.Agent free keep-alive socket over http > reuses a free socket that received nothing [100.60ms]
(pass) http.Agent free keep-alive socket over https > destroys a free socket that receives unsolicited data [474.62ms]
(pass) http.Agent free keep-alive socket over https > does not pool a socket that holds unsolicited data when it is freed [200.09ms]
(pass) http.Agent free keep-alive socket over https > does not hand a freed socket that holds unsolicited data to a queued request [147.77ms]
(pass) http.Agent fre
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     400b9a9
  features     baseline

23 deps, 131 codegen, 1172 objects in 697ms

ninja: Entering directory `/workspace/bun/build/release'
[1/146] fetch picohttpparser
[picohttpparser] up to date
[2/146] fetch WebKit (prebuilt)
[WebKit] up to date
[3/146] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts

... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/js/internal/net/symbols.ts                     |   3 +
 src/js/node/_http_agent.ts                         |  12 +
 src/js/node/net.ts                                 |  14 +-
 .../node/http/node-http-agent-free-socket.test.ts  | 251 +++++++++++++++++++++
 4 files changed, 279 insertions(+), 1 deletion(-)
```

</details>

**gate history** · 3 passed · 2 rejected · iteration 2

<details><summary>evidence per changed file</summary>

```
file                                                   reads  edits  tests
src/js/internal/net/symbols.ts                             3      4      6
src/js/node/_http_agent.ts                                 5     10      8
src/js/node/net.ts                                        13     15      7
test/js/node/http/node-http-agent-free-socket.test.ts      0      0      2
```

</details>

<!-- robobun:evidence:end -->
@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

This PR also fixes a second way to hit the same leaked cork: a 'clientError' listener that answers with socket.write() after the server accepted a request head. I did not open a second PR for it.

Each case below has a listener that calls socket.write("HTTP/1.1 400 ...") and keeps the socket open. The table shows the status lines that the client receives.

case Node v26.3.0 main (c6b7fcb) main + this PR's _http_server.ts diff
parse error in the head (HTTP/9.9) 400 400 400
parse error in the body (bad chunk size) 400 nothing 400
requestTimeout while the body stalls 400 nothing 400
kept-alive: one served request, then a bad head 200, 400 200 200, 400

On main the listener sees socket.writableCorked === 1, write() returns true, and the bytes stay in socket.writableLength. The existing 'clientError' tests do not catch this because their listeners call socket.end(), and Writable.end() force-uncorks.

Checked on a debug build of main with this PR's _http_server.ts diff (it still applies cleanly):

  • test/js/node/http/node-http.test.ts: 165 pass, 1 skip, 0 fail
  • node-http-connect.test.ts, node-http-with-ws.test.ts, node-http-server-timeouts.test.ts: all pass

A test for this door is on branch robobun/5fa063b1/clienterror-write-after-dispatch, commit 3228809 (test only, 3 cases in node-http.test.ts). It times out on main, passes with this PR's diff, and passes on Node.js v26.3.0. git cherry-pick 32288091e6 applies cleanly on top of this branch.

Related: #42610 releases the same dispatcher cork at the CONNECT/Upgrade handoff only. I applied its _http_server.ts diff to main and ran the cases above: the body, timeout and kept-alive rows still receive nothing, because the cork is still in place when the 'clientError' listener runs.

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

This PR also fixes #43342: on a keep-alive connection that already served a request, an 'upgrade' listener's socket.write() of the 101 stays buffered until end(). The script in that issue reproduces it on main at 367d939. The fix is the same stray socket.cork() in onNodeHTTPRequest.

@robobun

robobun commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

Status of this PR against main at 36cd151:

What remains for this PR is the change to ServerResponse.prototype.end and its test.

Bewinxed added a commit to Bewinxed/whiffle that referenced this pull request Sep 28, 2026
… connection

iOS Safari sends its websocket handshake over a connection that has already
loaded the page's assets. Bun 1.4.0's node:http corks a connection's socket
after every request it dispatches and never uncorks it (oven-sh/bun#35664,
open), so the socket serve.js is handed in 'upgrade' is still corked: the
hub's 101 sat in its write buffer and never reached the phone. The dashboard
socket stayed CONNECTING for good, refresh() never ran, and the board never
filled in. Chromium and Playwright WebKit open a fresh connection for every
websocket, so neither showed it.

The upgrade handler uncorks the socket before it writes or pipes anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

puppeteer with proxy doesn't work

1 participant