Skip to content

node:http2: rewritten inbound engine, batched write path, server push, +290 node v26.3.0 tests (79% passing) - #31584

Merged
cirospaciari merged 1 commit into
mainfrom
claude/http2-rust
Jun 18, 2026
Merged

cirospaciari merged 1 commit into
mainfrom
claude/http2-rust

Conversation

@cirospaciari

@cirospaciari cirospaciari commented May 29, 2026 •

Copy link
Copy Markdown
Member

Summary

Rewrites the inbound half of node:http2 on a self-contained HTTP/2 frame engine, closes a large set of Node compatibility gaps, and overhauls the outbound write path end to end (frame batching, vectored writes, TLS record batching). The ported test suite is byte-identical to node v26.3.0 — every deviation from upstream behavior is either fixed in the implementation or registered explicitly in test/expectations.txt.

  • New frame engine (src/runtime/api/bun/h2/: wire codec, SETTINGS, flow control, stream state machine, HPACK over lshpack, connection core). All inbound traffic — native sockets and JS-fed streams — runs through it; outbound still uses the existing encoder (follow-up).
  • Server push end to end: pushStream/createPushResponse, pushed-stream lifecycle, maxReservedRemoteStreams, push-disabled enforcement (RFC 9113 §6.6).
  • Protocol correctness: RFC 9113 §8.2 malformed-header rejection, §5.1 stream-state transitions, §6.5 SETTINGS validation + maxSettings/maxOutstandingSettings, §6.9 flow-control windows including the pre-ACK initial-window rule (§6.5.3) and the §6.9.2 delta, frame-size limits enforced at header-parse time, CONTINUATION sequencing as connection errors (§6.2/§6.10), closed-stream eviction, initialWindowSize bounded at 2^31-1 (§6.5.2).
  • Node API parity: raw (array) headers preserving on-wire duplicate order, never-index (sensitive) headers with nghttp2's auto-marking, single-value header validation before encoding, defaulted pseudo-headers in node's wire order, :authority matching node's kAuthority, GOAWAY semantics (ERR_HTTP2_GOAWAY_SESSION on new requests, REFUSED_STREAM sweep of unprocessed streams — grpc fails over correctly), session destroy semantics, respondWithFD accepting FileHandle, node-exact error messages on the http2 surface, writeInformation, diagnostics_channel integration, AltSvc/Origin frames, extended CONNECT (:protocol), allowHTTP1 fallback, graceful close with correct GOAWAY last-stream-id.
  • Stream teardown / memory: completed server streams release their engine map entry, JS-context root, and stream allocation immediately on every lifecycle path; RSS stays flat under sustained load (previously ~1.4 KB leaked per request).
  • Inbound header materialization in one native call (src/jsc/bindings/H2HeadersMaterializer.cpp): the raw headers array and the node-shaped headers object (full toHeaderObject semantics — set-cookie arrays, cookie joins, single-value first-wins, :status coercion, sensitive-headers symbol) are built together in C++, reusing WebCore's interned header-name strings.
  • Outbound write path: the auto-cork is enabled (the flag existed but shipped disabled) — frame writes coalesce per socket per event-loop turn, the cork holds exactly one max-size TLS record and fills to that boundary; multi-frame bodies flush as one batch; on plain TCP the batch goes out as a single writev with payload slices referenced zero-copy. Flush paths never hold thread-local borrows across socket writes (h2-over-h2 tunnels re-enter the writer through JS-stream-backed sockets).
  • TLS write batching in uSockets (packages/bun-usockets, benefits all TLS serving, not just h2): sealed records accumulate and reach the socket in 128 KB batches instead of one write per 16 KB record, with honest write accounting — consumption stops when the wire blocks, a bounded spill drains before any graceful shutdown, so flush-then-close semantics (destroySoon) hold. New bsd_writev/us_socket_raw_writev primitives (POSIX writev, sequential fallback elsewhere).
  • Test infrastructure: node tests gated on --expose-internals run via virtual internal/http2/* modules backed by the real internals.

Test results

Suite Result
node v26.3.0 http2 parallel suite (290 files, byte-identical to upstream) 228 passing (79%)
RFC 9113 conformance suite (h2-conformance.test.ts) 21/21
grpc-js suite 27/29 (the 2 deadline-propagation-through-proxy cases predate this PR's write-path work; tracked)
node test-tls-* parallel suite (after the uSockets changes) 149/151 (both failures pre-existing)
Regressions across the branch 0 (every batch full-suite gated)

Remaining failures are tracked in test/expectations.txt with reasons: node-internals-dependent tests (internalBinding monkey-patching), observability (NODE_DEBUG trace, perf_hooks entries, AsyncLocalStorage propagation), validator wording (and vs && in the shared range template), customSettings, the nghttp2 flow-control error surface, paddingStrategy, and platform-specific teardown gaps.

Follow-ups (tracked in review threads)

  • Outbound migration onto the engine, then legacy parser removal
  • Engine-borrow restructure around dispatch, Sink error-code passthrough
  • Send-window consumption wiring once outbound moves
  • grpc deadline propagation through proxied calls (pre-existing gap, isolated to the rewrite branch)

Performance

http2.createServer/createSecureServer hello + streaming servers, oha --http-version 2, macOS arm64 release build (representative runs; local builds vary a few percent):

workload this PR bun 1.3.14
h2c, small response 133k req/s (p50 0.43 ms) 70k (0.88 ms)
h2c, 16 request headers 96k req/s 57k
h2c, 256 KB response 37k req/s = 9.3 GB/s ~14k = 3.5 GB/s
TLS, small response 100k req/s (p50 0.28 ms) 49k
TLS, 256 KB response 13.3k req/s = 3.3 GB/s 7.2k = 1.8 GB/s

bench/grpc-server (grpc-js unary ping, ghz --total=60000 -c 50):

mode this PR bun 1.3.14
gRPC insecure 63.1k req/s 34.7k
gRPC TLS 58.8k req/s 31.1k

What gets the rewrite there:

  • The outbound auto-cork is now enabled (the flag existed but shipped disabled, in the previous engine too): frame writes coalesce per socket per event-loop turn and flush through the same deferred-task mechanism the node:http server uses. The cork holds exactly one max-size TLS record and fills to that boundary.
  • Multi-frame response bodies on plain TCP reach the socket with one writev: frame headers from a small scratch plus payload slices straight from the caller's buffer — no per-frame copy, one syscall per body.
  • The TLS write path batches sealed records (packages/bun-usockets): one SSL_write consumes plaintext in record-size slices, ciphertext accumulates and hits the socket in 128 KB batches instead of one write per 16 KB record. Write accounting stays honest — when the wire blocks, consumption stops, so flush-then-close semantics (destroySoon) hold; a bounded spill drains before any graceful shutdown.
  • Completed server streams release their engine map entry, JS-context root, and stream allocation immediately (previously ~1.4 KB leaked per request).
  • Header blocks are materialized in a single native call, reusing WebCore's interned header-name strings.
  • Per-request trims: no deferred no-op rstStream for cleanly closed streams; local-close state rides the writeStream return value; header objects never flip into dictionary mode.

@robobun

robobun commented May 29, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 6:04 PM PT - Jun 17th, 2026

❌ @cirospaciari, your commit 557f409 has 1 failures in Build #63217 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31584

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

bun-31584 --bun

Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts 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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/runtime/api/bun/h2_frame_parser.rs:8377-8385 — read() no longer pins the input ArrayBuffer (the previous as_pinned_arraybuffer()/unpin() was replaced with a bare as_array_buffer()), but rewrite_read()'s empty-tail fast path passes the borrowed byte_slice() directly into engine.receive(), which loops over it and synchronously dispatches Sink callbacks (on_ping/on_data/on_headers_complete) into JS user code between frames. If a user handler .transfer()s the input chunk's ArrayBuffer (reachable via a createConnection Duplex), the &[u8] the engine continues iterating over now dangles — UAF/garbage parse. The PR also deletes the regression test for exactly this scenario from node-http2.test.js. Restore the pin/unpin around rewrite_read() (or copy the bytes before dispatch when rewrite_tail is empty).

    Extended reasoning...

    What the bug is

    H2FrameParser::read() (the JS-fed inbound path used when options.createConnection returns a user Duplex) takes a JS Buffer/ArrayBuffer, borrows its backing memory as a &[u8], and hands it to the new frame engine. The previous implementation explicitly protected that borrow:

    let array_buffer = buffer.as_pinned_arraybuffer(global_object);
    // ... read_bytes() loop ...
    if let Some(array_buffer) = &array_buffer { array_buffer.unpin(); }

    Per src/jsc/JSValue.rs:954-961, as_pinned_arraybuffer "pins the backing JSC::ArrayBuffer first so it cannot be detached" — i.e. while pinned, ArrayBuffer.prototype.transfer() / structuredClone(..., {transfer}) cannot move the bytes out from under the borrowed slice.

    The new implementation (lines 8378–8386) drops the pin entirely:

    let buffer = args_list.ptr[0];
    buffer.ensure_still_alive();
    if let Some(array_buffer) = buffer.as_array_buffer(global_object) {
        this.rewrite_read(array_buffer.byte_slice());
        ...
    }

    ensure_still_alive() only protects against GC; it does not prevent detachment.

    The code path that triggers it

    rewrite_read() has two branches. When rewrite_tail is empty (the common case — every chunk that starts on a frame boundary), it does no copy:

    if self.rewrite_tail.get().is_empty() {
        let consumed = { ... engine.receive(self, bytes).consumed };
        ...
    }

    Connection::receive() (src/runtime/api/bun/h2/connection.rs) then loops:

    loop {
        let remaining = &bytes[offset..];
        ...
        let payload = &remaining[FRAME_HEADER_SIZE..total];
        if self.dispatch(sink, &hdr, payload) { ... }
        offset += total;
    }

    dispatch() → handle_ping / handle_data / handle_headers → sink.on_ping / sink.on_data / sink.on_headers_complete. The Sink impl for H2FrameParser (e.g. on_ping) calls self.dispatch_with_extra(JSH2FrameParser::Gc::onPing, ...), which synchronously invokes the JS handler — session.emit('ping', payload) in http2.ts. After that JS callback returns, receive() re-derives &bytes[offset..] and continues parsing the next frame from the same slice.

    Step-by-step proof

    This is exactly the scenario the deleted regression test ('http2 client keeps parsing a socket chunk whose ArrayBuffer is transferred by a frame event handler' in test/js/node/http2/node-http2.test.js) exercised:

    1. http2.connect('http://localhost', { createConnection: () => socket }) with a user Duplex. The socket.on('data', ...) listener installed by http2.ts calls parser.read(chunk) — chunk is the exact Buffer the user pushed.
    2. User pushes pingChunk = Buffer.from(chunkArrayBuffer) containing two PING frames back-to-back (so rewrite_tail is empty → no-copy path).
    3. read() → as_array_buffer() (unpinned) → byte_slice() → rewrite_read(bytes) → engine.receive(self, bytes).
    4. The engine parses frame 1 (PING), calls sink.on_ping() → dispatch_with_extra(onPing, ...) → JS client.on('ping', payload => { ... }) runs synchronously.
    5. The user handler calls chunkArrayBuffer.transfer() and new Uint8Array(moved).fill(0xff). With the pin removed, transfer succeeds: the backing store is moved to a new ArrayBuffer the user owns and may free/overwrite; the original is detached.
    6. The handler returns. receive() continues with let remaining = &bytes[offset..] — but bytes still points at the old backing store, which is now detached. The engine reads frame 2's header and payload from memory the application owns and has overwritten with 0xff (or that the allocator has reclaimed).

    The deleted test asserted PINGS:["4141…","4242…"] — i.e. the second ping's original payload survives the mid-parse transfer — which only held because the pin made step 5 throw (try { ... .transfer() } catch { /* runtime may refuse */ }).

    Why nothing else prevents it

    • The else branch of rewrite_read() copies into combined first, so it is safe — but it only runs when there's a leftover tail from the previous chunk; the steady state hits the no-copy branch.
    • buffer.ensure_still_alive() only roots the JS wrapper for GC; transfer/detach is orthogonal.
    • The Sink callbacks are documented as taking borrowed slices the embedder must copy before returning, but that protects the payload slice, not the outer bytes slice the loop re-indexes after the callback.

    Impact

    User-reachable memory-safety issue: with a createConnection Duplex (used for proxies, HTTP/2-over-anything, and the test-http2-generic-streams/backpressure family), a 'ping'/'data'/'response' handler that detaches the chunk it was fed causes the native engine to read freed/overwritten memory on the very next frame in the same chunk — a use-after-free leading to garbage parsing or a crash. This is a regression of a previously-fixed bug whose regression test is removed by this same PR.

    Fix

    Restore the pin around the borrow:

    if let Some(array_buffer) = buffer.as_pinned_arraybuffer(global_object) {
        this.rewrite_read(array_buffer.byte_slice());
        array_buffer.unpin();
        Ok(JSValue::UNDEFINED)
    } else { ... }

    (or, equivalently, make rewrite_read()'s empty-tail branch copy bytes into an owned Vec<u8> before calling engine.receive(), matching what the non-empty-tail branch already does). And restore the deleted regression test.

Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread src/runtime/api/bun/h2/stream.rs
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts
Comment thread src/runtime/api/bun/h2_frame_parser.rs
Comment thread src/js/node/http2.ts Outdated
Comment thread src/runtime/api/bun/h2/stream.rs Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread test/js/node/http2/h2-conformance.test.ts
Comment thread src/runtime/api/bun/h2_frame_parser.rs
Comment thread src/js/node/http2.ts Outdated
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread src/runtime/api/bun/h2/connection.rs
@cirospaciari
cirospaciari force-pushed the claude/port-node-net-tls-tests-2 branch from 83618ad to 036fff3 Compare May 30, 2026 21:33
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread src/js/node/http2.ts
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread src/js/node/http2.ts
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts Outdated
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread test/expectations.txt Outdated
Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread src/js/node/http2.ts
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread test/expectations.txt Outdated
Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2/wire.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread src/js/node/http2.ts 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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/js/node/http2.ts:4652-4662 — The new CONNECT validation throws ($ERR_HTTP2_CONNECT_AUTHORITY/SCHEME/PATH at lines 4652-4661 and the array-path equivalents at ~4606-4608, plus the new $ERR_HTTP2_INVALID_SESSION at 4547) all run before getNextStream(), but the pre-existing catch (e) { this.#connections--; … } at line 4787 unconditionally decrements a counter that is only incremented inside getNextStream()'s streamStart handler — so request({':method':'CONNECT'}) (no :authority) throws and drives #connections to -1, breaking the if (this.#connections === 0) auto-destroy gate in close()/streamEnd (and emitErrorNT also fires a spurious session-level 'error' for what Node treats as a pure synchronous user-input throw). The catch defect itself is pre-existing, but the CONNECT validation is the first realistic trigger this PR adds — test-http2-connect-method.js (ported by this PR) hits it three times → #connections = -3. Fix: track whether getNextStream() ran (a local flag) and only decrement in the catch when it did.

    Extended reasoning...

    What the bug is

    ClientHttp2Session.request() at src/js/node/http2.ts:4542-4791 wraps its body in a try/catch whose catch block (lines 4787-4790) is:

    } catch (e: any) {
      this.#connections--;
      process.nextTick(emitErrorNT, this, e, this.#connections === 0 && this.#closed);
      throw e;
    }

    #connections is incremented in exactly one place: the streamStart handler (line 3875, self.#connections++), which fires synchronously from this.#parser.getNextStream() at line 4756. Any throw before line 4756 reaches the catch with no matching increment, so the decrement underflows the counter.

    This PR adds several new throws before getNextStream():

    • $ERR_HTTP2_INVALID_SESSION() at line 4547 (new — destroyed sessions used to throw INVALID_STREAM, but it was the same shape).
    • The new object-path CONNECT validation at lines 4652-4661: $ERR_HTTP2_CONNECT_AUTHORITY() / $ERR_HTTP2_CONNECT_SCHEME() / $ERR_HTTP2_CONNECT_PATH().
    • The new array-path CONNECT validation at lines ~4606-4608 (same three errors).

    The catch block itself is pre-existing (git log -L traces it to the file's initial commit d6d8447), and pre-PR there were already throws before getNextStream() — $ERR_HTTP2_INVALID_STREAM on a destroyed/closed session, $ERR_INVALID_ARG_TYPE on non-object headers, $ERR_INVALID_ARG_VALUE on a bad sensitives array — so the structural flaw is not new. But the CONNECT validation throws are new in this PR and are the first realistic trigger: forgetting :authority on a CONNECT (or accidentally passing :path/:scheme) is a common user error that Node's API explicitly checks for, whereas the pre-existing triggers are programming errors that never reach a graceful-close gate anyway.

    Step-by-step proof

    Take test-http2-connect-method.js (ported by this PR), which on a connected client does:

    assert.throws(() => client.request({ ':method': 'CONNECT' }),               { code: 'ERR_HTTP2_CONNECT_AUTHORITY' });
    assert.throws(() => client.request({ ':method': 'CONNECT', ':authority': a, ':scheme': 'http' }), { code: 'ERR_HTTP2_CONNECT_SCHEME' });
    assert.throws(() => client.request({ ':method': 'CONNECT', ':authority': a, ':path': '/' }),      { code: 'ERR_HTTP2_CONNECT_PATH' });

    Trace the first call:

    1. request({':method':'CONNECT'}) enters the try. Not destroyed, not closed, no sentTrailers. headers is an object → headers = {...headers}.
    2. Line 4652: headers[':method'] === 'CONNECT' && headers[':protocol'] === undefined → true.
    3. Line 4653: !headers[':authority'] → !undefined → true → throw $ERR_HTTP2_CONNECT_AUTHORITY().
    4. getNextStream() (line 4756) never ran — streamStart never fired — #connections was never incremented.
    5. Catch at line 4787: this.#connections-- → 0 → -1. process.nextTick(emitErrorNT, this, e, -1 === 0 && this.#closed) schedules a session-level error emission. throw e re-throws to the caller.
    6. assert.throws catches the synchronous throw — the test passes that assertion.

    After all three assert.throws calls, #connections === -3.

    Consequences

    (a) Broken graceful-close gate. Both ClientHttp2Session.close() (line 4475) and the streamEnd handler check this.#connections === 0 (and self.#connections === 0 && self.#closed) with strict equality, not <= 0. With the counter negative, those gates can never match at the right moment. Concretely:

    • After one bad CONNECT (#connections = -1), the user opens one valid request → streamStart → #connections = 0. The user then calls client.close(): if (this.#connections === 0) is true with the request still in flight, so setImmediate(destroy) fires and the in-flight request is torn down with ERR_HTTP2_STREAM_CANCEL.
    • After three bad CONNECTs (#connections = -3), each subsequent valid request increments to -2, -1, 0, … and streamEnd's #connections === 0 && #closed auto-destroy lands at the wrong time or never.

    In practice the socket-close path eventually destroys the session anyway (so this is not a hard hang), but the bookkeeping is unambiguously corrupted.

    (b) Spurious session 'error'. Node treats CONNECT-validation failures as pure synchronous throws — they do not emit a session-level 'error' event. Here emitErrorNT (line 391-401) fires on the next tick: it checks self.listenerCount('error') > 0 first (line 398), so without a listener it's a silent no-op (which is why test-http2-connect-method.js doesn't crash). But any user with a session 'error' listener — e.g. for connection-loss handling — will see a confusing duplicate of the error they already caught synchronously.

    Why nothing prevents it

    #connections is a plain private field with no clamp; the catch block has no flag to know whether getNextStream() ran. The catch was clearly written assuming every throw happens after the increment, which was already false pre-PR and is now false for several more (and more reachable) cases.

    Why this is in scope for this PR

    The catch flaw is pre-existing, but:

    • CONNECT validation is a feature this PR explicitly adds (the PR description lists it under "Node compatibility fixes"), and that feature directly triggers the flaw.
    • This PR ports test-http2-connect-method.js, which exercises all three new throw paths on a live session.
    • The same fix also corrects the pre-existing trigger paths ($ERR_INVALID_ARG_TYPE on non-object headers, etc.).

    Fix

    Track whether the increment actually happened and only undo it in the catch:

    let streamStarted = false;
    try {
      …
      let stream_id: number = this.#parser.getNextStream();
      streamStarted = true;
      …
    } catch (e: any) {
      if (streamStarted) {
        this.#connections--;
        process.nextTick(emitErrorNT, this, e, this.#connections === 0 && this.#closed);
      }
      throw e;
    }

    (Dropping the emitErrorNT for the pre-getNextStream() case also fixes the spurious session-error: those throws are pure user-input validation that Node surfaces synchronously only.)

Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/js/node/http2.ts

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js/node/http2.ts:2622-2633 — Nit: the new ServerHttp2Stream._write()/_writev() overrides at lines 2622-2633 auto-respond() when !this.headersSent so DATA frames are preceded by response HEADERS, but there's no matching _final() override — so server.on('stream', s => s.end()) (no chunk, no respond()) goes straight to base Http2Stream._final() → native.writeStream(id, '', 'ascii', true, cb) at line 2371, putting an empty DATA|END_STREAM on the wire with no preceding response HEADERS. The PR fixed 2 of 3 outbound write paths for the same hazard the inline comment describes; add a _final(callback) override with the same if (!this.headersSent && !this.destroyed && !this.closed) this.respond(); guard before super._final(callback).

    Extended reasoning...

    What the bug is

    This PR adds ServerHttp2Stream._write() and _writev() overrides at src/js/node/http2.ts:2622-2633:

    // Node sends the implicit response headers (:status 200) when the stream is written to before
    // respond() was called; without this the DATA frames would go out with no preceding HEADERS.
    _write(chunk, encoding, callback) {
      if (!this.headersSent && !this.destroyed && !this.closed) {
        this.respond();
      }
      super._write(chunk, encoding, callback);
    }
    _writev(data, callback) {
      if (!this.headersSent && !this.destroyed && !this.closed) {
        this.respond();
      }
      super._writev(data, callback);
    }

    The inline comment (lines 2620-2621) is explicit: "without this the DATA frames would go out with no preceding HEADERS". But there is no ServerHttp2Stream._final() override (a grep confirms only one _final(callback) definition in the file, at line 2327 on the base Http2Stream), and the base Http2Stream._final() at line 2371 also writes a DATA frame:

    native.writeStream(this.#id, "", "ascii", true, callback);

    — an empty DATA frame with close=true (END_STREAM). So the third outbound-write path has the exact hazard the first two were just fixed for.

    The code path that triggers it

    Writable.prototype.end() with no chunk does not call _write() — it goes prefinish → _final(callback) directly. Http2Stream.end()'s own internal comment in this file confirms this: "just calls _final without _write for empty data". So a server 'stream' handler that calls stream.end() without first calling stream.respond() (and without writing any data) bypasses the auto-respond entirely.

    Step-by-step proof

    Take server.on('stream', s => s.end()):

    1. The client's request HEADERS arrives → engine on_stream_open → JS creates ServerHttp2Stream(1) and emits 'stream'.
    2. The handler calls s.end() with no chunk. headersSent is false (respond() never called).
    3. Duplex.prototype.end() → no chunk → _write() is not invoked. The Writable side moves to prefinish → _final(callback).
    4. ServerHttp2Stream has no _final override, so base Http2Stream._final() (line 2327) runs. pending is false (the stream has an id). waitForTrailers is unset.
    5. Line 2371: native.writeStream(1, "", "ascii", true, callback) enqueues a DATA frame with END_STREAM on stream 1.
    6. The legacy Stream::can_send_data() check returns true for HALF_CLOSED_REMOTE, so the frame is written to the socket: a 9-byte DATA(stream=1, flags=END_STREAM, length=0).
    7. No response HEADERS frame was ever sent on stream 1. Per RFC 9113 §8.3.2, every response must contain a :status pseudo-header in a HEADERS frame; sending DATA before that is a malformed response. A compliant client (nghttp2/Node) typically resets the stream with PROTOCOL_ERROR.

    Had the handler instead called s.end('x') or s.write('x'); s.end(), step 3 would have routed through ServerHttp2Stream._write('x', …), which auto-respond()s first — so the asymmetry is purely the bare-end() case.

    Why nothing prevents it

    The auto-respond guard lives only in the two new _write/_writev overrides. Nothing in base _final() checks headersSent, and nothing else on the server side intercepts end()-with-no-chunk. Pre-PR there was no auto-respond on any of the three paths, so this isn't a regression — the PR added a new feature to two of the three sibling write hooks and missed the third.

    A note on Node parity: Node's own _final() does not call [kProceed] (auto-respond) either — only Node's _write/_writev do. But Node's _final() calls handle.shutdown() → nghttp2_session_resume_data(), which for a stream with no submitted response is a no-op (nghttp2 has nothing to resume) and sends nothing on the wire. Bun's _final() actively writes a DATA frame via native.writeStream. So Bun diverges from Node (Bun sends a malformed DATA; Node sends nothing), and given Bun's architecture the consistent fix is to apply the same auto-respond guard the PR already added to _write/_writev.

    Impact

    Nit. Narrow trigger: a server handler must call bare stream.end() with neither a prior write() nor an explicit respond() — uncommon, since most code either calls respond() or end(data). Pre-PR none of the three paths auto-responded, so this is an incomplete new feature rather than a regression. The fix is one method.

    Fix

    Add the same guard as a _final override on ServerHttp2Stream, alongside the _write/_writev overrides:

    _final(callback) {
      if (!this.headersSent && !this.destroyed && !this.closed) {
        this.respond();
      }
      super._final(callback);
    }

Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts
@cirospaciari cirospaciari changed the title node:http2: new inbound frame engine, server push, compat fixes node:http2: rewritten inbound engine, server push, and Node compatibility (82% of node's suite) Jun 1, 2026
@cirospaciari cirospaciari changed the title node:http2: rewritten inbound engine, server push, and Node compatibility (82% of node's suite) node:http2: rewritten inbound engine, server push, +125 ported node tests (82% suite passing) Jun 1, 2026
Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants