Repository navigation
node:quic: send headers queued before the stream is bound - #42869
Conversation
… is bound A stream created before the handshake completes queues its header block in pending_headers. bind_raw hands the block to lsquic_stream_send_headers, which only buffers it. The flush in drain_outbound runs from on_write, and bind_raw asked for a write event only when the JS side had already started the writer. Ask for one whenever a non-terminal header block was queued. Fixes #42866
|
Updated 10:09 PM PT - Sep 15th, 2026
✅ @robobun, your commit dad2a6a4e94f8d6e54f05070a0c6dae9f3c159c2 passed in 🧪 To try this PR locally: bunx bun-pr 42869That installs a local version of the PR into your bun-42869 --bun |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review. Walkthrough
ChangesQUIC queued header flushing
Suggested reviewers: Priority: ⬇️ Low Merge Risk: ⚪ Minimal · up to The queued-header flushing change has no identified unresolved defect and is ready to merge after normal checks. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, I also checked whether the new write event from bind_raw changes JS-visible behavior: the resulting drain_outbound run with an empty queue (clear write interest, flush, possible StreamDrain push since has_outbound is already set) is the same sequence the already-bound sendHeaders path produces via kick_write, so it is parity rather than new behavior. The restructured loop still sets wrote_to_lsquic and handles the EOS branch exactly as before.
Extended reasoning...
The diff is a small, focused change to QuicStream::bind_raw in /home/claude/bun/src/runtime/node/quic/stream.rs plus one new test. I traced the new want_write flag to its consumer (s.want_write(true) at line 268-270), then followed the resulting on_write into drain_outbound (lines 480-544): with an empty outbound queue it hits the empty branch, clears write interest, calls s.flush(), and may push StreamDrain because has_outbound was set to 1 when the headers were queued (line 946). That is the same sequence the bound-stream send_headers path already produces through kick_write (lines 950-964), so the candidate concern about a premature drain event is not introduced by this PR. The refactor of the send_headers == 0 && eos condition into nested ifs preserves the old EOS behavior and the unconditional wrote_to_lsquic.set(true); the non-zero return path remains a silent no-op, which is the inline finding already posted. The single inline finding means a human still needs to look, so this note only records what else was examined.
|
The inline finding about a rejected header block in bind_raw is pre-existing and out of scope here. I replied in the thread. No code change in this PR for it. |
Problem
node:quicclient that opens an HTTP/3 stream before the handshake completes and callssendHeaders(..)without a terminal flag does not send the request. The HEADERS frame stays in the lsquic stream buffer until the client writes tostream.writeror ends it. A client that waits for the server (for example for100 Continue) waits until the idle timeout.bind_rawinsrc/runtime/node/quic/stream.rs:235. It hands the queuedpending_headerstolsquic_stream_send_headers, which only buffers the block. The flush runs indrain_outbound, which runs from theon_writecallback.bind_rawasked for a write event only whenoutbound.startedwas already true, so noon_writeran.Fix
bind_rawnow asks for a write event (s.want_write(true)) when it queued at least one non-terminal header block, whatever the state of the writer.send_headerscallskick_write()afters.send_headers(..). A terminal block does not need it, becauses.shutdown(1)makes lsquic flush on its own.on_writerunsdrain_outboundwith an empty outbound queue. It clears the write interest and callss.flush(), so the headers leave. Latency aftersendHeadersis the same as for a stream created afterclient.opened(about 30 ms on a debug build).test/js/node/quic/quic-stream.test.ts(new test, fails on stock bun with the idle timeout close). Alsotest/js/node/quic/,test/js/bun/http/serve-http3.test.tsandtest/js/web/fetch/fetch-http3-client.test.ts.Background
QuicStreamwraps one lsquic stream.createBidirectionalStreambefore the handshake creates the JS object first, withrawnull. lsquic callson_new_streamlater, andbind_rawattaches the lsquic stream and replays what JS queued in the meantime (pending_headers).on_writecallback only while it has write interest (lsquic_stream_wantwrite).drain_outboundis theon_writehandler. It writes queued body bytes, then callslsquic_stream_flush.outbound.startedbecomes true when JS takesstream.writeror passes abody. That is why taking the writer before the handshake hid the bug.Notes
Timing probe against
Bun.serve({ http3: true })on a debug build, fromsendHeadersto the server handler: writer taken before the handshake 59 ms,await client.openedfirst 27 ms, neither (this fix) 38 ms.PR #42860 and #42867 change the
quic.cheader writers andon_stream_read. They do not touchbind_raw.