Skip to content

node:http2: submit a request's frames after request() returns - #42353

Open
robobun wants to merge 3 commits into
mainfrom
robobun/b4e23d84/http2-defer-request-submit
Open

robobun wants to merge 3 commits into
mainfrom
robobun/b4e23d84/http2-defer-request-submit

Conversation

@robobun

@robobun robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • session.request() on a connected node:http2 client writes HEADERS while it is on the stack. Over a transport that delivers synchronously (an in-memory Duplex from createConnection), the response comes back inside request(), so 'response' fires before a listener exists.
  • Cause: Http2Stream._final calls native.flush(), and request() runs req.end() before it returns. Node schedules every session write (MaybeScheduleWrite).
  • Those calls hide two ways a corked frame is never flushed.

Fix

  • Remove both native.flush() calls. Frames leave at the microtask checkpoint through the session's auto-flush entry, where node writes too.
  • drain_microtasks_with_global: after the deferred queue runs, drain pending microtasks again. If a deferred task was posted too late for the pass, a yield task gives the loop one more checkpoint before it can exit.
  • H2FrameParser::on_auto_flush: a tick that finds a write-fatal latch the transport recovered from clears it and flushes. It used to return, and the frames corked for that tick stayed corked.
  • Verified: node-http2.test.js (four new, main fails the first), node-http2-syscall-fault.test.ts (two new, main fails one), all 278 node test-http2-* tests.

Background

  • A session serializes frames into a cork buffer and registers an entry in the DeferredTaskQueue, which runs once per microtask checkpoint, after the drain.
  • is_event_loop_alive() does not count pending microtasks or deferred entries, and an in-process pair owns no handle.
  • A yield task runs on the next loop iteration and counts toward liveness.
  • The write-fatal latch marks a send the kernel rejected. A deferred tick closes the transport, or clears the latch when a later send went through.
Notes

The latch tick, and the darwin lanes. The first CI run of this PR failed h2-conformance.test.ts and h2-push-refusal-staged.test.ts on both darwin lanes, every attempt, with the tape preface -> recv:SETTINGS#0 and no client HEADERS. At that base the session still wrote its preface to a connecting socket. macOS fails that send() with ENOTCONN (Linux says EAGAIN), us_socket_write_check_error classifies ENOTCONN as peer-gone, and the parser latched transport_write_fatal. The next auto-flush tick, the one the request's HEADERS were corked for, cleared the latch and returned without flushing. The _final flush hid this. #40048 (now in the base) stopped the connect-time write, so darwin no longer latches there, but the tick still must not be spent. Reproduced on Linux with socketFaultInjection (one ENOTCONN on the first send after connect): main strands the HEADERS of a request that stays open, this PR delivers them.

Repro. Three sequential requests on one session over an in-memory Duplex pair that delivers synchronously, against a hand-written peer that answers every HEADERS frame with :status 200 and ok. bun 1.4.3: the peer sees the HEADERS for streams 3 and 5 while session.request() is on the stack, and those two requests get 'close' with the body but no 'response'. node v26.3.0 and this PR: on the stack: false and 'response' 200 for all three. Over a kernel socket nothing can be delivered inside request(), so http2.connect(url) never showed the symptom.

Why the event-loop change is in this PR. Removing only the two flush() calls regresses cases that pass today (the "flushes removed only" column below): the frames are written, but the process exits before the client stream reports them. The flushes were a workaround for that strand, and the strand is reachable on main without them (the 112 failing cases in the second matrix).

Why the fix is in the checkpoint and not in the http2 parser. The DeferredTaskQueue already documents that a callback can reach user JS. Every other user of the queue avoids resolving promises inside its callback by posting a task (FlushPendingFileSinkTask, StreamPending), which is the same constraint worked around per call site. With the drain and the yield task in the checkpoint, the queue's contract holds for any callback.

Self-review. Checked: take_unrun() can report a late entry that a pass did run (a swap-remove pulled it forward). The cost is one empty loop iteration. It cannot miss one, because only post_task of a new key sets the mark and run() clears it before the first callback. An entry that returns true and stays registered never sets the mark, so a sink waiting on a writable poll does not spin the loop. On a loop that is closed for tasks, enqueue_task_after_yield releases the task at once. Native-socket sessions post from inside a pass only when a flush dispatches a write callback that writes again. Those frames used to wait for the next unrelated checkpoint and now get one on the next iteration.

