Skip to content

node:quic on lsquic — Node v26 compat, HTTP/3 - #32602

Merged
dylan-conway merged 100 commits into
mainfrom
claude/node-quic
Jul 25, 2026
Merged

dylan-conway merged 100 commits into
mainfrom
claude/node-quic

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jun 22, 2026 •

Copy link
Copy Markdown
Member

Ports Node v26's node:quic module to Bun, backed by lsquic (already vendored for HTTP/3) — no new native dependencies.

Implements the full experimental node:quic surface: endpoints, client/server sessions, bidi/uni streams, HTTP/3 and raw-QUIC applications, datagrams, 0-RTT/session resumption, path validation and preferred-address migration, stateless resets, version negotiation, HTTP/3 ORIGIN frames, qlog, keylog, blocklists, per-SNI certificates, and the full stats/state surface.

Performance

bench/quic (in this PR), 500,000 requests at concurrency 50, one QUIC session with one bidirectional stream per request, linux x64. Bun is this branch built with LTO; node is v26.3.0 built from source with --experimental-quic (QUIC is compiled out of stock node binaries).

server client req/s
bun bun 64,591
bun node 56,535
node node 49,239
node bun 49,269

End-to-end bun-to-bun is 1.31x node-to-node, and bun is ahead on both halves: server 56,535 vs 49,239 against the same node client, client 64,591 vs 56,535 against the same bun server.

Concurrency above ~100 does not help either runtime: initialMaxStreamsBidi defaults to 100 (in node too), and past that every stream waits on a MAX_STREAMS grant. Raising it recovers the throughput. Peak is at c=50, which is what the table uses.

Compatibility

All 235 vendored Node v26.3.0 node:quic tests pass on a release build. Zero timeouts. Verified under BUN_JSC_validateExceptionChecks=1 with zero unchecked-exception aborts.

Two upstream quic tests are deliberately not vendored, because only passing tests are vendored here. Both reasons were verified against a from-source QUIC-enabled Node v26.3.0:

  • test-quic-reject-unauthorized — depends on Node discarding the client's TLS Finished when close() runs in the same event-loop turn as handshake completion, so the server never completes its handshake and its opened rejects. lsquic flushes the handshake inside process_conns, so our server always completes and opened resolves. A flush-timing artifact rather than a semantic gap; not emulatable without engine surgery.
  • test-quic-stream-body-pooled-buffer — the test's own precondition (expected.buffer.byteLength > expected.byteLength) requires Buffer.from(string) to be pooled into a larger ArrayBuffer. Bun allocates exact-size buffers, so the assertion can never hold. A Buffer allocation-strategy question, not a QUIC one.

test-quic-session-initial-rtt asserts loopback smoothedRtt < 10ms. It passes on release but not on a debug build, where interpreter overhead exceeds the bound; CI runs these on release.

Why the diff shows 227 added files, not 235

The diff adds 227 test-quic-* files, deletes 3, and renames 1; 7 pre-existing files are untouched. 227 + 1 renamed + 7 untouched = the 235 that run.

The 3 deletions and the rename are one thing: Node v26.3.0 converted the four test-quic-internal-* tests to ESM, so upstream now has only .mjs versions and the .js paths 404 in the v26.3.0 tree. This PR replaces them in place. Git recorded three as delete+add rather than renames because the ESM rewrite changed too much to match (test-quic-internal-endpoint-stats-state went from 79 to 233 lines); test-quic-internal-setcallbacks stayed similar enough to be detected as a rename.

No coverage is lost, in either direction:

  • On main, all four .js files exit 0 only by self-skipping with 1..0 # Skipped: missing quic — node:quic doesn't exist there, so they have never asserted anything.
  • The four .mjs replacements actually execute on this branch (no skip marker) and pass.
  • Three of the four are byte-identical to upstream v26.3.0. test-quic-internal-endpoint-stats-state.mjs differs only by a dropped four-line TODO(@jasnell) comment; no code differs.
  • The old .js files fail against this branch, as they should: they were written against the pre-v26 internal surface, and now hit internalBinding("block_list") is not implemented, a missing onSessionEarlyDataRejected callback, and a stale stats-shape assertion. Keeping them would mean asserting a spec upstream has replaced.

