Skip to content

node:http: a 'connect' or 'upgrade' socket keeps its unread bytes when the connection closes - #46067

Open
robobun wants to merge 1 commit into
mainfrom
robobun/39f9d4b2/tunnel-keeps-unread-data
Open

robobun wants to merge 1 commit into
mainfrom
robobun/39f9d4b2/tunnel-keeps-unread-data

Conversation

@robobun

@robobun robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A 'connect' or body-less 'upgrade' listener that ends the socket and reads late loses data. At the client's FIN the socket emits 'close' with no 'end' (for await throws ERR_STREAM_PREMATURE_CLOSE). Node.js v26.3.0 delivers every byte, then 'end', then 'close'.
  • usockets closes a connection after both FINs. NodeHTTPServerSocket#onClose (src/js/node/_http_server.ts) answered with destroy().

Fix

  • After the peer's FIN the socket waits for its reader, as node:net does, and closes after 'end' and 'finish'. The FIN no longer resumes a paused socket.
  • usockets records the peer's FIN (us_socket_read_fin), and uWS ends a tunnel's stream only for it. A reset closes the socket as on main.
  • The server stops tracking the socket when its connection closes, so server.close() does not wait for a waiting socket.
  • Verified: node-http-connect.node.mts (35 tests, also run by Node) and node-http-connect.test.ts (44). 46 fail on main. Self-reviewed: 47 concerns raised, 42 addressed.

Background

  • Node hands out the net.Socket for a complete request, and otherwise an UpgradeStream, which the close destroys. In Bun an Upgrade with a body keeps that behaviour.
  • libuv keeps the fd at EOF. usockets closes it, so the JavaScript class keeps the bytes. Keeping the fd in usockets adds a branch to every socket's EOF path.

Downsides

  • A socket that nobody reads emits no 'close' when its connection closes, as in Node. It has no fd, and it is collected when its owner drops it.
  • An ordinary connection runs 8 to 12 more bytecode instructions, no new allocation. The builtin grows 1533 bytes, .text 0.
Notes

Repro (Linux x64, idle machine, Node.js v26.3.0, debug builds of main and of this branch). The first test of describe("a tunnel socket whose connection closes before it is read") in node-http-connect.node.mts is the same flow with no timers.

const http = require("node:http"), net = require("node:net");
const server = http.createServer();
server.on("connect", (req, socket) => {
  const events = [];
  socket.on("end", () => events.push("end")).on("close", () => events.push("close"));
  socket.end("HTTP/1.1 200 Connection Established\r\n\r\n");
  setTimeout(() => {
    let received = 0;
    events.push("read-start");
    socket.on("data", chunk => (received += chunk.length));
    setTimeout(() => (console.log(JSON.stringify({ received, events })), process.exit(0)), 200);
  }, 200);
});
server.listen(0, () => {
  const client = net.connect({ port: server.address().port, allowHalfOpen: true });
  client.write("CONNECT x:1 HTTP/1.1\r\nHost: x:1\r\n\r\n");
  client.resume().on("end", () => client.end(Buffer.alloc(16384, 1)));
});
output
Node.js v26.3.0 {"received":16384,"events":["read-start","end","close"]}
main {"received":0,"events":["close","read-start"]}
this branch {"received":16384,"events":["read-start","end","close"]}

Rows (the same scripts under each runtime)

