node:quic on lsquic — Node v26 compat, HTTP/3 - #32602
Merged
Merged
Conversation
Collaborator
|
Updated 7:44 PM PT - Jul 24th, 2026
@dylan-conway, your commit 205c557 is building: |
cirospaciari
force-pushed
the
claude/node-quic
branch
8 times, most recently
from
July 10, 2026 22:39
f8d0094 to
918bcfc
Compare
cirospaciari
marked this pull request as ready for review
July 10, 2026 23:11
cirospaciari
force-pushed
the
claude/node-quic
branch
from
July 10, 2026 23:11
4595bdb to
3a298db
Compare
cirospaciari
force-pushed
the
claude/node-quic
branch
from
July 10, 2026 23:28
3a298db to
4fe67a3
Compare
cirospaciari
force-pushed
the
claude/node-quic
branch
from
July 11, 2026 00:08
4fe67a3 to
837aa2b
Compare
cirospaciari
force-pushed
the
claude/node-quic
branch
2 times, most recently
from
July 11, 2026 01:24
bce49b3 to
07eb701
Compare
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]
cirospaciari
force-pushed
the
claude/node-quic
branch
from
July 11, 2026 01:56
07eb701 to
36c1dfa
Compare
This was referenced Jul 26, 2026
Collaborator
|
Heads-up: after this merged, the binary-size CI step started flagging |
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.
This was referenced Jul 26, 2026
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 -->
This was referenced Aug 11, 2026
This was referenced Aug 12, 2026
This was referenced Aug 19, 2026
This was referenced Sep 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ports Node v26's
node:quicmodule to Bun, backed by lsquic (already vendored for HTTP/3) — no new native dependencies.Implements the full experimental
node:quicsurface: 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).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:
initialMaxStreamsBididefaults 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:quictests pass on a release build. Zero timeouts. Verified underBUN_JSC_validateExceptionChecks=1with 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 whenclose()runs in the same event-loop turn as handshake completion, so the server never completes its handshake and itsopenedrejects. lsquic flushes the handshake insideprocess_conns, so our server always completes andopenedresolves. 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) requiresBuffer.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-rttasserts loopbacksmoothedRtt < 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.mjsversions and the.jspaths 404 in thev26.3.0tree. 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-statewent from 79 to 233 lines);test-quic-internal-setcallbacksstayed similar enough to be detected as a rename.No coverage is lost, in either direction:
.jsfiles exit 0 only by self-skipping with1..0 # Skipped: missing quic—node:quicdoesn't exist there, so they have never asserted anything..mjsreplacements actually execute on this branch (no skip marker) and pass.test-quic-internal-endpoint-stats-state.mjsdiffers only by a dropped four-lineTODO(@jasnell)comment; no code differs..jsfiles fail against this branch, as they should: they were written against the pre-v26 internal surface, and now hitinternalBinding("block_list") is not implemented, a missingonSessionEarlyDataRejectedcallback, 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), andlsquic_stream_ifthunks 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 vialsquic_engine_packet_in, out viaea_packets_out; JS callbacks are never invoked from inside lsquic callbacks (events are queued and dispatched afterprocess_conns).src/js/internal/quic/*— the JS layer, ported from Node'sinternal/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)afterclose()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 mapsrenoto 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'son_datagram_status(conn, count, acked)carries only a frame count, so the Rust side correlates it against a send-order FIFO;got_ackfiresacked=1only for packets inside the ACK ranges and loss detection firesacked=0for the gap packets afterwards, so the FIFO drifts. Counts, theabandonedstatus, and every lossless path are unaffected. Node's ngtcp2 threads a per-datagram id and needs no correlation; fixing it here means threadingpo_packnointoon_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:
maxConnectionsPerHostis 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.lsquic_conn_abort_errorand theci_abort_errorvtable slot behind it both takeunsigned, so widening means patching its public signature and every implementor.packets_outre-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.lsquic_conn_make_uni_streamconflates pre-handshake with going-away. Separating them is another lsquic patch.state/statsArrayBuffers 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'sAliasedBuffer), which touches everywith_state/write_statsite.options.tokenis accepted but not replayed to lsquic. Handing it over makestest-quic-token-secretand 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:
RareDatareleased JSCStronghandles after the heap was gone — a heap-use-after-free at exit for any VM callingsetCallbacks()(SIGABRT under ASAN, segfault on Windows). It now releases them while the heap is alive.ea_packets_outassumed 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, soon_drainnever fired; it now resumes lsquic's unsent packets.test-process-features.jswas missing Node v26.3.0'squicentry; re-synced.[review] gate passed · iteration 53 · 311 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 41 passed · 7 rejected · iteration 53
evidence per changed file