Measured against node v26.3.0 over duplexPair. Two matrices, each run as a separate process per case, checking that 'response', 'end' (with the full body), 'close', and the server's 'close' all fire before exit.

matrix node bun 1.4.3 flushes removed only this PR
42 cases: request issued from top level, 'connect', setImmediate, setTimeout, or after a previous stream closed, times 7 server response shapes 42 27 25 42
448 cases: same entry points plus nextTick and microtask, times a server that answers from 1 to 5 nested nextTick / microtask / promise hops, times 4 response shapes 448 336 258 448

The 112 cases stock bun fails in the second matrix are all "trailers sent from a deferred callback": the pre-existing strand, which the synchronous flush never covered.

Why a yield task and not a second pass in the same checkpoint. Looping the deferred queue until it is quiet would run a whole in-process conversation inside one checkpoint, ahead of timers and I/O, and two chatty in-process peers could starve the loop. Node submits each side's frames from its own immediate, so I/O interleaves. One checkpoint per loop iteration gives the same fairness.

Cost. The second drain is guarded by JSC__JSGlobalObject__hasPendingMicrotasks (two loads), and the yield task is only queued when a deferred callback posted a new entry during the pass, which native-socket flushes do not do. A deferred-task-heavy loop (200k FileSink writes, one per setImmediate, debug+ASAN, interleaved runs): 27.3 / 26.9 / 27.5 / 26.9 s on main, 26.8 / 27.4 / 27.7 / 27.1 s with the change. An unguarded second drain cost about 9% on the same loop. 1500 sequential h2 requests over TCP, same build with and without the event-loop change: 5.45 / 5.60 / 5.62 s and 5.55 / 5.54 / 5.65 s.

Reset sockets. A peer that resets the connection after answering is reported the same way before and after (session error: ECONNRESET): the same write path runs, from the scheduled flush instead of from request().

Other suites, each compared with a build of main: test/js/web/fetch, test/js/web/streams, test/js/node/stream, test/js/bun/http, test/js/bun/io, test/js/third_party/grpc-js, test/js/third_party/http2-wrapper, and 1347 node tests matching test-{stream,net,timers,process-next,http-,https-,tls-,worker,child-process}*. Failures are identical with and without the change (IPv6, proxy, and CA-store tests that need network or host setup, ASAN leak thresholds, and files that exceed a 60 s cap under debug+ASAN and pass serially).


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/http2/node-http2-syscall-fault.test.ts

@coderabbitai

coderabbitai Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 692b39d4-45a0-469d-a792-b77a7008c73c

📥 Commits

Reviewing files that changed from the base of the PR and between 5034116 and f4442b1.

📒 Files selected for processing (4)
  • src/event_loop/DeferredTaskQueue.rs
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/event_loop.rs
  • src/runtime/api/bun/h2_frame_parser.rs
💤 Files with no reviewable changes (1)
  • src/jsc/bindings/ZigGlobalObject.cpp

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The changes update deferred-task and microtask checkpoint handling, revise HTTP/2 flush and transport error behavior, and add regression tests for synchronous transports and injected ENOTCONN failures.

Changes

HTTP/2 event loop and transport corrections

Layer / File(s) Summary
Deferred task and microtask checkpoint
src/event_loop/DeferredTaskQueue.rs, src/jsc/bindings/ZigGlobalObject.cpp, src/jsc/event_loop.rs
Deferred tasks track newly posted entries. The event loop detects and drains pending microtasks, then schedules another checkpoint when deferred work remains.
HTTP/2 flush lifecycle
src/js/node/http2.ts, src/runtime/api/bun/h2_frame_parser.rs
HTTP/2 finalization removes explicit native flush calls. The frame parser updates cork ownership, fatal-write handling, and auto-flusher registration lifetime.
Synchronous transport regression coverage
test/js/node/http2/node-http2.test.js
Tests cover deferred frame submission and complete response, body, trailer, and close event delivery over synchronous transports.
Transport fault recovery coverage
test/js/node/http2/node-http2-syscall-fault.test.ts
The test injects one send ENOTCONN failure and verifies HEADERS delivery after transport recovery for ended and open requests.

