Repository navigation
http3: deliver every header block that lsquic decodes while a stream is read - #42860
Conversation
…is read lsquic 4.9 decodes a header block that follows another one inside lsquic_stream_read, and that call returns 0 at the end of the stream with the block still queued. us_quic_on_read (Bun.serve and fetch) and the node:quic on_stream_read claimed a block only before they read, so they delivered the FIN and the block was lost: a final response with no body behind two 1xx responses, or trailers right behind the request headers. Both readers now claim every queued block before the FIN. A node:quic client also let lsquic queue two decoded blocks. One read then decoded the final response behind a claimed 1xx and returned its DATA, so the body reached JS ahead of onheaders. The node:quic engine now queues one block, as a server already does.
|
Status Reproduced on canary 09bb546 and on a debug build of main:
Bun 1.4.2 passes all three, so this is a regression from the lsquic 4.9.4 update (#42523). The new tests in Fix: this PR. CI, build 116289: 180 of 181 jobs passed. The failed job is the debian 13 x64-asan lane with |
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (6)
Disabled knowledge base sources:
WalkthroughHTTP/3 QUIC handling now delivers multiple decoded header blocks per stream. It defers header flushing through write callbacks and processes headers decoded with EOF. Tests cover informational responses, final responses, bodies, trailers, and request-body ordering. ChangesHTTP/3 header delivery
Suggested reviewers: Merge Risk: ⚪ Minimal · up to No actionable current-head issue remains; the response pipeline preserves final-header-before-body delivery. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 8:47 PM PT - Sep 15th, 2026
⏳ @robobun, your commit c446afd is still building in |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, I also checked the two new post-callback exits: the -1 arm of us_quic_deliver_hset in packages/bun-usockets/src/quic.c reuses the existing s->stream liveness check (nulled only in us_quic_on_close, which lsquic does not run synchronously inside on_read), and the reset break in the 0 arm of on_stream_read in src/runtime/node/quic/stream.rs ends in the same state as the old early return (mark_reset set, no want_read(false), FIN not pushed). The quic.c fetch client keeping lsquic's default of two queued header sets is not observable there because ClientSession holds body bytes until the final headers arrive.
Extended reasoning...
An inline finding is already posted, so this body only records what else was examined. The C helper us_quic_deliver_hset frees s->hset and replaces it before invoking on_stream_headers, then reads s->stream after the callback; s->stream is assigned only at us_quic_on_new_stream and nulled only in us_quic_on_close (which also frees s), which is the same pattern the pre-PR code relied on with if (!s->stream) return;, so the drain loop introduces no new lifetime hazard. In on_stream_read, the claim_header_sets closure returning false inside the 0 arm breaks out of the read loop without pushing the FIN or disabling reads, matching the pre-existing early-return behaviour when the malformed :status block was the first one claimed; got_any may still push a StreamWake, which only wakes consumers of data already delivered. The es_max_header_sets = 1 change lives in node_quic_shim.c and applies to node:quic sessions only; the quic.c client used by fetch() keeps the default, which the description acknowledges and which is masked by ClientSession buffering.
A node:quic server runs onheaders after the lsquic tick that read the request. By then the QPACK encoder stream is out, so lsquic takes three header blocks on the first request of a connection too. The tests fail on main and pass on 1.4.2 without the warm-up.
There was a problem hiding this comment.
I re-reviewed the PR after the warm-up removal and didn't find any bugs; since this changes stream-read control flow in both lsquic consumers and an engine setting, a human look at the lsquic side is still worthwhile.
What was reviewed:
- The earlier inline note about the
/warmrequests is addressed by 8a7eecb: both new tests now exercise the first request of a connection. us_quic_deliver_hsetin quic.c: the post-callbacks->streamliveness check from the old inline block is preserved, and the FIN-arm drain loop returns beforefin_delivered/shutdownwhenon_stream_headersclosed the stream.claim_header_setsin stream.rs: the malformed-request path still resets and stops before anypush_inbound; in the FIN arm itbreaks without pushing the FIN or callingwant_read(false), which matches what the pre-readreturnalready did on main.es_max_header_sets = 1in the node:quic shim applies to both client and server; I could not verify lsquic's default here (novendor/in this checkout), so the "server already has 1" claim is taken from the description.
Extended reasoning...
Overview
The diff touches two lsquic read callbacks — us_quic_on_read in packages/bun-usockets/src/quic.c (fetch/Bun.serve H3) and on_stream_read in src/runtime/node/quic/stream.rs (node:quic) — so that every header block lsquic has decoded is handed up before the FIN is delivered, plus a one-line es_max_header_sets = 1 in node_quic_shim.c. Two test files gain coverage for 100+103 followed by a 204 or a 200+body, and for request trailers on a server. The follow-up commit 8a7eecb removes the /warm warm-up requests that the prior review flagged, so the tests now cover the first request of a connection.
Security risks
No auth, crypto, or credential handling changes. The new loops consume header sets that lsquic already bounded via es_max_header_sets, so there is no new unbounded allocation from peer data. The H3_MESSAGE_ERROR reset for a :status in a request is preserved inside the loop and returns before any body bytes are pushed.
Level of scrutiny
Moderate-to-high: this is native code driven by peer-controlled network input, with a callback (on_stream_headers) that can close the stream re-entrantly. I checked that the C helper re-validates s->stream after the callback and that the FIN-arm loop bails out before fin_delivered/wantread(0)/shutdown(0) when it returns -1. In Rust the closure captures only &QuicStream, the stream wrapper and ctx, is Fn, and the FIN-arm break on a reset skips push_inbound(&[], true) and want_read(false) in the same way the pre-read return on main did; got_any from earlier data still triggers a StreamWake, which is harmless. The engine-setting change is applied for both server and client in the shim; I could not confirm lsquic's default from source in this checkout, so that part rests on the description and the test that the author says distinguishes it (/body ordering).
Other factors
The bug hunt ran dry with no findings, and the only prior inline note (warm-up requests hiding first-request behavior) was addressed by a later commit rather than by author self-resolution. The change is small and the tests are hermetic (port: 0, local node:quic origin, await using/destroy() cleanup). I am not approving outright because the correctness argument depends on lsquic's stream_readf semantics that I could not read here, and a human familiar with the lsquic integration should confirm the es_max_header_sets choice.
|
The lsquic facts this change rests on, at the pinned commit (
|
### Problem
- `Bun.serve({ http3: true })` sends its automatic `100 Continue` only
with the final response. A client that waits for the 100 waits its full
timeout: curl with `Expect: 100-continue` takes 1.01 s over
`--http3-only`, 7 ms over HTTP/1.1 and HTTP/2.
- `fetch()` over HTTP/3 with a streamed request body does not reach the
server until the first chunk exists, so a body whose first chunk waits
for the server never gets there. HTTP/1.1 sends the request headers at
once.
- Cause: `lsquic_stream_send_headers` only buffers.
`us_quic_stream_send_informational`
(`packages/bun-usockets/src/quic.c:1125`) and
`us_quic_stream_send_headers` without `end_stream` (`quic.c:1136`) leave
the flush to the next body write.
### Fix
- Both writers call `us_quic_flush_from_on_write`, which asks for
`on_write`. `us_quic_on_write` flushes after `on_stream_writable`.
- Correct because `on_write` runs in the write phase of the same
`process_conns`, after lsquic wrote a held-back block. A response that
the handler wrote at once is behind the 100 by then. Both still leave in
one STREAM frame.
- Verified: `test/js/bun/http/serve-http3.test.ts` (2 new) and
`test/js/web/fetch/fetch-http3-client.test.ts` (1 new) time out without
it. Also `fetch-http3-*`, `serve-protocols`, `test/js/node/quic/`.
- Self-reviewed: 7 concerns raised, 7 addressed (Notes).
### Background
- lsquic buffers stream writes until a packet is full, the stream ends,
or a flush. `process_conns` runs read callbacks (the request handlers),
then write callbacks.
- On a new connection lsquic holds a header block back until its QPACK
encoder stream is out. A flush then does nothing. lsquic calls
`on_write` right after it writes it.
- Stacked on #42860: without it, a 100 in its own packet makes a reader
lose a final response with no body.
<details><summary>Notes</summary>
**No user reported this.** It was found while #42805 was in work. Reach:
the experimental `http3: true`, a client that sets `Expect` itself over
HTTP/3 and waits (curl does, `fetch()` does not), and a handler that
reads the body before it answers. The cost is the client's expect
timeout on each request, and the request still succeeds. The second
writer costs more: a `fetch()` body whose first chunk depends on the
server hangs until the idle timeout and rejects with `HTTP3StreamReset`.
**curl 8.14.1, 64 KB POST with `Expect: 100-continue`, three runs
each:**
| | `--http3-only` | `--http2` | `--http1.1` |
| --- | --- | --- | --- |
| canary 09bb546 | 1.012 s, 1.012 s, 1.010 s | 7 ms, 6 ms, 7 ms | 6
ms, 5 ms, 7 ms |
| this PR (debug build) | 0.337 s, 0.021 s, 0.017 s | 11 ms | 10 to 13
ms |
Without `Expect`, `--http3-only` takes 8 ms on canary. The first
debug-build run includes the first handshake of that process.
**Why the flush is in `on_write` and not in the two writers.**
- On the first request of a connection, `lsquic_qeh_write_headers`
returns `QWH_PARTIAL` and `send_headers_ietf` stashes the block.
`sm_n_buffered` is 0, so `lsquic_stream_flush` logs `flushing 0 bytes:
noop`. `on_write_header_wrapper` writes the block later and then calls
the user's `on_write`. The second server test covers this (lsquic debug
log: `stashed 4 bytes of header block`, once per run).
- A handler that answers in the same call writes its response in the
read phase. A flush at send time puts the 100 in its own STREAM frame
ahead of it. A flush in `on_write` keeps today's single frame
(`generated STREAM frame: stream 4, offset: 0, size: 55, fin: 1`).
**The flag is taken before the callback.** The `fetch()` client sends
its request from inside `on_stream_writable`. A block sent there is
flushed by the next `on_write`: lsquic calls it at once because
want-write is set again, or after it wrote a held-back block. No user of
`quic.c` clears want-write.
**Cost on the response path.** A response that ends in the same call
cancels the want-write in `stream_shutdown_write`, so no extra
`on_write` runs. A streamed response gets one extra `on_write`, and
`drain()` does nothing there without backpressure.
**Other details.**
- `pending_write_bytes++` in `us_quic_stream_send_informational` covers
a call from outside an lsquic callback (`uws_h3_res_write_continue`).
`us_quic_stream_send_headers` already bumps it.
- lsquic calls `on_close` and destroys a stream only from the service
phase of its tick, so the stream is valid after `on_stream_writable`
returns. A flush on a stream that the callback shut down is a no-op
(`EBADF`).
- Response headers are not a third site. `Bun.serve` has no HTTP/3
caller of `flushHeaders()`, and it sends response headers with the first
chunk on HTTP/1.1 too (701 ms on both for a first chunk that is 700 ms
late).
**#42805 (for #33082).** The two PRs change different functions and pass
together: merged locally, `serve-http3` has 74 passes. Its body says the
fix for the late 100 "needs this retry path". That is the case where the
handler answers while lsquic still holds the 100 back: lsquic refuses
the final block there, with and without this PR. This PR does not change
that case. If the maintainers take the alternative that #42805 offers
and remove the automatic 100 for HTTP/3, the 1xx half of this PR has no
caller left. The request-headers half still stands.
**`node:quic`** has its own header writer with the same gap when a
stream is opened before the handshake completes. It is tracked in #42866
and not changed here. The new-connection test takes `stream.writer`
before `sendHeaders`, as the `server.stop()` test in the same file does,
so it does not depend on that bug.
**With #42860 and this PR**, a busy `node:quic` client that receives the
100 and a later 204 in one batch gets `info 100`, `headers 204` (3 of 3
runs). Without #42860 it lost `headers 204` against this server (0 of
3).
**Suites run on the debug build:** `serve-http3` (65),
`fetch-http3-client` (61), `fetch-http3-adversarial` (27),
`fetch-http3-cold-post` (2), `fetch-http3-syscall-fault` (7),
`serve-protocols` (20), `quic-stream` (6), `quic-endpoint` (7),
`quic-sni` (6), and the other files that use HTTP/3:
`serve-http2-lifecycle` (23), `serve-direct-readable-stream` (149),
`body-stream` (9086). With the sources of #42860, the three new tests
time out.
`fetch-backpressure.test.ts` does not pass on the debug build in my
container: the HTTP/3 receive-backpressure tests time out and the S3
tests fail. It fails the same way there with the sources of
`origin/main`, and it passes in CI.
</details>
<!-- robobun:evidence:begin -->
---
**[human-review]** gate passed · iteration 1 · 3 files touched
<details><summary>fails on main (without fix)</summary>
```console
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/bun/http/serve-http3.test.ts" "test/js/web/fetch/fetch-http3-client.test.ts"
bun test v1.4.3 (09bb546)
test/js/bun/http/serve-http3.test.ts:
(node:409468) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun-debug --trace-warnings ...` to show where the warning was created)
(pass) Bun.serve HTTP/3 > basic GET [1697.81ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1688.75ms]
(pass) Bun.serve HTTP/3 > 204 with no body [1692.65ms]
(pass) Bun.serve HTTP/3 > query string is preserved [1952.17ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [1773.99ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [1830.79ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [1738.99ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [1689.01ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2766.35ms]
(pass) Bun.serve HTTP/3 > maxReque
... (truncated)
release without fix: 1 failed, 1 skipped
bun test v1.4.3-canary.1 (96577ad)
test/js/bun/http/serve-http3.test.ts:
(node:410372) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun --trace-warnings ...` to show where the warning was created)
(pass) Bun.serve HTTP/3 > basic GET [161.08ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [139.08ms]
(pass) Bun.serve HTTP/3 > 204 with no body [182.96ms]
(pass) Bun.serve HTTP/3 > query string is preserved [161.93ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [155.42ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [146.86ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [159.51ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [148.89ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [1039.78ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without Content-Length [150.46ms]
(pass) Bun.serve HTTP/3 > unknown route returns 404 [145.06ms]
(pass) Bun.serve HTTP/3 > routes: handler with :params [163.24ms]
(pass) Bun.serve HTTP/3
... (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/pr_gate.xml" "test/js/bun/http/serve-http3.test.ts" "test/js/web/fetch/fetch-http3-client.test.ts"
bun test v1.4.3 (09bb546)
test/js/bun/http/serve-http3.test.ts:
(node:411333) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun-debug --trace-warnings ...` to show where the warning was created)
(pass) Bun.serve HTTP/3 > basic GET [2068.94ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1988.03ms]
(pass) Bun.serve HTTP/3 > 204 with no body [1811.93ms]
(pass) Bun.serve HTTP/3 > query string is preserved [2127.37ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [2005.83ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [2007.58ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [2383.24ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [2323.66ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2927.11ms]
(pass) Bun.serve HTTP/3 > maxReque
... (truncated)
release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1127ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/10] cc obj/packages/bun-usockets/src/node_quic_shim.c.o
[2/10] cc obj/packages/bun-usockets/src/quic.c.o
[3/10] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 243 extern-C blocks audited
[3/10] cargo bun_runtime → libbun_runtime.a
�[1m�[92m Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/
... (truncated)
```
</details>
<details><summary>diff hotspot</summary>
```
packages/bun-usockets/src/quic.c | 24 ++++++++-
test/js/bun/http/serve-http3.test.ts | 73 ++++++++++++++++++++++++++++
test/js/web/fetch/fetch-http3-client.test.ts | 32 ++++++++++++
3 files changed, 128 insertions(+), 1 deletion(-)
```
</details>
**gate history** · 2 passed · 0 rejected · iteration 1
<details><summary>evidence per changed file</summary>
```
file reads edits tests
packages/bun-usockets/src/quic.c 17 21 67
test/js/bun/http/serve-http3.test.ts 4 9 46
test/js/web/fetch/fetch-http3-client.test.ts 3 8 41
```
</details>
<!-- robobun:evidence:end -->
### Problem
- A `node:quic` client that opens an HTTP/3 stream before the handshake
completes and calls `sendHeaders(..)` without a terminal flag does not
send the request. The HEADERS frame stays in the lsquic stream buffer
until the client writes to `stream.writer` or ends it. A client that
waits for the server (for example for `100 Continue`) waits until the
idle timeout.
- The cause is `bind_raw` in `src/runtime/node/quic/stream.rs:235`. It
hands the queued `pending_headers` to `lsquic_stream_send_headers`,
which only buffers the block. The flush runs in `drain_outbound`, which
runs from the `on_write` callback. `bind_raw` asked for a write event
only when `outbound.started` was already true, so no `on_write` ran.
### Fix
- `bind_raw` now asks for a write event (`s.want_write(true)`) when it
queued at least one non-terminal header block, whatever the state of the
writer.
- This matches the path for a stream that already exists: `send_headers`
calls `kick_write()` after `s.send_headers(..)`. A terminal block does
not need it, because `s.shutdown(1)` makes lsquic flush on its own.
- The resulting `on_write` runs `drain_outbound` with an empty outbound
queue. It clears the write interest and calls `s.flush()`, so the
headers leave. Latency after `sendHeaders` is the same as for a stream
created after `client.opened` (about 30 ms on a debug build).
- Verified: `test/js/node/quic/quic-stream.test.ts` (new test, fails on
stock bun with the idle timeout close). Also `test/js/node/quic/`,
`test/js/bun/http/serve-http3.test.ts` and
`test/js/web/fetch/fetch-http3-client.test.ts`.
### Background
- `QuicStream` wraps one lsquic stream. `createBidirectionalStream`
before the handshake creates the JS object first, with `raw` null.
lsquic calls `on_new_stream` later, and `bind_raw` attaches the lsquic
stream and replays what JS queued in the meantime (`pending_headers`).
- lsquic is event driven: a stream gets an `on_write` callback only
while it has write interest (`lsquic_stream_wantwrite`).
`drain_outbound` is the `on_write` handler. It writes queued body bytes,
then calls `lsquic_stream_flush`.
- `outbound.started` becomes true when JS takes `stream.writer` or
passes a `body`. That is why taking the writer before the handshake hid
the bug.
<details><summary>Notes</summary>
Timing probe against `Bun.serve({ http3: true })` on a debug build, from
`sendHeaders` to the server handler: writer taken before the handshake
59 ms, `await client.opened` first 27 ms, neither (this fix) 38 ms.
PR #42860 and #42867 change the `quic.c` header writers and
`on_stream_read`. They do not touch `bind_raw`.
</details>
Problem
fetch()over HTTP/3 rejects withHTTP3StreamResetwhen two 1xx blocks precede a final response with no body. Anode:quicclient gets noonheaders, and anode:quicserver loses trailers that follow the request headers. Bun 1.4.2 passes. No release has it yet.lsquic_stream_readnow decodes a header block that follows another one, and returns 0 at the end of the stream with that block still queued.us_quic_on_read(packages/bun-usockets/src/quic.c:676) andon_stream_read(src/runtime/node/quic/stream.rs:1084) claim blocks only before they read. They deliver the FIN and lose the block.node:quicclient gets the body beforeonheaders. It queues two decoded blocks, so one read decodes the final response behind a claimed 1xx and returns its DATA.Fix
us_nq_settings_initsetses_max_header_setsto 1, as a server has. lsquic then parses nothing past an unclaimed block, as in 1.4.2.test/js/node/quic/quic-stream.test.ts(2 new) andtest/js/web/fetch/fetch-http3-client.test.ts(/no-body) fail on main and pass on 1.4.2. Alsotest/js/node/quic/,serve-http3,fetch-http3-*,serve-protocols.Background
lsquic_stream_get_hsetclaims the next one.es_max_header_setsbounds the queue. Parsing stops while it is full.lsquic_stream_read, so a block that shares a STREAM frame with an earlier one is decoded during a read.Notes
Found by a read of the code, not from a user report. It came up while I worked on the late
100 ContinueofBun.serve({ http3: true }): a server that sends its 100 early puts it in its own STREAM frame, and that exposes these readers. The flush is #42867, stacked on this PR.Versions. 1.4.2 (744846f, lsquic 3181911) against canary 09bb546 and main (lsquic 4.9.4), same scripts:
fetch()204HTTP3StreamResetnode:quicclientinfo 100, info 103, headers 204info 100, info 103node:quicclientheaders 200before the dataheaders 200after the stream endednode:quicserverontrailersfiresontrailersThe exact window. The block is lost only when one
lsquic_stream_readcall decodes it and also reaches the FIN:on_readruns. lsquic skips its parser on receipt while a decoded block is queued.A 1xx and a final response that arrive in different event loop turns pass on every build, because
on_readruns between them. Trailers that follow DATA were already delivered: the read returns the bytes first, the next read fails on the unclaimed block, and lsquic callson_readagain.lsquic. At the pinned commit,
stream_readfreturns 0 when it read nothing and reached the FIN, also whenuhis not empty (lsquic_stream.c#L1650-L1667).lsquic_stream_readfguards the same state only on entry (#L1693-L1703) and lets the next call fail with "header set not claimed". The queue bound defaults to 1 for a server and 2 for a client (lsquic.h#L330-L335). I did not report this upstream. The two loops are correct with and without an upstream change.Why not a
patches/lsquicpatch. A few lines instream_readfwould cover both readers. The loops need no patch to carry across lsquic updates, and they are also correct against a queue bound above 1.es_max_header_setsforfetch(). Thequic.cclient keeps lsquic's default of 2.ClientSession::deliverholds body bytes until the final headers arrive, so DATA ahead of its HEADERS is not observable there. I found no test that can tell the two values apart forfetch(), so I left it.Each part is live. With the
es_max_header_setsline removed, only the/bodyorder assertion of the client test fails. Withorigin/mainsources, bothnode:quictests and thefetch()/no-bodycase fail on the debug build.Not asserted. The order of
ontrailersagainst the end of the request body on a server. Both events are queued by one lsquic callback, and the body iteration can finish in the microtasks between them. 1.4.2 has the same order.Excluded.
packages/h3blast/src/h3blast.c:435has the same single claim. It is a standalone load generator, it does not link until #42406, and it never sendsExpect.Overlap. #35731 hoists the same claim block of
on_stream_readinto a loop and changes the would-block arm. It leaves the end-of-stream arm alone and predates #42523, so it does not fix this. It needs a rebase onto this change: the closure here has the same shape.Other runs. The 25
test-quic-h3-*.mjsfiles: 24 pass.test-quic-h3-stream-idle-timeout.mjsfails the same way on canary 09bb546.[human-review] gate passed · iteration 0 · 5 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file