Conversation
…the stream id to frameError An empty DATA frame is only useful for carrying END_STREAM. The outbound path wrote one anyway whenever it had an empty payload, so a request or response ended while trailers were pending (END_STREAM deferred to the trailer HEADERS) and every user write of an empty chunk put a zero-length DATA frame with no flags on the wire. Node never sends those, and receivers (node's maxSessionInvalidFrames, Bun's own engine) count each one against the session's invalid-frame allowance. Both the direct send path and the queued flush path now skip the frame bytes in that case and only run the callback / wantTrailers bookkeeping. The stream-level 'frameError' event was emitted with (type, code) only; node passes the stream id as the third argument.
|
Warning Review limit reached
Next review available in: 10 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
Updated 8:41 PM PT - Aug 11th, 2026
❌ @robobun, your commit 59c579e has 2 failures in
🧪 To try this PR locally: bunx bun-pr 37712That installs a local version of the PR into your bun-37712 --bun |
|
Status Reproduced both divergences against node v26.3.0 with a raw frame-logging server: a Fix is in this PR: the native send and queued-flush paths only write an empty DATA frame when it carries END_STREAM, and |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The native change is small and I traced the bookkeeping on both paths, but since it alters HTTP/2 outbound wire framing a human look would still be worthwhile.
What was reviewed:
send_dataempty-payload branch: callback /onWantTrailers/ close-state dispatch in the!enqueuedtail still runs when nothing is written; the queued-behind-backpressure branch is unchanged.flush_queuezero-length branch:owned_frameis set before the early break sooutbound_queue_sizedecrement, write callback, andonWantTrailersstill fire from the defer block.emitFrameErrorEventNT: both call sites (server handler at ~4224, clientwithStreamFrameat ~5218) pass anHttp2Streamwhose.idis the stream id; matches Node's documented(type, code, id).
Extended reasoning...
Overview
Two Node-compat fixes on the node:http2 outbound path: (1) stop writing zero-length DATA frames that carry no END_STREAM (in both H2FrameParser::send_data and Stream::flush_queue in src/runtime/api/bun/h2_frame_parser.rs), and (2) pass stream.id as the third argument to the stream-level 'frameError' event in src/js/node/http2.ts. A comment on the server _final JS workaround is rewritten now that native handles the same case. Six new wire-level tests are added to test/js/node/http2/h2-conformance.test.ts.
Security risks
None. This strictly reduces what goes on the wire (a no-op frame is dropped) and adds a numeric argument to an event. No new parsing of untrusted input, no auth/crypto/permission surface.
Level of scrutiny
Moderate-to-high. The native diff is only ~25 lines and the logic is simple ("only write an empty DATA frame when it will carry END_STREAM"), but it sits on the HTTP/2 outbound framing path. I traced control flow through both modified functions to confirm the callback dispatch, onWantTrailers dispatch, outbound_queue_size decrement, and stream-state transitions are preserved on every branch (empty write with close=false, close=true + wait_for_trailers, and close=true without trailers), for both the direct-write and queued paths. The change is a strict subset of previous behavior — a frame that conveyed no information is no longer sent — so it cannot break a compliant peer, and the PR verified it against Node's own trailer test suite.
Other factors
The test coverage is unusually thorough: it exercises the direct send_data branch, the flush_queue branch (via a stream blocked behind an exhausted send window), the no-wantTrailers-listener path, plain empty writes interleaved with real payload, and both client- and server-side frameError on a non-default stream id. Tests follow the file's raw-server harness conventions, use Buffer.alloc(n, fill).toString() per repo guidance, and await observable wire events rather than sleeping. The PR description documents that all six fail on the unfixed binary and pass with the fix, and lists ten upstream Node parallel tests that continue to pass. No CODEOWNERS cover these paths and there are no outstanding review comments. I'm deferring only because outbound HTTP/2 framing is a critical enough path that a maintainer glance is warranted.
Problem
node:http2stream that callsend()with trailers pending, orwrite("")/write(Buffer.alloc(0)), puts a zero-length DATA frame with no flags on the wire. Node never sends one.maxSessionInvalidFrames, Bun's own inbound parser) count it against the session's invalid-frame allowance, so body-lesswaitForTrailersrequests slowly burn the peer's budget.'frameError'event was emitted as(type, code); node emits(type, code, id).Fix
wantTrailersdispatch still run. The queued path applies the same rule when it drains, so ordering behind earlier frames is unchanged.noTrailers()/sendTrailers({})still terminate with a singleDATA(END_STREAM), as node does.'frameError'now passes the stream id as its third argument, node's documented signature.frameErrorargs) fail on the unfixed binary and pass with the fix. The existing http2 suite and the 256 upstreamtest-http2-*files still pass; the manual repro against node v26.3.0 is in the original.Background
waitForTrailers: with this optionend()does not close the stream. The stream emitswantTrailers, and the trailer HEADERS frame written bysendTrailers()carries END_STREAM instead;sendTrailers({})falls back to an emptyDATA(END_STREAM).'frameError'is the per-stream event fired when a frame cannot be encoded (here, trailers larger than the max frame size); the stream is then reset withFRAME_SIZE_ERROR.Original description
What
Two node compat fixes on the
node:http2outbound path, both visible with awaitForTrailersrequest against a raw server that logs the frames it receives:req.end()with trailers pending (END_STREAM rides on the trailer HEADERS), and everywrite("")/write(Buffer.alloc(0)). Node never sends such a frame. An empty DATA frame without END_STREAM carries no information, and receivers count each one against the session's invalid-frame allowance (node'smaxSessionInvalidFrames; Bun's own inbound engine does the same inh2/connection.rs), so a Bun client doing body-lesswaitForTrailersPOSTs slowly burns the peer's budget.'frameError'event was emitted as(type, code). Node emits(type, code, id).Repro
Raw
netserver that sends SETTINGS after the preface, ACKs the client's SETTINGS and logs{type, flags, len}of every frame on stream 1:HEADERS(4)RST_STREAMHEADERS(4)DATA(flags=0,len=0)RST_STREAMframeErrorargs1 6 11 6 undefinedWith a small trailer the stray frame is the same: node sends
HEADERS(4)thenHEADERS(5), bun sentHEADERS(4)DATA(0,0)HEADERS(5); with nowantTrailerslistener node sendsHEADERS(4)DATA(flags=1,len=0), bun sentHEADERS(4)DATA(0,0)DATA(1,0).req.write("")on a plain POST also produced aDATA(0,0)per call. After this change every one of these flows produces the same frame sequence as node (the request flows that already carried a body were already identical and are unchanged).Cause
H2FrameParser::send_data("empty payload we still need to send a frame") wrote a DATA header for every empty payload and only decided the flags based onwait_for_trailers;Stream::flush_queuedid the same for a zero-length queued entry (the path taken when the session already has frames queued behind flow control or socket backpressure). The server-side_finalinhttp2.tshad grown a JS workaround for this; the client_finaland plain empty writes went straight to native.emitFrameErrorEventNTinhttp2.ts(shared by the client and server handlers) simply never passed the id.Fix
send_data: an empty payload is written only when it can carry END_STREAM. When it cannot (trailers pending, or a plain empty write) nothing goes on the wire; the write callback and thewantTrailersdispatch still run exactly as before. The queued branch is unchanged so ordering relative to frames ahead of it is preserved.flush_queue: a zero-length queued entry is written only when it ends up carrying END_STREAM; otherwise it is consumed for its callback /wantTrailersbookkeeping alone.emitFrameErrorEventNTpassesstream.id.Why this is right: END_STREAM is the only thing a zero-length DATA frame can convey, so a frame that will not carry it has no receiver-visible purpose, and both nghttp2 (observed above) and Bun's own receiver treat it as noise to be rate-limited.
noTrailers()/sendTrailers({})still terminate with a singleDATA(END_STREAM, len 0), as node does. TheframeErrorargument order is the documented node signature.Not changed here (separate, already tracked): the
ERR_HTTP2_STREAM_CANCELvsERR_HTTP2_STREAM_ERRORcode on this path (#36389), and a clientendStream+waitForTrailersrequest, where node puts END_STREAM on the request HEADERS and never asks for trailers.Verification
New
outbound empty DATA frames and trailer encode failuresblock intest/js/node/http2/h2-conformance.test.ts, all wire-level against the file's raw server:end()with trailers pending: stream frames are exactlyHEADERS, trailerHEADERS(END_STREAM)wantTrailerslistener: exactlyHEADERS,DATA(END_STREAM, len 0)write(""),write(Buffer.alloc(0)),write("payload"),write(""),end(): exactlyHEADERS,DATA(7),DATA(END_STREAM, 0)end()goes through the outbound queue; after a connection WINDOW_UPDATE stream 3 shows exactlyHEADERS, trailerHEADERS(END_STREAM)(this is theflush_queuepath; the directsend_databranch is not involved while another stream's data is queued)frameErrorargs are[HEADERS, FRAME_SIZE_ERROR, 3]and the wire isHEADERS,RST_STREAM(FRAME_SIZE_ERROR)with no DATAframeErrorargs are[HEADERS, FRAME_SIZE_ERROR, 3]All six fail on the unfixed binary (the extra
{type: 0, flags: 0, length: 0}entries / two-elementframeErrorargs) and pass with the fix.bun bd test test/js/node/http2/otherwise passes apart from two tests that only time out under the debug build's 5 s default when the whole directory runs (node-http2-streams-rehashforEachStream, and the TLS-over-Duplex "tail" case innode-http2.test.js); both pass when run on their own and neither touches the changed paths. All 256 upstreamtest-http2-*files intest/js/node/test/parallelexit 0 with the fix (includingtest-http2-write-empty-string,test-http2-zero-length-write,test-http2-write-callbacks,test-http2-trailers,test-http2-exceeds-server-trailer-sizeand the compat trailer tests);test-http2-forget-closed-streamsneeds about 1.5 minutes under the debug build but passes.