Architecture

  • packages/bun-usockets/src/node_quic_shim.c — thin C glue: lsquic settings/engine-api setters (the structs are large and version-sensitive, so Rust never mirrors their layout), and lsquic_stream_if thunks that bounce into a Rust vtable.
  • src/lsquic_sys/ — safe wrappers (Conn/Stream/Settings) so unsafe stays inside one crate.
  • src/runtime/node/quic/{endpoint,session,stream,tls}.rs — the native objects. One UDP socket + up to two lsquic engines per endpoint; packets in via lsquic_engine_packet_in, out via ea_packets_out; JS callbacks are never invoked from inside lsquic callbacks (events are queued and dispatched after process_conns).
  • src/js/internal/quic/* — the JS layer, ported from Node's internal/quic/*.
  • patches/lsquic/node-quic-accessors.patch — lsquic additions Node semantics require: per-conn keep-alive/idle/preferred-address overrides, millisecond idle timeouts with the RFC 9000 §10.1 3×PTO floor, path-switch/early-data/origin callbacks, HTTP/3 ORIGIN frame read+write (RFC 9412), client-side stateless resets (purgatory + packet-response queue for client engines, NEW_CONNECTION_ID reset-token hashing), stream error-code recording, and a handful of accessors.

Behavioral fidelity

Where Node semantics weren't derivable from the API docs, they were probed empirically against a from-source QUIC-enabled Node v26.3.0 (frame-level ngtcp2 traces), e.g.: a stream created and destroyed in one turn only manifests its RESET/STOP_SENDING to the peer if the connection goes quiet — the frames are dropped if other stream data is written first; silently-destroyed sessions still ACK the packet that triggered the destroy so the peer idles out instead of eating a stateless reset; endpoint.destroy(err) after close() tears down silently.

Known limitations

  • session.updateKey() reports "not updated". lsquic only responds to a peer-initiated key update; it exposes no way to initiate one. Node's JS layer discards the return value, so the port matches upstream — the native stub says why.
  • cc: "reno" uses Cubic. lsquic ships no Reno controller (only Cubic, BBRv1 and Adaptive); the native side maps reno to Cubic so a user asking for a loss-based algorithm at least gets one, rather than silently falling through to Adaptive which may pick BBR.
  • ondatagramstatus(id, status) reports the wrong id under packet loss. lsquic's on_datagram_status(conn, count, acked) carries only a frame count, so the Rust side correlates it against a send-order FIFO; got_ack fires acked=1 only for packets inside the ACK ranges and loss detection fires acked=0 for the gap packets afterwards, so the FIFO drifts. Counts, the abandoned status, and every lossless path are unaffected. Node's ngtcp2 threads a per-datagram id and needs no correlation; fixing it here means threading po_packno into on_dg_write (a parser-vtable change) plus a packno→ids map. Left as a follow-up: it changes lsquic's send path and needs a loss-injection harness to verify, since loopback never drops a packet.

Deferred to follow-ups, each with the reason on its (resolved) review thread:

  • maxConnectionsPerHost is validated but not enforced. Enforcing needs per-peer-address accounting in the accept path, and node's exact semantics (when the count drops, what the peer sees) need probing against a from-source build first.
  • 62-bit CONNECTION_CLOSE codes truncate to 32 bits. The narrowing is lsquic's: lsquic_conn_abort_error and the ci_abort_error vtable slot behind it both take unsigned, so widening means patching its public signature and every implementor.
  • packets_out re-arms the drain on the wrong endpoint when a spec routes through another registered endpoint's socket. Needs preferred-address migration across two endpoints to reach, and no test does.
  • The stateless-reset broadcast feeds every other endpoint's server engine. Scoping it needs a cross-endpoint CID lookup.
  • lsquic_conn_make_uni_stream conflates pre-handshake with going-away. Separating them is another lsquic patch.
  • The native state/stats ArrayBuffers are transferable. Under --expose-internals, detaching or deleting them frees the backing store while the Rust side keeps the raw pointer; the fix is allocating the store natively and exposing it as an external ArrayBuffer JSC refuses to transfer (like node's AliasedBuffer), which touches every with_state/write_stat site.
  • options.token is accepted but not replayed to lsquic. Handing it over makes test-quic-token-secret and both zero-rtt tests time out, so the Retry path needs understanding first; the cost is the Retry round trip the token would have saved.

Also fixed here, each a real bug the quic tests exposed:

  • RareData released JSC Strong handles after the heap was gone — a heap-use-after-free at exit for any VM calling setCallbacks() (SIGABRT under ASAN, segfault on Windows). It now releases them while the heap is alive.
  • ea_packets_out assumed one iovec per datagram — lsquic coalesces Initial/Handshake/0-RTT into one datagram with several iovecs, so the old code clipped past a 1452-byte stack buffer and still reported the datagram sent. It now gathers into a reused grow-on-demand buffer, and the single-iovec case sends straight from lsquic's memory with no copy.
  • us_udp_socket_send's drain re-arm was dead code, so on_drain never fired; it now resumes lsquic's unsent packets.
  • Vendored test-process-features.js was missing Node v26.3.0's quic entry; re-synced.

[review] gate passed · iteration 53 · 311 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/quic/quic-endpoint.test.ts test/js/node/quic/quic-sni.test.ts test/js/node/quic/quic-stream.test.ts "test/regression/issue/25707.test.ts"
ninja: Entering directory `/workspace/bun/build/debug'
[1/113] gen ErrorCode+*.h
[2/113] gen BunProcess.lut.h
Generating /workspace/bun/build/debug/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[3/113] gen cpp.rs (cppbind)
[4/113] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 240 extern-C blocks audited
[5/113] gen JS modules (bundle-modules)
FAILED: codegen/WebCoreJSBuiltins.cpp codegen/WebCoreJSBuiltins.h codegen/InternalModuleRegistryConstants.h codegen/InternalModuleRegistry+createInternalModuleById.h codegen/InternalModuleRegistry+enum.h codegen/InternalModuleRegistry+numberOfModules.h codegen/NativeModuleImpl.h codegen/SyntheticModuleType.h codegen/GeneratedJS2Native.h codegen/generated_js2native.rs codegen/generated_resolved_source_tag.rs codegen/InternalModuleRegistryConstants.S codegen/In
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (784c975a3)

test/regression/issue/25707.test.ts:
(pass) require() of CJS file containing dynamic import of non-existent node: module does not fail at load time [11.59ms]
(pass) require() of CJS file with bare dynamic import of non-existent node: module does not fail at load time [9.16ms]
(pass) dynamic import of non-existent node: module in CJS rejects at runtime with correct error [8.80ms]

test/js/node/quic/quic-sni.test.ts:
ExperimentalWarning: quic is an experimental feature and might change at any time
      at node:quic (node:quic:15:20)

(pass) listen({ sni }) serves a different certificate per servername [36.67ms]
(pass) setSNIContexts() replaces and merges identities [11.84ms]
(pass) an identity with several cert/key pairs installs a matching pair [5.09ms]
(pass) setSNIContexts() rejects a non-object and a closed endpoint [3.64ms]
(pass) opened reports the X509 code name for validationErrorCode [4.36ms]

test/js/node/quic/quic-endpoint.test.ts:
(pass) QuicEndpoint client-engine mode > an explicit endpoint rejects a connect() in the other mode [6.29ms]
(pass) server ALPN list > rejects a list mixing HTTP/3 and non-HTTP/3 protocols [
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/quic/quic-endpoint.test.ts test/js/node/quic/quic-sni.test.ts test/js/node/quic/quic-stream.test.ts "test/regression/issue/25707.test.ts"
bun test v1.4.0 (323a71f83)

test/regression/issue/25707.test.ts:
(pass) require() of CJS file containing dynamic import of non-existent node: module does not fail at load time [346.33ms]
(pass) require() of CJS file with bare dynamic import of non-existent node: module does not fail at load time [299.37ms]
(pass) dynamic import of non-existent node: module in CJS rejects at runtime with correct error [295.74ms]

test/js/node/quic/quic-sni.test.ts:
ExperimentalWarning: quic is an experimental feature and might change at any time
      at node:quic (node:quic:16:20)

(pass) listen({ sni }) serves a different certificate per servername [451.74ms]
(pass) setSNIContexts() replaces and merges identities [184.04ms]
(pass) an identity with several cert/key pairs installs a matching pair [73.22ms]
(pass) setSNIContexts() rejects a non-object and a closed endpoint [34.20ms]
(pass) opened reports 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 706ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/211] gen ErrorCode+*.h
[2/211] fetch lsquic
[lsquic] up to date
[3/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_arr.c.o
[4/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_adaptive_cc.c.o
[5/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_alarmset.c.o
[6/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_attq.c.o
[7/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_cfcw.c.o
[8/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_crand.c.o
[9/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_bbr.c.o
[10/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_bw_sampler.c.o
[11/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_conn.c.o
[12/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_cubic.c.o
[13/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_di_error.c.o
[14/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_di_hash.c.o
[15/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_di_nocopy.c.o
[16/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_eng_hist.c.o
[17/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_frab_list.c.o
[18/211] cc obj/vendor/
... (truncated)
diff hotspot
Cargo.lock                                         |    5 +
 Cargo.toml                                         |    2 +
 bench/quic/README.md                               |   46 +
 bench/quic/cert.pem                                |   23 +
 bench/quic/client.mjs                              |   87 +
 bench/quic/key.pem                                 |   27 +
 bench/quic/run.mjs                                 |  115 +
 bench/quic/server.mjs                              |   39 +
 packages/bun-usockets/src/bsd.c                    |   54 +-
 packages/bun-usockets/src/internal/internal.h      |    7 +
 packages/bun-usockets/src/internal/loop_data.h     |    6 +
 .../bun-usockets/src/internal/networking/bsd.h     |   10 +-
 packages/bun-usockets/src/loop.c                   |    3 +
 packages/bun-usockets/src/node_quic_shim.c         |  608 +++
 packages/bun-usockets/src/udp.c                    |    2 +-
 patches/lsquic/abort-error-tickable.patch          |   17 +
 patches/lsquic/coalesce-batch-drop.patch           |   69 +
 patches/lsquic/connection-close-pns.patch          |   93 +
 patches/lsquic/hash-nested-iter.patch              |   58 +
 patches/lsquic/node-quic-accessors.patch           | 2037 ++++++++
 scripts/build/deps/lsquic.ts                       |   41 +-
 scripts/runner.node.mjs                            |   18 +-
 src/boringssl_sys/boringssl.rs                     |  150 +-
 src/codegen/generate-js2native.ts                  |    1 +
 src/event_loop/EventLoopTimer.rs                   |    2 +
 src/js/internal/quic/binding.ts                    |    2 +
 src/js/internal/quic/diagnostics.ts                |   70 +
 src/js/internal/quic/http2util.ts                  |  197 +
 src/js/internal/quic/quic.ts                       | 5239 ++++++++++++++++++++
 src/js/internal/quic/state.ts                      |  890 ++++
 src/js/internal/quic/stats.ts                      |  990 ++++
 src/js/internal/quic/symbols.ts                    | 
... (truncated)

gate history · 41 passed · 7 rejected · iteration 53

evidence per changed file
file                                                 reads  edits  tests
Cargo.lock                                               0      0      0
Cargo.toml                                               0      0      0
bench/quic/README.md                                     0      0      0
bench/quic/cert.pem                                      0      0      0
bench/quic/client.mjs                                    0      0      0
bench/quic/key.pem                                       0      0      0
bench/quic/run.mjs                                       0      0      0
bench/quic/server.mjs                                    0      0      0
packages/bun-usockets/src/bsd.c                          1      1      0
packages/bun-usockets/src/internal/internal.h            0      0      0
packages/bun-usockets/src/internal/loop_data.h           0      0      0
packages/bun-usockets/src/internal/networking/bsd.h      1      1      0
packages/bun-usockets/src/loop.c                         0      0      0
packages/bun-usockets/src/node_quic_shim.c               0      0      0
packages/bun-usockets/src/udp.c                          1      1      0
patches/lsquic/abort-error-tickable.patch                0      0      0
(+ 295 more files)

@robobun

robobun commented Jun 22, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 7:44 PM PT - Jul 24th, 2026

@dylan-conway, your commit 205c557 is building: #80039

@cirospaciari cirospaciari changed the title node:quic: initial implementation of the Node v26.3.0 QUIC API (WIP) node:quic on lsquic — 154/237 (65.0%) Jun 24, 2026
@cirospaciari cirospaciari changed the title node:quic on lsquic — 154/237 (65.0%) node:quic on lsquic — 163/237 (68.8%), HTTP/3 working Jun 24, 2026
@cirospaciari cirospaciari changed the title node:quic on lsquic — 163/237 (68.8%), HTTP/3 working node:quic on lsquic — 165/237 (69.6%), HTTP/3 working Jun 24, 2026
@cirospaciari cirospaciari changed the title node:quic on lsquic — 165/237 (69.6%), HTTP/3 working node:quic on lsquic — 166/237 (70.0%), HTTP/3 working Jun 24, 2026
@cirospaciari cirospaciari changed the title node:quic on lsquic — 166/237 (70.0%), HTTP/3 working node:quic on lsquic — 167/237 (70.5%), HTTP/3 working Jun 24, 2026
@cirospaciari cirospaciari changed the title node:quic on lsquic — 167/237 (70.5%), HTTP/3 working node:quic on lsquic — 188/237 (79.3%), HTTP/3 working Jul 1, 2026
@cirospaciari cirospaciari changed the title node:quic on lsquic — 188/237 (79.3%), HTTP/3 working node:quic on lsquic — 232/237 (97.9%) Node v26 compat, HTTP/3 working Jul 3, 2026
@cirospaciari
cirospaciari force-pushed the claude/node-quic branch 8 times, most recently from f8d0094 to 918bcfc Compare July 10, 2026 22:39
@cirospaciari
cirospaciari marked this pull request as ready for review July 10, 2026 23:11
Comment thread src/runtime/node/quic/stream.rs Outdated
Comment thread src/js/internal/quic/stats.ts
Comment thread src/js/internal/quic/state.ts
Comment thread packages/bun-usockets/src/udp.c Outdated
Comment thread src/runtime/node/quic/tls.rs Outdated
Comment thread src/lsquic_sys/lib.rs Outdated
Comment thread src/lsquic_sys/lib.rs Outdated
Comment thread src/runtime/node/quic/tls.rs
Comment thread src/jsc/lib.rs Outdated
Comment thread src/runtime/node/quic/tls.rs Outdated
@cirospaciari
cirospaciari force-pushed the claude/node-quic branch 2 times, most recently from bce49b3 to 07eb701 Compare July 11, 2026 01:24
Comment thread patches/lsquic/node-quic-accessors.patch
Comment thread src/runtime/node/quic/stream.rs
Comment thread src/runtime/node/quic/stream.rs
Comment thread packages/bun-usockets/src/node_quic_shim.c
Implements Node v26's experimental `node:quic` surface on top of lsquic,
which is already vendored for HTTP/3 — no new native dependencies.

Covered: endpoints, client/server sessions, bidirectional and
unidirectional streams, HTTP/3 and raw-QUIC applications, datagrams,
0-RTT and session resumption, key updates, path validation and
preferred-address migration, stateless resets, version negotiation,
HTTP/3 ORIGIN frames, qlog, keylog, blocklists, and the stats/state
surface.

Layout:

- `packages/bun-usockets/src/node_quic_shim.c` — C glue for lsquic's
  large, version-sensitive settings/engine-api structs, plus
  `lsquic_stream_if` thunks that bounce into a Rust vtable.
- `src/lsquic_sys/` — safe `Conn`/`Stream`/`Settings` wrappers so unsafe
  stays in one crate.
- `src/runtime/node/quic/{endpoint,session,stream,tls}.rs` — the native
  objects. One UDP socket and up to two lsquic engines per endpoint;
  packets in via `lsquic_engine_packet_in`, out via `ea_packets_out`.
  JS callbacks are never invoked from inside lsquic callbacks: events are
  queued and dispatched after `process_conns`.
- `src/js/internal/quic/*` — the JS layer, ported from Node's
  `internal/quic/*`.
- `patches/lsquic/node-quic-accessors.patch` — lsquic additions Node
  semantics require: per-connection keep-alive/idle/preferred-address
  overrides, millisecond idle timeouts with the RFC 9000 10.1 3xPTO
  floor, path-switch/early-data/origin callbacks, HTTP/3 ORIGIN frame
  read and write (RFC 9412), client-side stateless resets, stream error
  code recording, and a handful of accessors.

The Node v26.3.0 `test-quic-*` suite is vendored under
`test/js/node/test/parallel/`, along with the `common/quic` helpers.
`process.features.quic` is now reported so those tests no longer
self-skip.

Fixes outside `src/runtime/node/quic/` that this needs:

- `JSValue::from_{int,uint}64_no_truncate` allocate a heap BigInt and can
  throw, but returned a bare `JSValue`, so the exception was never
  checked. They now return `JsResult<JSValue>` like the neighbouring
  `from_timeval_no_truncate`; callers in `FFIObject`, `HashObject` and
  the quic binding propagate it. Without this the ASAN runner aborts with
  "exception check validation failed" on any BigInt-returning path.
- `ea_packets_out` assumed one iovec per datagram, but lsquic coalesces
  Initial/Handshake/0-RTT packets into a single datagram with several iovecs.
  The old code clipped anything past a 1452-byte stack buffer and still
  reported the datagram as sent, so the peer dropped an AEAD-invalid packet the
  engine believed had gone out. It now gathers into a reused, grow-on-demand
  buffer; a single-iovec datagram is sent straight from lsquic's memory with no
  copy at all.
- `bsd_sendmmsg`'s Windows branch reported failures only through
  `WSAGetLastError()` and left `errno` stale; callers (including lsquic,
  which closes the connection on any errno other than EAGAIN/EMSGSIZE)
  read `errno`. It now mirrors the Winsock error. Both emulation branches
  (Windows, and the macOS `sendmsg` loop) also discarded the count of
  datagrams already sent on a hard mid-batch error; both now match Linux
  `sendmmsg`: report the count sent, and error only when nothing was.
- `us_udp_socket_send` compared the short-send count against the
  already-decremented remaining count, so the WRITABLE re-arm that drives
  the drain callback was unreachable for single-packet sends.
- `--expose-internals` set only `IS_ALLOWED_TO_USE_INTERNAL_TESTING_APIS`,
  so a release build resolved `internal/test/binding` but not the bare
  `internal/*` specifiers it hands back. `expose_internals_enabled()` no
  longer defaults on in debug builds either — that made `internal/*`
  shadow a same-named npm package in debug only, which no test could
  catch.
- `endpoint.setSNIContexts()` stored nothing and `lookup_cert` ignored the
  servername, so every handshake used the single default certificate —
  including `listen({tls:{sni}})` with several hostnames. Both now resolve
  exact, then `*.suffix`, then `*`. The vendored tests cannot see which
  certificate was served (upstream's own comment says so), so
  `test/js/node/quic/quic-sni.test.ts` asserts on the certificate the client
  received.
- The quic TLS context installed `certs[last]` against `keys[0]`, so a
  two-pair identity failed BoringSSL's cert/key consistency check. Each cert
  is now installed with its own key.
- lsquic's `send_packets_out()` leaked every packet already coalesced into
  the in-progress `out_spec` when encrypting a later one failed (a mid-
  handshake abort takes the `ENCPA_BADCRYPT` path): the staged packets were
  pulled off the scheduled queue but never sent and never returned to the
  connection. One packet + its encrypted buffer per aborted handshake,
  forever. `patches/lsquic/coalesce-batch-drop.patch` gives them back on all
  three bail-outs.
- `QuicStream.destroy()` retracted an already-committed response. With
  `onwanttrailers` configured, `endSync()` records `trailers_pending` rather
  than `fin_pending`, so `destroy()` did not recognize the send side as
  ended and issued a `RESET_STREAM` — and lsquic elides a reset stream's
  frames from packets it has not sent yet, so a throwing `onwanttrailers`
  raced the server's `200` off the wire. It also reset for a merely
  unfinished READ side, which needs `STOP_SENDING` (what `close()` sends),
  never `RESET_STREAM`. `test/js/node/quic/quic-stream.test.ts` asserts the
  peer is not reset.
- Every `connect()` after the first was broken. `connect()` reuses an
  implicit client endpoint, but lsquic fixes HTTP/3-vs-raw framing per
  client ENGINE, decided by the first connect's ALPN — so
  `connect(a, { alpn: 'x' })` followed by `connect(b)` handed the h3
  session a raw engine (and vice versa). `findSuitableEndpoint()` now only
  reuses a mode-compatible endpoint, and an explicit `endpoint:` reused in
  the other mode fails with `ERR_INVALID_STATE` instead of silently
  negotiating an ALPN the engine cannot frame.
- `RareData` is dropped from `VirtualMachine::destroy`, long after
  `destructOnExit` frees the JSC heap, so releasing a `Strong` there
  dereferences a freed `HandleSet`. It now releases its JS handles while the
  heap is alive. This was a heap-use-after-free at exit for any VM that ever
  called `setCallbacks()` (and a latent one for `s3_default_client`); it
  showed up as a SIGABRT under ASAN and a segfault on Windows.
- Vendored `test-process-features.js` was missing Node v26.3.0's `quic`
  entry; re-synced to upstream.
- The node-test runner now forwards only the `// Flags:` entries that gate
  module *resolution* (`--expose-internals`, `--experimental-quic`,
  `--experimental-stream-iter`, `--no-warnings`), since a static import of a
  flag-gated module fails before `common/index.js` can re-spawn. Forwarding
  the rest changed behavior the re-spawn already handles — `--expose_gc` broke
  `test-http-parser-bad-ref` and `--expose_externalize_string` broke
  `test-fs-write`.
- Two upstream tests are marked in `test/expectations.txt` with their reasons:
  `test-quic-reject-unauthorized` (lsquic completes the server handshake where
  ngtcp2 does not) and `test-quic-stream-body-pooled-buffer` (its precondition
  needs a pooled `Buffer.from(string)`, which bun does not do).

The binary grows a uniform 320-528 KB on every target -- an entire new
module carried by the already-vendored lsquic. [skip size check]
Comment thread src/runtime/node/quic/stream.rs Outdated
Comment thread src/runtime/node/quic/quic.classes.ts
Comment thread packages/bun-usockets/src/node_quic_shim.c
Comment thread src/js/internal/quic/http2util.ts
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Heads-up: after this merged, the binary-size CI step started flagging 12 over 0.50 MB on unrelated PRs because the 12 main builds after #79916 never ran the aggregator (canceled/timed-out deps), so every PR was compared against a pre-QUIC baseline. #35906 makes the comparison per-target from the PR's merge-base so this can't recur.

robobun added a commit that referenced this pull request Jul 26, 2026
The binary-size baseline is build #79916 (ae4b17d, 2026-07-25). Since
then 12 commits landed on main including node:quic (#32602), node:repl
(#31827), node:inspector (#31823) and the tls overhaul (#34598); other
PRs branched from current main see the same ~550KB delta (e.g. build
82225). This PR adds two small node:fs ops.
Jarred-Sumner pushed a commit that referenced this pull request Jul 27, 2026
… slowly (#36006)

## Repro

A `Bun.serve` handler that reads `req.body` slower than the client sends
gets no TCP backpressure: the client is never throttled and the whole
remaining body is buffered in server memory.

```js
const s = Bun.serve({
  port: 0,
  maxRequestBodySize: 2 ** 33,
  async fetch(req) {
    let n = 0, mx = 0;
    for await (const c of req.body) {
      n += c.length; mx = Math.max(mx, c.length);
      await Bun.sleep(n / 5e6 * 1000 - performance.now()); // ~5 MB/s sink
    }
    return Response.json({ bytes: n, maxChunkMB: Math.round(mx / 1e6) });
  },
});
// raw-socket client PUTs 60 MB at loopback speed
```

Before: `{"clientWriteMs":116}` / server
`{"bytes":60000000,"maxChunkMB":55,"peakRssMB":204}` (client dumps 60 MB
in 116 ms; one 55 MB chunk).
After: `{"clientWriteMs":10586}` / server `{"maxChunkMB":1}` (client
throttled to the sink rate; every chunk ≤ ~1.4 MB).

The same shape held for `pipeTo(slowWritable)`, for `getReader()` + one
`read()` then idle, and for a handler that did not touch `req.body` at
all while the body arrived. Bun's own `node:http` server already applied
backpressure on the same uWS socket (`NodeHTTPResponse::pause_socket`),
so the primitive was in place; the `Bun.serve` `req.body` path just
never used it.

## Cause

`RequestContext::on_buffered_body_chunk` forwards every uWS `onData`
chunk into `ByteStream::on_data`. When no JS reader is waiting,
`on_data` appends to the ByteStream's internal `buffer: Vec<u8>` and
returns; nothing observed that buffer's size and nothing called
`resp.pause()`. For a body that has not been touched yet the chunk is
appended to `request_body_buf` with the same unbounded growth.

## Fix

**Pause.** `on_buffered_body_chunk` pauses the socket once the
ByteStream's unconsumed buffer (or the pre-stream `request_body_buf`)
crosses a 1 MiB high-water mark. The ByteStream's existing
`signal_drained` (fired from `on_pull` when the buffer empties) is wired
to resume via `on_stream_drained` on the request's `PendingValue`.

**Whole-body consumers.** A consumer that wants the whole body never
drives `on_pull`, so pausing would wedge it:
- `.text()`/`.json()`/`.arrayBuffer()` on an untouched body fire
`on_start_buffering`, which resumes and sets `REQUEST_BODY_BUFFER_ALL`
to suppress further pre-stream pausing.
- `.text()` after `req.body` has been touched goes through the
ByteStream's `buffer_action`; `on_buffered_body_chunk` skips the pause
when `buffer_action` or a native `pipe` is set.
- `Bun.write(file, req)` registered `on_receive_value` without calling
`on_start_buffering`; it now does, mirroring `BodyValueBufferer`.

**Stale-`resp` window.** Once a streaming-response sink calls
`res.end()`, uWS `markDone()` drops `onAborted` and (for `Connection:
close`) the socket may be freed on the next loop tick while the
`RequestContext` still holds `resp`. The sink resumes the socket at both
points it sets `ended_response = true` (same frame as `res.end()`, so
the socket is at worst closed, never freed). Every Rust-side resume path
consults `resp_may_be_freed()` (i.e. `sink.ended_response`) and clears
the flag without dereferencing `resp` once the sink has ended.
`handle_resolve_stream`/`handle_reject_stream` clear
`REQUEST_BODY_PAUSED` and the ByteStream `drain_handler` immediately
after reading `ended_response`, before `detach()`/`run_error_handler`
can re-enter JS. `end_already_responded_stream` and `detach_response`
clear the flag without dereferencing. uWS itself is unchanged, so
node:http's own pause owners (`pausePipelineReads`, `IncomingMessage`
pause, the C++ pipeline-flood guard) are unaffected.

Two `FlagsBits` are added (widening the set to `u32`):
`REQUEST_BODY_PAUSED` and `REQUEST_BODY_BUFFER_ALL`.

## Verification

`bun bd test test/js/bun/http/serve.test.ts -t "request body
backpressure"`

Five tests next to the existing response-side backpressure tests: a
stalled streaming reader, a handler that defers touching `req.body`,
`Bun.write(file, req)` after the pre-stream pause, and `.arrayBuffer()`
with and without `req.body` touched first. Each writes a 32 MiB body
from a raw socket, polls the client's `sent` counter until it plateaus,
and asserts the plateau is well short of the total and the largest chunk
the handler sees is under 4 MiB. All five fail on `main` and pass with
this change; bytes are delivered intact.

The stale-`resp` paths (reader.read and req.text inside a direct-stream
`pull()` after `c.end()` across a loop tick) were verified under ASAN.
`node-http.test.ts` "pipelined responses buffered past the high water
mark pause reads on the connection" passes.

## Binary size

The +~530 KB flagged by the size check is measured against main build
79916, the last passing canary. Twelve commits landed on main between
that baseline and this PR's parent, including `node:quic` (#32602), the
full `node:repl` (#31827), and `node:inspector` (#31823), none of which
has produced a passing canary yet.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 2 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/http/serve.test.ts

<!-- robobun:evidence:end -->
Jarred-Sumner pushed a commit that referenced this pull request Jul 27, 2026
Ports Node v26's `node:quic` module to Bun, backed by lsquic (already
vendored for HTTP/3) — no new native dependencies.

Implements the full experimental `node:quic` surface: endpoints,
client/server sessions, bidi/uni streams, HTTP/3 and raw-QUIC
applications, datagrams, 0-RTT/session resumption, path validation and
preferred-address migration, stateless resets, version negotiation,
HTTP/3 ORIGIN frames, qlog, keylog, blocklists, per-SNI certificates,
and the full stats/state surface.

## Performance

`bench/quic` (in this PR), 500,000 requests at concurrency 50, one QUIC
session with one bidirectional stream per request, linux x64. Bun is
this branch built with LTO; node is v26.3.0 built from source with
`--experimental-quic` (QUIC is compiled out of stock node binaries).

| server | client | req/s |
|---|---|---|
| **bun** | **bun** | **64,591** |
| bun | node | 56,535 |
| node | node | 49,239 |
| node | bun | 49,269 |

End-to-end bun-to-bun is 1.31x node-to-node, and bun is ahead on both
halves: server 56,535 vs 49,239 against the same node client, client
64,591 vs 56,535 against the same bun server.

Concurrency above ~100 does not help either runtime:
`initialMaxStreamsBidi` defaults to 100 (in node too), and past that
every stream waits on a MAX_STREAMS grant. Raising it recovers the
throughput. Peak is at c=50, which is what the table uses.

## Compatibility

All 235 vendored Node v26.3.0 `node:quic` tests pass on a release build.
Zero timeouts. Verified under `BUN_JSC_validateExceptionChecks=1` with
zero unchecked-exception aborts.

Two upstream quic tests are deliberately **not vendored**, because only
passing tests are vendored here. Both reasons were verified against a
from-source QUIC-enabled Node v26.3.0:

- `test-quic-reject-unauthorized` — depends on Node discarding the
client's TLS Finished when `close()` runs in the same event-loop turn as
handshake completion, so the server never completes its handshake and
its `opened` rejects. lsquic flushes the handshake inside
`process_conns`, so our server always completes and `opened` resolves. A
flush-timing artifact rather than a semantic gap; not emulatable without
engine surgery.
- `test-quic-stream-body-pooled-buffer` — the test's own precondition
(`expected.buffer.byteLength > expected.byteLength`) requires
`Buffer.from(string)` to be pooled into a larger ArrayBuffer. Bun
allocates exact-size buffers, so the assertion can never hold. A Buffer
allocation-strategy question, not a QUIC one.

`test-quic-session-initial-rtt` asserts loopback `smoothedRtt < 10ms`.
It passes on release but not on a debug build, where interpreter
overhead exceeds the bound; CI runs these on release.

### Why the diff shows 227 added files, not 235

The diff adds 227 `test-quic-*` files, deletes 3, and renames 1; 7
pre-existing files are untouched. 227 + 1 renamed + 7 untouched = the
235 that run.

The 3 deletions and the rename are one thing: Node v26.3.0 converted the
four `test-quic-internal-*` tests to ESM, so upstream now has only
`.mjs` versions and the `.js` paths 404 in the `v26.3.0` tree. This PR
replaces them in place. Git recorded three as delete+add rather than
renames because the ESM rewrite changed too much to match
(`test-quic-internal-endpoint-stats-state` went from 79 to 233 lines);
`test-quic-internal-setcallbacks` stayed similar enough to be detected
as a rename.

No coverage is lost, in either direction:

- On main, all four `.js` files exit 0 only by self-skipping with `1..0
# Skipped: missing quic` — `node:quic` doesn't exist there, so they have
never asserted anything.
- The four `.mjs` replacements actually execute on this branch (no skip
marker) and pass.
- Three of the four are byte-identical to upstream v26.3.0.
`test-quic-internal-endpoint-stats-state.mjs` differs only by a dropped
four-line `TODO(@jasnell)` comment; no code differs.
- The old `.js` files fail against this branch, as they should: they
were written against the pre-v26 internal surface, and now hit
`internalBinding("block_list") is not implemented`, a missing
`onSessionEarlyDataRejected` callback, and a stale stats-shape
assertion. Keeping them would mean asserting a spec upstream has
replaced.

## Architecture

- `packages/bun-usockets/src/node_quic_shim.c` — thin C glue: lsquic
settings/engine-api setters (the structs are large and
version-sensitive, so Rust never mirrors their layout), and
`lsquic_stream_if` thunks that bounce into a Rust vtable.
- `src/lsquic_sys/` — safe wrappers (`Conn`/`Stream`/`Settings`) so
unsafe stays inside one crate.
- `src/runtime/node/quic/{endpoint,session,stream,tls}.rs` — the native
objects. One UDP socket + up to two lsquic engines per endpoint; packets
in via `lsquic_engine_packet_in`, out via `ea_packets_out`; JS callbacks
are never invoked from inside lsquic callbacks (events are queued and
dispatched after `process_conns`).
- `src/js/internal/quic/*` — the JS layer, ported from Node's
`internal/quic/*`.
- `patches/lsquic/node-quic-accessors.patch` — lsquic additions Node
semantics require: per-conn keep-alive/idle/preferred-address overrides,
millisecond idle timeouts with the RFC 9000 §10.1 3×PTO floor,
path-switch/early-data/origin callbacks, HTTP/3 ORIGIN frame read+write
(RFC 9412), client-side stateless resets (purgatory + packet-response
queue for client engines, NEW_CONNECTION_ID reset-token hashing), stream
error-code recording, and a handful of accessors.

## Behavioral fidelity

Where Node semantics weren't derivable from the API docs, they were
probed empirically against a from-source QUIC-enabled Node v26.3.0
(frame-level ngtcp2 traces), e.g.: a stream created and destroyed in one
turn only manifests its RESET/STOP_SENDING to the peer if the connection
goes quiet — the frames are dropped if other stream data is written
first; silently-destroyed sessions still ACK the packet that triggered
the destroy so the peer idles out instead of eating a stateless reset;
`endpoint.destroy(err)` after `close()` tears down silently.

## Known limitations

- **`session.updateKey()` reports "not updated".** lsquic only
*responds* to a peer-initiated key update; it exposes no way to initiate
one. Node's JS layer discards the return value, so the port matches
upstream — the native stub says why.
- **`cc: "reno"` uses Cubic.** lsquic ships no Reno controller (only
Cubic, BBRv1 and Adaptive); the native side maps `reno` to Cubic so a
user asking for a loss-based algorithm at least gets one, rather than
silently falling through to Adaptive which may pick BBR.
- **`ondatagramstatus(id, status)` reports the wrong id under packet
loss.** lsquic's `on_datagram_status(conn, count, acked)` carries only a
frame count, so the Rust side correlates it against a send-order FIFO;
`got_ack` fires `acked=1` only for packets inside the ACK ranges and
loss detection fires `acked=0` for the gap packets afterwards, so the
FIFO drifts. Counts, the `abandoned` status, and every lossless path are
unaffected. Node's ngtcp2 threads a per-datagram id and needs no
correlation; fixing it here means threading `po_packno` into
`on_dg_write` (a parser-vtable change) plus a packno→ids map. Left as a
follow-up: it changes lsquic's send path and needs a loss-injection
harness to verify, since loopback never drops a packet.

Deferred to follow-ups, each with the reason on its (resolved) review
thread:

- **`maxConnectionsPerHost` is validated but not enforced.** Enforcing
needs per-peer-address accounting in the accept path, and node's exact
semantics (when the count drops, what the peer sees) need probing
against a from-source build first.
- **62-bit CONNECTION_CLOSE codes truncate to 32 bits.** The narrowing
is lsquic's: `lsquic_conn_abort_error` and the `ci_abort_error` vtable
slot behind it both take `unsigned`, so widening means patching its
public signature and every implementor.
- **`packets_out` re-arms the drain on the wrong endpoint** when a spec
routes through another registered endpoint's socket. Needs
preferred-address migration across two endpoints to reach, and no test
does.
- **The stateless-reset broadcast feeds every other endpoint's server
engine.** Scoping it needs a cross-endpoint CID lookup.
- **`lsquic_conn_make_uni_stream` conflates pre-handshake with
going-away.** Separating them is another lsquic patch.
- **The native `state`/`stats` ArrayBuffers are transferable.** Under
`--expose-internals`, detaching or deleting them frees the backing store
while the Rust side keeps the raw pointer; the fix is allocating the
store natively and exposing it as an external ArrayBuffer JSC refuses to
transfer (like node's `AliasedBuffer`), which touches every
`with_state`/`write_stat` site.
- **`options.token` is accepted but not replayed to lsquic.** Handing it
over makes `test-quic-token-secret` and both zero-rtt tests time out, so
the Retry path needs understanding first; the cost is the Retry round
trip the token would have saved.

Also fixed here, each a real bug the quic tests exposed:

- **`RareData` released JSC `Strong` handles after the heap was gone** —
a heap-use-after-free at exit for any VM calling `setCallbacks()`
(SIGABRT under ASAN, segfault on Windows). It now releases them while
the heap is alive.
- **`ea_packets_out` assumed one iovec per datagram** — lsquic coalesces
Initial/Handshake/0-RTT into one datagram with several iovecs, so the
old code clipped past a 1452-byte stack buffer and still reported the
datagram sent. It now gathers into a reused grow-on-demand buffer, and
the single-iovec case sends straight from lsquic's memory with no copy.
- **`us_udp_socket_send`'s drain re-arm was dead code**, so `on_drain`
never fired; it now resumes lsquic's unsent packets.
- Vendored `test-process-features.js` was missing Node v26.3.0's `quic`
entry; re-synced.

<!-- robobun:evidence:begin -->

---

**[review]** gate passed · iteration 53 · 311 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/quic/quic-endpoint.test.ts test/js/node/quic/quic-sni.test.ts test/js/node/quic/quic-stream.test.ts "test/regression/issue/25707.test.ts"
ninja: Entering directory `/workspace/bun/build/debug'
[1/113] gen ErrorCode+*.h
[2/113] gen BunProcess.lut.h
Generating /workspace/bun/build/debug/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[3/113] gen cpp.rs (cppbind)
[4/113] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 240 extern-C blocks audited
[5/113] gen JS modules (bundle-modules)
FAILED: codegen/WebCoreJSBuiltins.cpp codegen/WebCoreJSBuiltins.h codegen/InternalModuleRegistryConstants.h codegen/InternalModuleRegistry+createInternalModuleById.h codegen/InternalModuleRegistry+enum.h codegen/InternalModuleRegistry+numberOfModules.h codegen/NativeModuleImpl.h codegen/SyntheticModuleType.h codegen/GeneratedJS2Native.h codegen/generated_js2native.rs codegen/generated_resolved_source_tag.rs codegen/InternalModuleRegistryConstants.S codegen/In
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (784c975)

test/regression/issue/25707.test.ts:
(pass) require() of CJS file containing dynamic import of non-existent node: module does not fail at load time [11.59ms]
(pass) require() of CJS file with bare dynamic import of non-existent node: module does not fail at load time [9.16ms]
(pass) dynamic import of non-existent node: module in CJS rejects at runtime with correct error [8.80ms]

test/js/node/quic/quic-sni.test.ts:
ExperimentalWarning: quic is an experimental feature and might change at any time
      at node:quic (node:quic:15:20)

(pass) listen({ sni }) serves a different certificate per servername [36.67ms]
(pass) setSNIContexts() replaces and merges identities [11.84ms]
(pass) an identity with several cert/key pairs installs a matching pair [5.09ms]
(pass) setSNIContexts() rejects a non-object and a closed endpoint [3.64ms]
(pass) opened reports the X509 code name for validationErrorCode [4.36ms]

test/js/node/quic/quic-endpoint.test.ts:
(pass) QuicEndpoint client-engine mode > an explicit endpoint rejects a connect() in the other mode [6.29ms]
(pass) server ALPN list > rejects a list mixing HTTP/3 and non-HTTP/3 protocols [
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/quic/quic-endpoint.test.ts test/js/node/quic/quic-sni.test.ts test/js/node/quic/quic-stream.test.ts "test/regression/issue/25707.test.ts"
bun test v1.4.0 (323a71f)

test/regression/issue/25707.test.ts:
(pass) require() of CJS file containing dynamic import of non-existent node: module does not fail at load time [346.33ms]
(pass) require() of CJS file with bare dynamic import of non-existent node: module does not fail at load time [299.37ms]
(pass) dynamic import of non-existent node: module in CJS rejects at runtime with correct error [295.74ms]

test/js/node/quic/quic-sni.test.ts:
ExperimentalWarning: quic is an experimental feature and might change at any time
      at node:quic (node:quic:16:20)

(pass) listen({ sni }) serves a different certificate per servername [451.74ms]
(pass) setSNIContexts() replaces and merges identities [184.04ms]
(pass) an identity with several cert/key pairs installs a matching pair [73.22ms]
(pass) setSNIContexts() rejects a non-object and a closed endpoint [34.20ms]
(pass) opened reports 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 706ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/211] gen ErrorCode+*.h
[2/211] fetch lsquic
[lsquic] up to date
[3/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_arr.c.o
[4/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_adaptive_cc.c.o
[5/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_alarmset.c.o
[6/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_attq.c.o
[7/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_cfcw.c.o
[8/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_crand.c.o
[9/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_bbr.c.o
[10/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_bw_sampler.c.o
[11/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_conn.c.o
[12/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_cubic.c.o
[13/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_di_error.c.o
[14/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_di_hash.c.o
[15/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_di_nocopy.c.o
[16/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_eng_hist.c.o
[17/211] cc obj/vendor/lsquic/src/liblsquic/lsquic_frab_list.c.o
[18/211] cc obj/vendor/
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
Cargo.lock                                         |    5 +
 Cargo.toml                                         |    2 +
 bench/quic/README.md                               |   46 +
 bench/quic/cert.pem                                |   23 +
 bench/quic/client.mjs                              |   87 +
 bench/quic/key.pem                                 |   27 +
 bench/quic/run.mjs                                 |  115 +
 bench/quic/server.mjs                              |   39 +
 packages/bun-usockets/src/bsd.c                    |   54 +-
 packages/bun-usockets/src/internal/internal.h      |    7 +
 packages/bun-usockets/src/internal/loop_data.h     |    6 +
 .../bun-usockets/src/internal/networking/bsd.h     |   10 +-
 packages/bun-usockets/src/loop.c                   |    3 +
 packages/bun-usockets/src/node_quic_shim.c         |  608 +++
 packages/bun-usockets/src/udp.c                    |    2 +-
 patches/lsquic/abort-error-tickable.patch          |   17 +
 patches/lsquic/coalesce-batch-drop.patch           |   69 +
 patches/lsquic/connection-close-pns.patch          |   93 +
 patches/lsquic/hash-nested-iter.patch              |   58 +
 patches/lsquic/node-quic-accessors.patch           | 2037 ++++++++
 scripts/build/deps/lsquic.ts                       |   41 +-
 scripts/runner.node.mjs                            |   18 +-
 src/boringssl_sys/boringssl.rs                     |  150 +-
 src/codegen/generate-js2native.ts                  |    1 +
 src/event_loop/EventLoopTimer.rs                   |    2 +
 src/js/internal/quic/binding.ts                    |    2 +
 src/js/internal/quic/diagnostics.ts                |   70 +
 src/js/internal/quic/http2util.ts                  |  197 +
 src/js/internal/quic/quic.ts                       | 5239 ++++++++++++++++++++
 src/js/internal/quic/state.ts                      |  890 ++++
 src/js/internal/quic/stats.ts                      |  990 ++++
 src/js/internal/quic/symbols.ts                    | 
... (truncated)
```

</details>

**gate history** · 41 passed · 7 rejected · iteration 53

<details><summary>evidence per changed file</summary>

```
file                                                 reads  edits  tests
Cargo.lock                                               0      0      0
Cargo.toml                                               0      0      0
bench/quic/README.md                                     0      0      0
bench/quic/cert.pem                                      0      0      0
bench/quic/client.mjs                                    0      0      0
bench/quic/key.pem                                       0      0      0
bench/quic/run.mjs                                       0      0      0
bench/quic/server.mjs                                    0      0      0
packages/bun-usockets/src/bsd.c                          1      1      0
packages/bun-usockets/src/internal/internal.h            0      0      0
packages/bun-usockets/src/internal/loop_data.h           0      0      0
packages/bun-usockets/src/internal/networking/bsd.h      1      1      0
packages/bun-usockets/src/loop.c                         0      0      0
packages/bun-usockets/src/node_quic_shim.c               0      0      0
packages/bun-usockets/src/udp.c                          1      1      0
patches/lsquic/abort-error-tickable.patch                0      0      0
(+ 295 more files)
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
Jarred-Sumner pushed a commit that referenced this pull request Jul 27, 2026
… slowly (#36006)

## Repro

A `Bun.serve` handler that reads `req.body` slower than the client sends
gets no TCP backpressure: the client is never throttled and the whole
remaining body is buffered in server memory.

```js
const s = Bun.serve({
  port: 0,
  maxRequestBodySize: 2 ** 33,
  async fetch(req) {
    let n = 0, mx = 0;
    for await (const c of req.body) {
      n += c.length; mx = Math.max(mx, c.length);
      await Bun.sleep(n / 5e6 * 1000 - performance.now()); // ~5 MB/s sink
    }
    return Response.json({ bytes: n, maxChunkMB: Math.round(mx / 1e6) });
  },
});
// raw-socket client PUTs 60 MB at loopback speed
```

Before: `{"clientWriteMs":116}` / server
`{"bytes":60000000,"maxChunkMB":55,"peakRssMB":204}` (client dumps 60 MB
in 116 ms; one 55 MB chunk).
After: `{"clientWriteMs":10586}` / server `{"maxChunkMB":1}` (client
throttled to the sink rate; every chunk ≤ ~1.4 MB).

The same shape held for `pipeTo(slowWritable)`, for `getReader()` + one
`read()` then idle, and for a handler that did not touch `req.body` at
all while the body arrived. Bun's own `node:http` server already applied
backpressure on the same uWS socket (`NodeHTTPResponse::pause_socket`),
so the primitive was in place; the `Bun.serve` `req.body` path just
never used it.

## Cause

`RequestContext::on_buffered_body_chunk` forwards every uWS `onData`
chunk into `ByteStream::on_data`. When no JS reader is waiting,
`on_data` appends to the ByteStream's internal `buffer: Vec<u8>` and
returns; nothing observed that buffer's size and nothing called
`resp.pause()`. For a body that has not been touched yet the chunk is
appended to `request_body_buf` with the same unbounded growth.

## Fix

**Pause.** `on_buffered_body_chunk` pauses the socket once the
ByteStream's unconsumed buffer (or the pre-stream `request_body_buf`)
crosses a 1 MiB high-water mark. The ByteStream's existing
`signal_drained` (fired from `on_pull` when the buffer empties) is wired
to resume via `on_stream_drained` on the request's `PendingValue`.

**Whole-body consumers.** A consumer that wants the whole body never
drives `on_pull`, so pausing would wedge it:
- `.text()`/`.json()`/`.arrayBuffer()` on an untouched body fire
`on_start_buffering`, which resumes and sets `REQUEST_BODY_BUFFER_ALL`
to suppress further pre-stream pausing.
- `.text()` after `req.body` has been touched goes through the
ByteStream's `buffer_action`; `on_buffered_body_chunk` skips the pause
when `buffer_action` or a native `pipe` is set.
- `Bun.write(file, req)` registered `on_receive_value` without calling
`on_start_buffering`; it now does, mirroring `BodyValueBufferer`.

**Stale-`resp` window.** Once a streaming-response sink calls
`res.end()`, uWS `markDone()` drops `onAborted` and (for `Connection:
close`) the socket may be freed on the next loop tick while the
`RequestContext` still holds `resp`. The sink resumes the socket at both
points it sets `ended_response = true` (same frame as `res.end()`, so
the socket is at worst closed, never freed). Every Rust-side resume path
consults `resp_may_be_freed()` (i.e. `sink.ended_response`) and clears
the flag without dereferencing `resp` once the sink has ended.
`handle_resolve_stream`/`handle_reject_stream` clear
`REQUEST_BODY_PAUSED` and the ByteStream `drain_handler` immediately
after reading `ended_response`, before `detach()`/`run_error_handler`
can re-enter JS. `end_already_responded_stream` and `detach_response`
clear the flag without dereferencing. uWS itself is unchanged, so
node:http's own pause owners (`pausePipelineReads`, `IncomingMessage`
pause, the C++ pipeline-flood guard) are unaffected.

Two `FlagsBits` are added (widening the set to `u32`):
`REQUEST_BODY_PAUSED` and `REQUEST_BODY_BUFFER_ALL`.

## Verification

`bun bd test test/js/bun/http/serve.test.ts -t "request body
backpressure"`

Five tests next to the existing response-side backpressure tests: a
stalled streaming reader, a handler that defers touching `req.body`,
`Bun.write(file, req)` after the pre-stream pause, and `.arrayBuffer()`
with and without `req.body` touched first. Each writes a 32 MiB body
from a raw socket, polls the client's `sent` counter until it plateaus,
and asserts the plateau is well short of the total and the largest chunk
the handler sees is under 4 MiB. All five fail on `main` and pass with
this change; bytes are delivered intact.

The stale-`resp` paths (reader.read and req.text inside a direct-stream
`pull()` after `c.end()` across a loop tick) were verified under ASAN.
`node-http.test.ts` "pipelined responses buffered past the high water
mark pause reads on the connection" passes.

## Binary size

The +~530 KB flagged by the size check is measured against main build
79916, the last passing canary. Twelve commits landed on main between
that baseline and this PR's parent, including `node:quic` (#32602), the
full `node:repl` (#31827), and `node:inspector` (#31823), none of which
has produced a passing canary yet.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 2 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/http/serve.test.ts

<!-- robobun:evidence:end -->
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.

3 participants