Repository navigation
Conversation
A stream counts its frames as sent when they are serialized. On a graceful close the session was destroyed while part of a body was still in the parser's write buffer: detach() freed it, and the socket's FIN followed the bytes the kernel already had. parser.detach(true) keeps that output. The parser stays in the socket's native-callback slot, ignores inbound frames, writes the output from writable events and then lets go of the socket. A shutdown() of the socket in that time is held back, and the parser issues it after the last byte. The session 'close' event of a graceful close now follows the socket's 'close', as in node.
…the slot's own ref The match repeated the one in on_writable arm for arm (mordant same_match_twice). defer_shutdown runs no JS, so it does not need the pointer copied out first.
A peer sends its request, ends its side of the connection and keeps reading. net.Socket answers that FIN with end(). The session still holds frames that the socket has not taken, and the socket's FIN went out ahead of them. node:net now ends the write side of a native handle through a private helper. The helper asks the attached HTTP/2 parser first. The parser writes what the socket takes now. If output is still owed and the peer has ended its side, the parser keeps the FIN and the helper answers false. The parser sends the FIN from the writable event that finds nothing owed, and the socket then runs its drain dispatch. node:net parks the _final callback on `false`. It completes the callback from that drain, from the close of the handle, from destroy(), or when the handle dials again. After the peer's FIN a parked end holds the event loop, as a pending write does. The public Socket#shutdown() is unchanged.
Collaborator
Author
|
Status
|
Collaborator
Author
|
Updated 7:46 PM PT - Oct 21st, 2026
❌ @robobun, your commit aff161e has 2 failures in
🧪 To try this PR locally: bunx bun-pr 46380That installs a local version of the PR into your bun-46380 --bun |
robobun
changed the base branch from
robobun/953366a6/http2-graceful-close-pending-data
to
main
October 21, 2026 20:42
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft. The branch contains #45673: review commit
5f42abe2e05. Two questions for a maintainer open the Notes.Problem
endNT(src/js/node/net.ts:441) sends the socket's FIN while the session's parser still holds frames.Fix
shutdown()asks the attached parser (H2FrameParser::defer_shutdown). A parser that owes output to a peer that ended its side sends the FIN after that output.endNTreads that from a private helper,isShutdownHeld. It parks the_finalcallback until the drain that follows the FIN.test/js/node/http2/node-http2-graceful-close.test.ts(36 of 48 new cases fail on node:http2: keep the unsent frames on a graceful close and send the FIN after them #45673). Alsotest/js/node/{http2,net,tls}.Background
Socket#shutdown()keeps its type. The helper is a bindgen function, likereleaseHeldFlight.Downsides
net.Socketpays +57 instructions per end and +4 bytecodes per drain. The binary grows by 2,621 bytes.Notes
Questions for a maintainer. This PR asks for no review until #45673 has merged and these have an answer. They are also on #43877.
_finalof anet.Socketwait for a FIN that its handle holds? This PR adds a second parked callback to node:net (kshutdownCallback, next tokwriteCallback), completed from seven places. Without it the hold can stay native, with nonet.tschange. 'finish' then comes before the kernel has the bytes, and a socket whose peer has ended is destroyed at that 'finish'.socket.end(), at the cost of one check in every frame write and of the END_STREAM of a compat response.What each runtime sends. linux-x64 loopback, release builds, default kernel buffers. A raw peer opens 2^31-1 windows, sends
GET /, then a FIN, and reads.allis every byte of the body.stream.end(16 MiB)write(16 MiB); end(16 MiB)res.end(16 MiB)socket.end()a turn afterstream.end(16 MiB), no FIN from the peerstream.end(16 MiB): 0 of 100 runs cut over TCP, 0 of 30 over TLS.tls.connect({ socket })adopted). Unix socket: 219,104 before.Who sees the bug. No issue reports it. The trigger is a peer that ends its side with a stream open, has granted more window than one kernel write takes, and keeps reading. The tests use a raw-socket peer for that. A server can avoid the cut today:
http2.createServer({ allowHalfOpen: true })andsession.socket.once("end", () => session.setTimeout(300, () => session.close()))delivered the whole body on the #45673 head and on Bun 1.4.3. That is a timer, and it does not cover a client.Details of the fix.
Socket#shutdown()and its type do not change.NewSocket::shutdownalready asks the parser since node:http2: keep the unsent frames on a graceful close and send the FIN after them #45673. node:net then readsisShutdownHeld(handle), a bindgen function insocket_body.bind.tslikereleaseHeldFlightandgetBufferedAmount.defer_shutdownfirst writes what the socket takes now: the frames of the turn, the buffer, and DATA that is queued on a stream. Without that write a FIN could pass queued DATA:write(4 MiB); end(12 MiB)lost its tail in 9 of 300 two-process runs, and in 0 of 300 with it. A fault-injection case and an ASAN fixture pin this write and the keepalive around it.us_socket_read_ended). A session that gets its socket after the FIN is covered (server.emit("connection", socket)).session.close()ordestroy()while the FIN waits goes throughrelease_with_held_fin. A close keeps the FIN behind the output. A destroy sends it at once.end()call, and completes when the handle drains, closes, is destroyed or dials again.Http2Session::ClearOutgoingcompletes the writes with status 0.Differences from Node that remain. Each one is in a test comment.
session.close()for a peer that already ended its side: Bun emits 'end' before 'finish' over TCP and sends one GOAWAY. Node v26.3.0 emits 'finish' before 'end' and sends two.Not covered. This PR changes none of these.
session.destroy()in the turn ofstream.end(): 2,652,707 bytes, Node sends 16,777,216. A response that is written after the socket has asked for its FIN, for example by an async handler.socket.end()ordestroySoon()while the peer still has its side open: last row of the table, and anit.todoin the test file.end()on the raw half of a socket thattls.connect({ socket })adopted.Bun.serve({ http2: true })cuts the same peer at 2,587,190 bytes. Sessions on a JS Duplex transport do not pass through this code.allowHalfOpen: truewhose process exits with an upload pending.Order with the open PRs.
5f42abe2e05), and a merge of main. The base is main because CI does not build a branch without a recent main commit, and the branch of node:http2: keep the unsent frames on a graceful close and send the FIN after them #45673 has none. So the diff also shows node:http2: keep the unsent frames on a graceful close and send the FIN after them #45673 until that PR merges. node:http2: keep the unsent frames on a graceful close and send the FIN after them #45673 addsDraining,detach(keepOutput)andNativeCallbacks::defer_shutdown. This PR adds theFinstate.endNTandNewSocket::shutdownfor other sources of a held FIN. This PR leavesNewSocket::shutdownas node:http2: keep the unsent frames on a graceful close and send the FIN after them #45673 has it. InendNTkeep both rules: the parser is asked first._finalhere does the same for itself.Measurements. Release builds, main with #45673 against this PR. gdb single-step,
objdump,size, and anLD_PRELOADcounter. strace, perf, valgrind and bloaty are not installed in this environment..text: 107,351,210 to 107,353,831 bytes (+2,621). Bundled node:net: +1,341 bytes. One more native function object when node:net loads.net.Socketend with no parser:shutdown()takes 117 instructions before and after, and the newisShutdownHeld()call adds 57.mov, one alignmentnop). EOF event: no change.net.Socket: 41 own keys and 240 shallow bytes before and after.H2FrameParser: 1,496 bytes before and after.shutdown(SHUT_WR)in both, after 6,648,689 bytes before and after 16,786,493 bytes now (measured before the merge of main).Suites.
test/js/node/http2,net,tlsandtest/js/bun/neton a debug build of this branch, andtest/js/node/httpbefore the merge of main. The 660 vendored node teststest-(http2|net|tls|https)-*: 657 pass before and after, the same 3 fail.cargo checkfor windows-msvc and aarch64-apple-darwin. Windows and macOS were compiled and not run.Self-review. Two passes. The first asked for one shutdown contract, a cut-off for a peer that keeps sending, and the flush at the ask. The second raised 41 points.
shutdown(), the peer's EOF read from the socket, completion when the handle dials again, the async context, one-line comments, capped socket buffers and 1 MiB bodies in the tests, pinned values in place of loose matchers, TLS rows, two-stream cases, trailers, TLS 1.2, the fault-injection case and the ASAN fixture, and the claims about Node in this text.socket.end(), and the Node version to follow.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/http2/node-http2-graceful-close.test.ts