Suggested reviewers: cirospaciari, jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to f4442

The HTTP/2 scheduling and transport changes have no identified merge-blocking risk in the reviewed scope.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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 clearly explains the problem, the fix, the background, and extensive verification results. It does not use the exact template headings, but it provides the required information.
Title check ✅ Passed The title clearly and concisely describes the primary change: deferring HTTP/2 request frame submission until after request() returns.

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

@robobun

robobun commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced with a single file and no network: three sequential requests on one node:http2 client session over an in-memory Duplex pair that delivers synchronously (createConnection), against a hand-written peer that answers every HEADERS frame with :status 200 and ok.

  • bun 1.4.3 and main: the peer receives the HEADERS for streams 3 and 5 while session.request() is still on the stack, and those two requests get 'close' with the body but never 'response'.
  • node v26.3.0 and this branch: nothing reaches the peer inside request(), and all three requests get 'response' 200.

The same check is the first new test in test/js/node/http2/node-http2.test.js. It fails on main and passes on this branch.

PR: #42353

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
@robobun

robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:59 PM PT - Sep 11th, 2026

✅ @robobun, your commit f4442b12abc7b7cb933f14048905a6387f02cc75 passed in Build #114426! 🎉


🧪   To try this PR locally:

bunx bun-pr 42353

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

bun-42353 --bun

Http2Stream._final called native.flush() synchronously. session.request()
calls req.end() for an endStream request before it returns, so the HEADERS
frame reached the transport while request() was still on the stack. Over a
transport that delivers synchronously (an in-memory Duplex pair, a mock
socket) the peer's whole response arrived before the caller could attach a
'response' listener, so the status was lost while the body and 'close' still
came through. Node schedules every session write, so nothing observable
happens inside request().

Both flush calls exist to stop a frame from being stranded in the cork when
_final runs on the last live turn of the program. That strand has its own
cause: a deferred task writes its buffer to its transport, and a JS-backed
transport runs user code for that write. That code queues microtasks and
nextTicks, and it can post deferred tasks of its own (the peer end of an
in-process pair answering). Nothing drained or ran those afterwards, and the
event loop could find no work left and exit. The checkpoint now drains again
after the deferred queue when either queue holds work, and a yield task keeps
the loop alive for one more checkpoint when a deferred task was posted too
late for the pass.
@robobun
robobun force-pushed the robobun/b4e23d84/http2-defer-request-submit branch from 6031dd6 to 5034116 Compare September 11, 2026 20:10
Comment thread src/event_loop/DeferredTaskQueue.rs Outdated
Comment thread src/event_loop/DeferredTaskQueue.rs Outdated
Comment thread src/event_loop/DeferredTaskQueue.rs Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs 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.

Code review found no issues

No high-confidence issues detected in this change.

robobun added a commit that referenced this pull request Sep 20, 2026
A server handler that calls end() and then pushStream() in the same tick
wrote the PUSH_PROMISE after the parent's DATA(END_STREAM). RFC 9113
section 6.6 only allows a PUSH_PROMISE on a stream that is open or
half-closed (remote), so a node client cancels that push.

send_data records where a stream's END_STREAM DATA frame lands in the
cork. The last frame of a multi-frame body takes the corked path too, so
a body of any size keeps its END_STREAM corked until the tick ends.
push_promise cuts the cork at that mark, writes the PUSH_PROMISE, and
puts the tail back. The cut only moves DATA frames, so HPACK header
blocks keep their encode order. It bumps the cork generation and marks
the moved END_STREAM frames again. push_promise no longer flushes.

Stacked on #42353, which removes the synchronous flush in
Http2Stream._final and gives the deferred queue its liveness guarantee.
@robobun

robobun commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

A review on #43578 (which carries this PR's three commits as cherry-picks) raised a point about JSC__JSGlobalObject__hasPendingMicrotasks in ZigGlobalObject.cpp: JSNextTickQueue field 0 is a scheduled latch (set to 1 by nextTick(), reset only by discard()), not a count, so isEmpty() stays false for the life of any process that called process.nextTick once. The second drain in event_loop.rs then runs at every checkpoint. Thread: #43578 (comment)

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.

2 participants