Scenario Node.js v26.3.0 main this branch
Listener ends, peer sends 16 KiB and FIN, late reader 16384 bytes, end, close 0 bytes, close as Node
Peer's FIN on a paused socket stays paused resumed as Node
destroySoon(), peer silent finish, close finish, end, close as Node
Peer reset on a live tunnel, reader keeps up error ECONNRESET, close end, close as main
Two pipe() calls and an 'error' listener that ends the other leg (http-proxy's WebSocket pass), 8 client resets: upstream legs left open 0 0 0
Listener ends and never reads, peer sends 1 byte and FIN kept, getConnections() 1, server.close(cb) waits closed kept, getConnections() 0, server.close(cb) calls back
CONNECT proxy of the Node.js docs, 20 tunnels, origin closes first, client answers 24 bytes and FIN 20 kept, server.close(cb) waits 0 kept 20 kept until they are collected, not counted, server.close(cb) calls back
1,000 such sockets that the listener dropped 1,000 kept with their fds none not counted, no fd, heap as on main (4 MB)
Upgrade whose 10-byte body came with its head, late reader 16384 bytes, end, close 0 bytes, close as main

Server accounting. Node counts a socket until it closes, because the socket keeps its handle, and server.close(cb) waits for it. So in Node a socket whose peer sent bytes that nobody reads is a connection for ever. The two-pipe() proxy of the Node.js docs makes such sockets: unpipe() leaves the tunnel paused, and the client's TLS close_notify arrives behind it. Here the connection of such a socket has closed, so the server stops tracking it: getConnections(), maxConnections and server.close(cb) do what they do on main. The first revision of this PR counted these sockets as Node does. A listener that ends a socket and never reads it then left a count that nothing could release (#45095 is a report of that kind), so the count is gone.

Still different from Node.js

Measurements (main 4354b80d7b against this branch, before the rebase and before the server accounting changed, except where a line says otherwise)

  • Tunnel with a reader that keeps up, 5 scenarios for 'connect' and 'upgrade': JS frames -5 (-6 with a 'readable' listener), tick entries +0, native calls -2 (entry counters on every builtin function, debug build).
  • Ordinary connection close, 3 keep-alive requests, aborted response: frames +0, tick entries +0, native calls +0.
  • Bytecode instructions on an ordinary connection: constructor +2, _destroy +6, #onClose +0 to +4, aborted response +4. No allocation.
  • Heap per open tunnel (2,000 tunnels, heapStats): the same objects on both (10 Function, 1 Promise, 11 Object, 6 Array, 3 JSLexicalEnvironment, the socket and the response).
  • Syscalls over 500 exchanges, with a late reader and with a reader that keeps up: the same on both (epoll_ctl ADD 2002, MOD 3501, DEL 2001, close 2001, shutdown 2000, recv 4000, send 2500, from an LD_PRELOAD counter).
  • A socket that waits with 16 KiB unread, 1,000 of them: no fd. A listener that keeps the socket keeps 20.6 KB of heap for it (https: 29.1 KB). On main the same listener keeps 19.8 KB for the destroyed socket. A listener that drops the socket keeps nothing (heap as on main).
  • sizeof(struct us_socket_t): 80 bytes on both (the bit uses a spare bit).
  • Release build: _http_server builtin +1533 bytes (99002 to 100535, from bundle-modules.ts --debug=OFF). .text +0 (81369941) and .rodata +0 (size -A, measured before the server accounting changed; the native part is the same). The four HttpContext::onClose instantiations keep their sizes, and us_socket_read_fin is inlined.

Suites run on the debug build: test/js/node/http/ (39 files, 1270 tests) and test/js/first_party/ws/ (21 files, 3189 tests) on this revision. Before the server accounting changed, also 845 files of test/js/node/test/parallel (test-http*, test-https*, test-http2*, and every connect, upgrade, proxy and tunnel test). The 4 of them that fail also fail on main.

Self-review: 47 concerns, 42 addressed. Not taken: Node's 'error' for a reset on a tunnel (4 concerns, reason above). Not possible in this tree: the req.complete key (1, #45058). Not run: Windows and macOS. The tests size their payloads from readableHighWaterMark, and each AF_UNIX row has a TCP twin.

Neighbours: #45058 (Upgrade with a whole body), #44885 (parked sockets above the mark), #45257 (order of the server's 'close'), #39889 (write callbacks of a dead connection), #40897 (speed of this test file), #43708 and #43557 (the code this builds on). #44034 reported this loss and was closed as not planned, because no user had reported it.


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/node/http/node-http-connect.test.ts

@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:08 AM PT - Oct 22nd, 2026

❌ @robobun, your commit 147965f has 1 failures in Build #130899 (All Failures):

  • step failed outside runner - exit 1 on ⚙️

🧪   To try this PR locally:

bunx bun-pr 46067

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

bun-46067 --bun

@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on main with the script in the Notes of the description. Node.js v26.3.0 prints {"received":16384,"events":["read-start","end","close"]}. main prints {"received":0,"events":["close","read-start"]}. This branch prints Node's line.
  • The same flow with no timers is in test/js/node/http/node-http-connect.node.mts, which runs under Node.js and under Bun.
  • One decision is open for a maintainer. It is the first bullet of Downsides.

@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between 38afee6 and 147965f.

📒 Files selected for processing: 9
  • packages/bun-usockets/src/context.c
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/libusockets.h
  • packages/bun-usockets/src/loop.c
  • packages/bun-usockets/src/socket.c
  • packages/bun-uws/src/HttpContext.h
  • src/js/node/_http_server.ts
  • test/js/node/http/node-http-connect.node.mts
  • test/js/node/http/node-http-connect.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Walkthrough

The change records peer FINs in native socket state and exposes that state to HTTP close handling. Node HTTP handoff sockets now track connection and readable-stream closure separately. Tests cover buffered data and teardown behavior across CONNECT and upgrade tunnels.

Changes

HTTP handoff socket teardown

Layer / File(s) Summary
Record and expose peer FIN state
packages/bun-usockets/src/context.c, packages/bun-usockets/src/internal/internal.h, packages/bun-usockets/src/libusockets.h, packages/bun-usockets/src/loop.c, packages/bun-usockets/src/socket.c, packages/bun-uws/src/HttpContext.h
Native sockets initialize and record read_fin. The public query returns that state, and Node HTTP close handling uses it to gate the raw-socket EOF callback.
Track handed-off sockets through close
src/js/node/_http_server.ts
Handoff states distinguish raw sockets from upgrade streams. Close handling can preserve unread data, release connection tracking, and continue cleanup when the socket is later destroyed. Writes and resetAndDestroy() also handle the outlived-connection state.
Validate handoff and teardown cases
test/js/node/http/node-http-connect.node.mts, test/js/node/http/node-http-connect.test.ts
Tests cover buffered reads, FIN ordering, resets, TLS failures, writes, server connection counts, and cleanup for CONNECT and upgrade tunnels.

Possibly related issues

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 14796

The change keeps unread bytes on CONNECT and upgrade sockets after the peer closes, and it handles resets as before. No concrete merge-blocking issue was found. CI on the tests that were not run locally should still pass before merge.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the primary change: preserving unread bytes for node:http CONNECT and upgrade sockets after connection closure.
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.
Description check Passed The description is complete and relevant. It explains the problem, implementation, behavior changes, trade-offs, known differences, and verification results. It does not use the exact template heading…
Full details: Description check

Explanation

The description is complete and relevant. It explains the problem, implementation, behavior changes, trade-offs, known differences, and verification results. It does not use the exact template headings, but it provides the required information in equivalent sections.

✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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 findings, I also checked the native side of this change: the new read_fin bit is initialized at every socket-init site (listen and connect init in context.c, the adopt path in loop.c, us_socket_from_fd in socket.c), is set only when a read returns 0, and the new gate in HttpContext::onClose is IsNodeHttp-only, so Bun.serve / uWS CONNECT tunnels keep the unconditional end-of-stream dispatch they had before.

Extended reasoning...

The PR adds a one-bit peer-FIN record to usockets, gates uWS's tunnel end-of-stream dispatch on it for node:http only, and reworks NodeHTTPServerSocket close handling in src/js/node/_http_server.ts so a handed-off 'connect'/'upgrade' socket with unread bytes outlives its native connection and is counted separately by the server. It touches no auth, crypto, or input-parsing surface. The inline findings cover the user-visible behavior change (sockets nobody reads now keep server.close and connection counts pending) that the author explicitly flags as needing a maintainer decision, so a human must weigh that design choice; this note only records that the small native portion is consistent and scoped.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/js/node/_http_server.ts Resolved
…n the connection closes

The socket that a 'connect' listener gets, and the socket of an 'upgrade'
listener whose request has no body, is the connection itself as a
net.Socket. usockets closes a connection when both sides have sent their
FIN, and NodeHTTPServerSocket answered that close with destroy(). The bytes
that its reader had not taken were dropped, and there was no 'end'.

Like Node's net.Socket, the socket closes after its own 'end' and
'finish'. After the peer's FIN it waits for its reader, or for its owner's
destroy(). The end of the stream is push(null) and read(0), which leaves a
paused socket paused.

usockets records the peer's FIN on the socket (us_socket_read_fin), and uWS
reports the end of a tunnel's stream only for it. A connection that closes
with no FIN of the peer (a reset, a failed TLS session) closes the socket
as before.

The server tracks connections. It stops tracking the socket when its
connection closes, so a socket that waits is not counted, server.close()
does not wait for it, and a socket that its owner dropped is collected.
@robobun
robobun force-pushed the robobun/39f9d4b2/tunnel-keeps-unread-data branch from 4796e20 to 147965f Compare October 22, 2026 12:08
Comment on lines +92 to +96
// Its values. Node hands its listener the net.Socket itself when the request is complete at the emit, and otherwise an
// UpgradeStream, which the close of the connection destroys:
// https://github.com/nodejs/node/blob/v26.3.0/lib/_http_server.js#L1027-L1050
// https://github.com/nodejs/node/blob/v26.3.0/lib/_http_server.js#L1110-L1143
// Bun emits 'upgrade' before it parses the body, so an Upgrade request with a body is never complete there.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +99 to +100
// #closeHandled of a kHandedOffSocket that has the end of its stream. Like Node's net.Socket, it closes after its own
// 'end' and 'finish', so the close of the connection leaves it the bytes that its reader has not taken.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1862 to +1864
// Whether a handed-off socket whose connection closed with no FIN of the peer gets 'end' when its reader has
// taken every byte, as it always has. That is the close after a reset of the peer, and after a failed TLS
// session. Node emits 'error' there. pipe() needs the 'end' to end its destination.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1876 to +1878
// The peer's FIN ended the stream of a net.Socket, and its reader has not taken all of it. Like Node's, the
// socket closes after its own 'end' and 'finish': _destroy() runs the rest of this function. A write that
// waits for a drain has failed, and destroySoon() asked for the close, so neither waits for the reader.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1884 to +1885
// So did the server's part: it tracks connections, and keeps nothing for a socket that its owner can drop.
// Node, whose socket keeps its handle, counts the socket until it closes, and server.close() waits for it.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +2303 to +2305
// The connection has closed, and the socket waits for its reader. The write fails like one that waited for a
// drain at the close: ERR_STREAM_DESTROYED to its callback, and no 'error'. Node fails it with EPIPE, and
// emits 'error'.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the 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.

This automated review ran on the new push and found no bugs; the points raised on the previous version are addressed in the code (a waiting socket is now dropped from kTrackedConnections in #onClose, with tests asserting getConnections() 0 and a completing server.close(cb), and the 120 s per-test timeouts are gone). Because this is a +1312-line change to socket lifecycle across usockets, uWS and node:http with a deliberate behavioral divergence from Node that the description itself asks a maintainer to decide, a human look is still worthwhile.

What was reviewed:

  • read_fin is zeroed at all four us_socket_t init sites (listen/connect in context.c, adopt in loop.c, us_socket_from_fd in socket.c) and set only on a 0-byte read; the uWS gate is IsNodeHttp-only, so Bun.serve CONNECT paths are unchanged.
  • The #onClose tri-state: the kOutlivesConnection branch only runs when not destroyed/ended/destroySoon'd/pending-write, nulls kHandle before returning, and _destroy re-enters with null after destroyed is set, so the full close sequence runs once; a 'readable' listener destroying synchronously inside push(null) lands in the same path.
  • One thing for the human reviewer: the PR description ("the server counts a waiting socket", table rows "getConnections() 1, server.close(cb) waits — as Node") no longer matches the code or the new test at test/js/node/http/node-http-connect.test.ts:2511, which asserts the opposite; the description should be updated to the behavior that lands.
Extended reasoning...

The commit adds a read_fin bit to usockets and exports us_socket_read_fin, gates uWS's end-of-stream dispatch for node:http tunnels on it, and reworks NodeHTTPServerSocket's close handling so a 'connect'/body-less 'upgrade' socket survives the native close until its reader drains it. It touches no auth, crypto or input-parsing surface beyond the existing TLS close path, where a failed session no longer reports stream end. Both inline findings from the prior run (permanent connection count, test timeouts) are addressed by this push with dedicated tests. Deferral rather than approval rests on the size and complexity of the lifecycle change, the native/uWS surface, and the explicit maintainer decision the author requests on keeping unread sockets alive outside server tracking.

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.

1 participant