From 89607c731290ee2c148c11541f73c40b23b00481 Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Sat, 27 Jun 2026 08:52:35 -0400 Subject: [PATCH] net.http: add multiplexed HTTP/2 client connection (H2MuxConn) (#27413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * net.http: add multiplexed HTTP/2 client connection (H2MuxConn) Second phase of the connection-reuse work: a client-side HTTP/2 connection that carries many concurrent request streams, complementing the synchronous single-stream H2Conn (which remains for the one-shot paths). Engine only — nothing routes through it yet; wiring it into the pooling Transport is the next phase. A background reader thread is the transport's only reader and the sole owner of the HPACK decoder and read buffer; it demuxes frames to per-stream state (mutex + condvar + chunk list, so a slow requester can never stall delivery to other streams — backpressure comes from flow control itself, since window credit follows consumption). Writes, the HPACK encoder and stream-id allocation share a write lock, with id allocation + stream registration + HEADERS send forming one atomic critical section. Flow control tracks the connection window plus per-stream windows, applies SETTINGS initial-window deltas to every open stream (RFC 7540 6.9.2), and wakes all blocked senders on growth or connection death. Unlike H2Conn, cancelling one stream (stop_receiving_limit) does not poison the connection: the stream is RST + deregistered, and late DATA for it is absorbed with connection-level WINDOW_UPDATE credit (mirroring the server's unknown-stream backstop). GOAWAY fails only streams above last_stream_id, tagged with h2_err_retryable_code (also used for closed-before-send and admission refusals) so a pool can retry them safely on a fresh connection. Teardown is refcounted: the reader alone marks the connection closed, which makes closing the transport on the last reference race-free. Tested hermetically over an in-memory pipe against a scripted peer: interleaved concurrent streams, send-window blocking at exactly 65535 and resumption on WINDOW_UPDATE, GOAWAY mid-flight (lower id completes, higher fails retryable, new requests refused), stream cancellation with late DATA while another stream completes, HEADERS+CONTINUATION reassembly, and four in-flight waiters all waking when the connection dies. Co-Authored-By: Claude Fable 5 * net.http: fix two H2MuxConn races found in review 1. sent_headers was set after the HEADERS write completed (outside wmu), so a connection dying in that window classified the stream as retryable even though the request may have reached the server — unsafe replay for non-idempotent methods. It is now set pessimistically before the stream becomes visible to the reader. 2. The stream's initial send window was assigned after registration, so a WINDOW_UPDATE (or SETTINGS initial-window delta) arriving immediately after the HEADERS could be overwritten by the assignment, losing credit and potentially blocking an upload forever. Registration now assigns the window atomically with map insertion under a new smu -> fmu nesting; the WINDOW_UPDATE and SETTINGS handlers nest the same way, so credit and deltas strictly serialize with registration (a stream can neither miss a delta nor receive it twice). The lock-order doc is updated: wmu -> smu -> fmu, acyclic. Co-Authored-By: Claude Fable 5 * net.http: fix H2MuxConn build with the current checker The WINDOW_UPDATE handler assigned an if-guard binding (immutable) to a mutable reference, which the current checker rejects ('s is immutable, cannot have a mutable reference to an immutable object') — this broke every CI job, while passing with the older prebuilt compiler used locally. Use the map-or access pattern, which yields a mutable reference directly. Co-Authored-By: Claude Fable 5 * net.http: wake H2MuxConn body senders when a blocked stream is reset A request body sender blocked in send_body_on_stream waiting for window credit only woke for connection death (fmu_dead) or window growth. If the peer reset the stream (RST_STREAM) or repudiated it (GOAWAY) while both windows were exhausted, fail() marked the stream ended and signaled the response condvar but never broadcast fcv, and the send loop never checked per-stream termination -- so do() hung forever, holding the stream's active slot and reference. Add an fmu-guarded send_dead flag: the RST_STREAM and GOAWAY handlers set it and broadcast fcv after failing the stream, and the send loop observes it and returns the stream's terminal error. do()'s existing `or { finish_stream(...) }` then releases the slot/ref. Adds a regression test: the peer RST_STREAMs an upload after the client exhausts its windows, and the upload must error instead of hanging. Reported by Codex review on #27413. Co-Authored-By: Claude Opus 4.8 * net.http: fix three H2MuxConn correctness issues from review Addresses three Codex review findings on the multiplexed HTTP/2 client: - HPACK: a peer SETTINGS_HEADER_TABLE_SIZE bounds the table our encoder uses to compress request headers, not the table we advertised for decoding responses. The old code shrank c.decoder, which could break valid dynamic references in the server's responses. Our encoder never uses HPACK dynamic indexing, so the setting is now correctly a no-op rather than applied to the wrong table. - Retryable errors: a body sender blocked on flow control that wakes via send_dead now preserves the stream's retryable flag, so a POST whose upload is window-blocked when GOAWAY repudiates it (id above last_stream_id) surfaces h2_err_retryable_code and the pool can replay a request the server never processed. send_failure_reason() now returns the flag alongside the message. - cancel_stream now deregisters the stream before sending RST_STREAM, so any in-flight late DATA hits the unknown-stream backstop (which credits the connection-level receive window) instead of being queued against a still-registered stream the requester has stopped draining, which would permanently leak the connection window. Tests: peer HEADER_TABLE_SIZE=0 leaves the response decoder limit at its advertised default; a GOAWAY-repudiated blocked upload fails retryably. Both verified to fail without their fix. The cancel-ordering fix is covered by the existing no-poisoning cancel test (its race is not deterministically reproducible in the hermetic harness). Co-Authored-By: WOZCODE * net.http: harden H2MuxConn against startup race and bad peer SETTINGS Two more correctness fixes from review: - do_on_stream now rechecks closed/goaway/shutting_down under smu right before registering the stream. A terminal event (GOAWAY, or fail_conn after the reader saw the transport die) can land while a request is blocked on wmu after do()'s admission check already passed; that event fails every stream in the map, but this one is not registered yet, so it slipped through and wait_response could block forever. The recheck and the registration happen under the same smu those events use, so it is race-free: either we abort retryably before sending, or we are in the map and any later terminal event fails us. - apply_peer_settings now validates peer SETTINGS and returns an error for out-of-range values (RFC 7540 6.5.2/6.5.3): SETTINGS_MAX_FRAME_SIZE outside 16384..16777215 and SETTINGS_INITIAL_WINDOW_SIZE above 2^31-1. The caller turns the error into fail_conn. A zero max frame size would otherwise make the HEADERS/DATA chunk step 0 and hang the send path in a zero-length-frame loop; a malformed/malicious server could stall senders. Test: an illegal SETTINGS_MAX_FRAME_SIZE (0) fails the connection instead of being accepted (verified to fail without the guard, where the request wrongly succeeds). The startup race is a reader-thread interleaving not deterministically reproducible in the hermetic harness; covered by the recheck plus the existing admission tests. Co-Authored-By: WOZCODE * net.http: retry REFUSED_STREAM resets and interrupt the idle reader on release Two more fixes from a pre-emptive multi-angle review of the mux connection: - RST_STREAM with REFUSED_STREAM now fails the stream as retryable (RFC 7540 8.1.4: the server did not process the request, so it is safe to replay on a fresh connection even for non-idempotent methods). Other reset codes stay non-retryable. Classifying it at fail() also fixes the two downstream exits (the flow-control-blocked body sender via send_failure_reason, and the mid-write body-send failure). - drop_ref now tears the transport down on the last reference even if the reader has not exited yet, via a new teardown_transport that runs close_transport exactly once. Previously an idle connection that was shut down and released leaked the reader thread and socket: the reader blocks in transport.read() and only sets `closed` after a read error/timeout, which never arrives on a silent transport, so the old `refs<=0 && closed` teardown gate never fired. The once-guard makes this path and fail_conn's teardown safe against double-close. Test: a REFUSED_STREAM reset surfaces h2_err_retryable_code (verified to fail when every reset is treated non-retryable). The idle-release leak is a lifecycle path with no test seam yet (close_transport is a bare fn() and the pool wiring is a follow-up), covered by construction. Co-Authored-By: WOZCODE * net.http: harden H2MuxConn flow control and protocol conformance Fixes a cluster of robustness defects found by a pre-emptive multi-angle review of the mux connection, against malformed/malicious or merely unusual (but spec-legal) peers: HIGH - DATA padding now counts toward flow control (RFC 7540 6.9.1). The pad length byte and padding were stripped before accounting, so the client credited back only the delivered bytes and the connection receive window leaked on every padded frame, eventually stalling. H2DataFrame gains a flow_size (full payload) and on_response_data credits the padding overhead immediately (and the whole payload on the unknown-stream path). - PUSH_PROMISE is now a connection error (we advertise ENABLE_PUSH=0; RFC 6.6/8.2). It was ignored, and its dropped fragment could desync the HPACK decoder and corrupt all later headers. - Received HEADERS+CONTINUATION blocks are capped (h2_max_recv_header_block, 1 MiB); previously an endless CONTINUATION stream was unbounded memory (the CONTINUATION-flood DoS). - An on_data callback error now credits the connection window for the bytes drained this round and RSTs the stream (like stop_receiving_limit), instead of leaking that window. - note_write_failure now tears the transport down (via the once-guarded teardown_transport), interrupting the reader so it fails every other in-flight stream, instead of hanging them when only the write side broke. LOW (spec MUSTs) - WINDOW_UPDATE with a 0 increment, or one overflowing 2^31-1, is rejected: a connection error on stream 0, a stream error (RST via the new reset_stream) on a stream (RFC 6.9). - SETTINGS_ENABLE_PUSH other than 0/1 is a connection error (RFC 6.5.2). Tests: padded DATA credits the full payload; PUSH_PROMISE, an invalid ENABLE_PUSH, and a zero stream WINDOW_UPDATE each fail (the last only the offending stream, connection survives). The conn-error tests are verified to fail (request wrongly succeeds) without their guard. Co-Authored-By: WOZCODE * net.http: credit all received DATA when cancelling a mux stream A stream cancelled via stop_receiving_limit (or an on_data callback error) discarded any DATA already queued in s.chunks without crediting the connection receive window, so repeated early cancellations permanently shrank it until unrelated streams stalled. cancel_stream now, after deregistering, sums the still-queued chunk bytes and credits them back, and sets a `cancelled` flag (under s.mu) so a DATA frame still in flight during cancellation is credited at the connection level and dropped by on_response_data instead of being queued onto a stream nobody drains. Checking the flag under the same s.mu that sets it makes the accounting race-free; the chunks' padding was already credited on receipt, so only the chunk data is credited here (no double count). Test: a cancelled stream that received three undrained chunks credits the full payload back to the connection window; verified to hang (window never restored) when the queued-chunk credit is removed. Co-Authored-By: WOZCODE * net.http: fix four H2MuxConn inbound flow-control correctness gaps - Stream recv_window not debited for padding-only DATA frames: the guard `if frame.data.len > 0` skipped `s.recv_window -= flow_size` for frames that carry padding but no data bytes (valid per RFC 7540 §6.1), while the padding credit `send_window_updates(s.id, pad_overhead)` still fired and credited s.recv_window without a prior debit. Each such frame silently inflated the tracked stream window, disabling the FLOW_CONTROL_ERROR check for subsequent real DATA. Fixed by separating the recv_window enforcement (`if frame.flow_size > 0`) from the chunk-append guard (`if frame.data.len > 0`). - reset_stream did not credit conn_recv_window for bytes already buffered in s.chunks when a per-stream protocol error caused an early stream reset (zero-increment WINDOW_UPDATE, stream recv_window overflow). cancel_stream (the voluntary path) correctly drained and credited those bytes via send_conn_window_update; reset_stream did not. Repeated resets with queued data gradually drove conn_recv_window negative, eventually causing spurious FLOW_CONTROL_ERROR against the peer. Fixed by mirroring cancel_stream's drain-and-credit pattern in reset_stream. - send_window_updates previously sent the per-stream WINDOW_UPDATE frame unconditionally even when the stream was no longer in c.streams (e.g. raced with cancel_stream). The send_window_updates fix gates the stream frame on a stream_alive flag set inside the smu critical section. - on_response_data stream recv_window debited data.len instead of flow_size, letting padding bypass stream-level enforcement. Fixed by using flow_size. Co-Authored-By: Richard Wheeler * net.http: propagate write failure in H2MuxConn reset_stream The RST_STREAM write in reset_stream used `or {}`, silently swallowing transport errors. Every other write site calls note_write_failure() on error to set shutting_down=true and tear down the transport. Without it, do() can admit new requests through the !shutting_down guard on a dead connection between the failed RST write and the next write that triggers the teardown. Co-Authored-By: Richard Wheeler * net.http: retire mux connection on stream-ID exhaustion; propagate cancel RST failure Two correctness fixes for H2MuxConn: 1. Stream ID exhaustion: RFC 7540 §5.1.1 forbids client stream IDs above 2^31-1. Once next_stream_id exceeds 0x7fffffff the ID would be masked to a previously-used value on the wire while the stream map is keyed by the raw value, causing responses to be lost or wait_response to hang. do_on_stream now detects this before allocating the ID, sets shutting_down, and returns a retryable error so the pool opens a fresh connection. 2. cancel_stream RST write failure: the RST_STREAM write in cancel_stream used `or {}`, leaving shutting_down=false and the broken connection eligible for can_take_new_request() after a transport write failure. This is the same class of defect fixed in reset_stream (9713c73d9a). The error path now calls note_write_failure() to trigger teardown. Co-Authored-By: WOZCODE * net.http: defer H2MuxConn SETTINGS ACK until after client preface RFC 7540 §3.5 requires the client connection preface to be the first bytes sent on the connection. The reader goroutine starts as soon as new_h2_mux_conn returns, so a server that sends its preface SETTINGS immediately after ALPN can trigger dispatch_frame to write a SETTINGS ACK before handshake_locked has sent the PRI magic + client SETTINGS, violating the preface-first ordering. Fix: when dispatch_frame receives a non-ACK SETTINGS frame and the client preface has not yet been sent (handshaked == false), defer the ACK by setting pending_settings_ack = true (under wmu, so it races safely with handshake_locked). handshake_locked flushes the ACK immediately after writing the preface, before returning to the caller. Both the flag set (dispatch_frame) and the flag check/clear (handshake_locked) run under wmu, so there is no data race. Co-Authored-By: WOZCODE * net.http: proactively retire H2MuxConn when stream ID space is exhausted Two fixes for Codex review round 6 on #27413: 1. Stream ID exhaustion (RFC 7540 S5.1.1): after allocating the last valid client stream ID (0x7fffffff), set shutting_down=true under smu so that can_take_new_request() returns false immediately. Without this, the pool dispatches one extra request that hits the top-of-do_on_stream guard and fails with a retryable error -- an unnecessary round-trip. 2. cancel_stream WINDOW_UPDATE failure: the send_conn_window_update call for queued-but-undrained bytes used or{} and swallowed write errors, leaving shutting_down false while the transport was already dead. Route the failure through note_write_failure() so the pool stops reusing the connection immediately. The RST_STREAM is skipped (peer will not receive it on a dead transport); the stream is already deregistered before this point. Co-Authored-By: WOZCODE * net.http: fix H2MuxConn DATA-after-END_STREAM and reset_stream hang Two correctness fixes in h2_mux_conn: 1. on_response_data: reject DATA frames after END_STREAM. Previously the handler appended payload to s.chunks without checking s.ended, so a misbehaving server could corrupt a completed response with late DATA. Now returns a connection-level protocol error (RFC 7540 §5.1). 2. reset_stream: wake the requester on RST write failure. The stream is deleted from c.streams before the RST write, so fail_conn() cannot find and wake it. Previously a write failure left the requester blocked on s.cv.wait() forever. Now s.fail() and wake_send() are called in the write-failure path. Co-Authored-By: WOZCODE * net.http: fix four H2MuxConn wake/admission/write-failure gaps Four correctness fixes in h2_mux_conn: 1. on_response_headers: wake body sender on early final response. When the server sends HEADERS+END_STREAM (e.g. 403) while the client is blocked waiting for flow-control credit, the sender never woke. Now wake_send is called when end_stream is set. send_body_on_stream is also fixed: in the stream_dead path it checks s.ended first and returns cleanly so do_on_stream proceeds to wait_response; without this the valid server response was lost. 2. do_on_stream: recheck peer stream limit before registering. SETTINGS_MAX_CONCURRENT_STREAMS can arrive while a thread is blocked on wmu after passing do()'s admission check, lowering the limit below active_streams. The recheck under smu now also rejects requests that would exceed the new peer limit. 3. note_write_failure: wake in-flight streams when closer is nil. teardown_transport is a no-op when close_transport is nil, so the reader is never interrupted and fail_conn never runs, leaving response waiters and flow-control-blocked senders hung. Now calls fail_conn directly; fail_conn is once-guarded so a concurrent reader invocation is a safe no-op. 4. fail_conn comment: update to reflect it is no longer reader-only. Co-Authored-By: WOZCODE * net.http: defer H2MuxConn PING ACK and fix SETTINGS ACK count Two fixes to the pre-preface ACK deferral mechanism: 1. PING ACK: the PING handler had no handshaked guard, so a PING received before the first do() could send a PING ACK as the first bytes on the transport, violating RFC 7540 SS3.5 (client preface must be first). The PING frame data is now queued in pending_ping_acks when !handshaked; handshake_locked flushes each entry after the preface. 2. SETTINGS ACK count: pending_settings_ack was a bool, collapsing any number of server SETTINGS frames into a single deferred ACK. RFC 7540 SS6.5 requires one ACK per SETTINGS frame. Changed to an int counter; handshake_locked loops and sends one ACK per pending frame. Co-Authored-By: WOZCODE * net.http: validate H2MuxConn body length and handle 1xx responses Two response-correctness fixes in the multiplexed client: 1. Content-Length validation: wait_response returned the response on a clean END_STREAM without comparing the received body length to a declared Content-Length, so a peer that ended the stream early (or sent too much) handed callers a silently truncated/overlong body. Now errors when body_so_far != Content-Length, skipping responses defined to carry no body (HEAD requests, 204, 304) per RFC 9110. 2. 1xx interim responses: on_response_headers latched headers_done on the first HEADERS block, so an informational 1xx (100 Continue, 103 Early Hints) was treated as the final response and the real final HEADERS was mistaken for trailers and dropped. Now only a >= 200 status latches the response; 1xx blocks are decoded (HPACK sync) and ignored, and the client keeps waiting for the final HEADERS. Co-Authored-By: WOZCODE * net.http: complete H2MuxConn early-response wake, abandon RST, and status validation Response-path correctness fixes (self-review + Codex follow-ups): 1. note_write_failure now calls fail_conn unconditionally, not only when close_transport is nil. fail_conn is idempotent (c.closed guard), so waking every in-flight stream here closes both the window before the reader notices the dead transport and the case of a closer that cannot interrupt a blocking read. 2. send_body_on_stream distinguished a clean early final response from a failure by checking s.ended, but every wake_send is preceded by s.ended = true, so the failure branch was unreachable dead code. Gate on s.err instead; remove the now-unused send_failure_reason. 3. On a clean early final response, send RST_STREAM(CANCEL) to close the client half so the server releases the stream instead of holding it half-open against its concurrency limit (RFC 9113 §8.1). 4. on_response_data now wakes a blocked body sender on END_STREAM, mirroring on_response_headers. An early rejection that carries a body (e.g. 413 + message) ends END_STREAM on DATA, not HEADERS, so the uploader previously hung waiting for a WINDOW_UPDATE that never came. 5. on_response_headers rejects a response with a missing or invalid :status (outside 100..599) as a stream PROTOCOL_ERROR (RFC 9113 §8.3.1) instead of treating it as a 1xx interim and waiting forever. Adds regression tests for the body-carrying early response (would hang without fix 4) and the missing-:status case (would hang without fix 5). Co-Authored-By: WOZCODE * net.http: validate H2MuxConn :status strictly before parsing The :status validation used f.value.int(), but V string.int() is lenient — it stops at the first non-digit and returns the leading number ("200 OK" -> 200, "200x" -> 200) — so a malformed :status passed the range check and was delivered as a successful 200. Validate the raw value is exactly three ASCII digits before converting (RFC 9113 §8.3.1), and also reject 101, which HTTP/2 forbids (§8.1.1). A missing, malformed, out-of-range, or 101 status is reset as a stream-level PROTOCOL_ERROR. Adds a regression test sending "200 OK". Co-Authored-By: WOZCODE * net.http: reset only the stream on H2MuxConn stream-window violations A stream-level receive-window violation (RFC 7540 §6.9.1) in on_response_data did `return error`, which escapes through dispatch_frame to read_loop and calls fail_conn — tearing down the whole connection and failing every other multiplexed stream for one stream''s fault. Handle it like the stream WINDOW_UPDATE overflow path: RST just the offending stream with FLOW_CONTROL_ERROR and credit this frame''s bytes back to the connection window (debited on receipt, never delivered). Only the connection-level window check remains a connection error. Adds a regression test that trips a stream''s window while the connection window stays valid and asserts the request fails but the connection survives (closed stays false). Co-Authored-By: WOZCODE * net.http: scope H2MuxConn DATA-after-END_STREAM error by stream state DATA after the peer''s END_STREAM was always a connection error, which on a multiplexed connection tore down every other in-flight stream for one stream''s fault. RFC 7540 §5.1 distinguishes by state: a "closed" stream (we also sent our END_STREAM/RST) is a connection error, but a "half-closed (remote)" stream (we have not closed our send side) is a STREAM error. Track our own send-side close in a new s.send_closed flag (set when HEADERS carry END_STREAM, when the request body finishes, and when we RST an abandoned upload). on_response_data now checks it: closed -> connection error (the spec MUST); half-closed -> RST just that stream and credit the frame back to the connection window. The check moves before the flow-control debit, since a half-closed-remote receiver is no longer obligated to maintain the stream window (§6.9.1). Adds white-box regression tests for both the closed (connection fails) and half-closed (only the stream is reset, connection survives) cases. Co-Authored-By: WOZCODE * net.http: validate H2MuxConn Content-Length and reject DATA before HEADERS Two response-conformance fixes (RFC 7540 §8.1 / RFC 9113 §8.1.1): 1. Content-Length: the value was parsed with string.u64(), which is lenient ('12junk' -> 12, '0x10' -> 16), so a malformed Content-Length could pass the body-length equality check and a malformed response be returned as success. on_response_headers now rejects a Content-Length that is not strictly decimal digits as a stream PROTOCOL_ERROR, before wait_response trusts it. This is the same lenient-parse class fixed for :status; a shared all_digits() helper now guards both sites. 2. DATA before HEADERS: an HTTP/2 response must begin with HEADERS, but a DATA frame arriving while headers_done is false was queued and later delivered with status 0. on_response_data now resets such a stream (PROTOCOL_ERROR) and credits the frame back to the connection window. Both are stream-scoped resets, so one malformed response does not tear down other multiplexed streams. Adds regression tests for both, and the white-box DATA-after-END_STREAM tests now set headers_done to reflect a realistic ended-stream state. Co-Authored-By: WOZCODE * net.http: guard H2MuxConn Content-Length parse at the point of use on_response_headers already rejects a non-numeric Content-Length, but the lenient u64() in wait_response was not guarded at the parse site, so the invariant "every wire-value parse has a local digit check" did not hold there — and a future change to the upstream check could silently make it lenient again. Guard it locally with all_digits(); a malformed value (which cannot currently reach here) is simply not treated as a length. Co-Authored-By: WOZCODE * net.http: document H2MuxConn transport concurrency requirement The background reader calls transport.read() while request threads call transport.write(), so the transport must be safe for simultaneous read and write on separate threads. A raw TCP socket is full-duplex and safe; a TLS connection is not (one OpenSSL/mbedTLS SSL object must not be read and written at once). Document that wiring this to a TLS backend requires a concurrent-safe transport adapter (serialized or non-blocking I/O — a single blocking I/O mutex would deadlock the reader), to be provided when the pooled transport is wired up. Until then H2MuxConn is exercised only over a full-duplex in-memory pipe in tests, so the hazard is not yet reachable. Co-Authored-By: WOZCODE * net.http: bound H2MuxConn deferred pre-preface control-frame ACKs The client preface is sent lazily on the first request, so a peer can hold the connection pre-handshake and flood PING/SETTINGS frames; each deferred ACK was appended to pending_ping_acks / counted in pending_settings_acks with no cap, growing the buffers without limit (memory DoS) and setting up a large ACK burst when a request finally sends the preface. Cap the combined deferred-ACK count at h2_max_pending_preface_acks (64) and fail the connection once exceeded — mirroring the existing CONTINUATION-flood cap. A well-behaved server sends only its preface SETTINGS (and perhaps a PING) before ours, so the bound never trips legitimately. Adds a regression test that floods PINGs pre-preface and asserts the connection fails. Co-Authored-By: WOZCODE * net.http: add an h2 file/module map to h2_conn.v Adds a short orientation comment at the top of the lead h2 file listing each h2_*.v by responsibility (framing, HPACK, error codes, the sync and multiplexed clients, server, net.http glue, and the SChannel transport), and updates the stale "multiplexing is a follow-up" note now that h2_mux_conn.v provides it. Higher-level than the symbol map (file purpose, not declarations) and lives next to the code so it stays current. Comment-only. Co-Authored-By: WOZCODE * net.http: fix frame-size check and flow-control padding credit mux_read_frame was comparing the incoming frame length against h2_default_max_frame_size (the 16 KiB RFC floor) instead of c.peer_max_frame (the negotiated value). Any server that legitimately raised SETTINGS_MAX_FRAME_SIZE above 16 KiB and then sent a larger frame had the connection torn down with a spurious FRAME_SIZE_ERROR. H2Conn.read_response and H2ServerConn.on_data were crediting only frame.data.len (the stripped payload) back to the receive window instead of frame.flow_size (the full wire payload including padding). Per RFC 7540 §6.9.1 receivers must return flow-control credit for the entire consumed payload. Every padded DATA frame permanently leaked pad_length+1 bytes of receive-window credit, eventually starving the window and stalling the connection. Co-Authored-By: WOZCODE * net.http: fix frame-size limit and body-RST flow-control gaps in H2Conn/H2ServerConn H2Conn.read_frame and H2ServerConn.read_frame were checking incoming frame lengths against the hardcoded 16 KiB floor (h2_default_max_frame_size) rather than the negotiated peer limit stored in c.peer.max_frame_size by apply_settings. A peer that sends SETTINGS_MAX_FRAME_SIZE > 16384 and then sends a larger-but-legal frame had the connection torn down with a spurious FRAME_SIZE_ERROR. Replace with c.peer.max_frame_size, consistent with what mux_read_frame now does. H2ServerConn.on_data was returning early after RST_STREAM on body-too-large requests without crediting the connection-level receive window for the received bytes. Each rejected request permanently shrank the connection window by frame.flow_size, eventually stalling all streams on the connection. Credit the window before sending the RST. Co-Authored-By: WOZCODE * net.http: fix read_frame direction inversion in H2Conn and H2ServerConn The previous commit changed read_frame to use c.peer.max_frame_size, but that field tracks the peer's receive limit (our outbound cap — what they told us they can accept). The inbound frame check must use our own advertised receive limit (what we told them they can send us). Per RFC 7540 §6.5.2, SETTINGS_MAX_FRAME_SIZE means "the sender of this SETTINGS is willing to receive frames up to this size." H2Conn advertises h2_default_max_frame_size in its SETTINGS and never renegotiates it, so the correct inbound limit is that constant. Using c.peer.max_frame_size caused a spurious FRAME_SIZE_ERROR whenever the peer lowered their own receive limit below 16384 (e.g. SETTINGS_MAX_FRAME_SIZE=4096), rejecting valid inbound frames the peer was still allowed to send. Co-Authored-By: WOZCODE * net.http: fix mux_read_frame direction inversion; complete h2_server comment mux_read_frame was using c.peer_max_frame (the server's receive limit / our outbound cap, stored from the peer's SETTINGS_MAX_FRAME_SIZE) to validate inbound frame lengths. Per RFC 7540 §6.5.2, SETTINGS_MAX_FRAME_SIZE means 'the sender can receive frames up to this size', so c.peer_max_frame bounds what we may send to the server, not what the server may send to us. H2MuxConn advertises h2_default_max_frame_size in handshake_locked and never renegotiates it, so the correct inbound limit is that constant. Using c.peer_max_frame caused a spurious FRAME_SIZE_ERROR if the server sent SETTINGS_MAX_FRAME_SIZE < 16384 (a low server receive cap), and silently accepted oversized inbound frames if the server sent a high cap. Also completed the equivalent comment in H2ServerConn.read_frame to note the no-renegotiation invariant that makes the hardcoded constant safe. Co-Authored-By: WOZCODE * net.http: credit queued DATA chunks in H2MuxConn finish_stream When do_on_stream returns an error (e.g. RST_STREAM arriving while send_body_on_stream is still uploading), finish_stream was called with s.chunks potentially holding DATA bytes whose flow_size had already been debited from conn_recv_window. Deleting the stream without crediting those bytes permanently shrank the connection receive window; repeated reset-after-data responses could eventually stall unrelated streams on the same connection. Mirror the drain-and-credit pattern from cancel_stream: set s.cancelled (so any in-flight DATA frame takes the credit-and-drop path instead of re-queuing), drain s.chunks, and call send_conn_window_update for the accumulated byte count. On the success path wait_response always empties chunks before returning, so queued == 0 and no WINDOW_UPDATE is sent. Co-Authored-By: WOZCODE * net.http: credit padding-only DATA frames in H2Conn and H2ServerConn RFC 7540 §6.9.1 requires crediting flow_size (full wire bytes including padding), not data.len (stripped payload). The send_window_update calls were correctly using flow_size as the amount, but were wrapped inside `if frame.data.len > 0 {}` — so a padding-only DATA frame (data.len==0, flow_size>0) skipped the update entirely, permanently shrinking the connection receive window. Fix: move both send_window_update calls outside the data.len gate, guarded by `if frame.flow_size > 0` instead, so padding-only frames are credited. The body-too-large early-return path in H2ServerConn already credits inside its own branch before returning and is unaffected. Co-Authored-By: WOZCODE * net.http: fix H2Conn lenient-parse and CONTINUATION-flood gaps Three issues in h2_conn.v that h2_mux_conn.v already handles correctly: 1. collect_header_block: add h2_max_recv_header_block (1 MiB) cap on the accumulated CONTINUATION fragment, matching the guard in on_response_headers. Without it a malicious server could stream unlimited CONTINUATION frames and exhaust client memory (CVE-2024-27316 class). 2. read_response :status: guard f.value.int() with all_digits(f.value) && f.value.len == 3, matching h2_mux_conn.v on_response_headers. V's .int() is lenient ('200 OK' -> 200), so unguarded parse silently accepts malformed status values from a server. 3. read_response content-length: guard f.value.u64() with all_digits(f.value), matching h2_mux_conn.v. '12junk'.u64() returns 12 without error. Co-Authored-By: WOZCODE * net.http: fix peer_max_frame race in H2MuxConn send_body_on_stream Between fmu.unlock() (where chunk is sized from peer_max_frame) and the subsequent wmu.lock()+DATA write, the reader can process a peer SETTINGS_MAX_FRAME_SIZE shrink and send the ACK under wmu, after which the peer starts enforcing the new limit. The old code sent a DATA frame at the old (larger) size, violating the peer's current SETTINGS. Fix: after acquiring wmu, re-acquire fmu (permitted wmu→fmu order per the file's documented lock hierarchy) to recheck peer_max_frame and return any excess window credit to the connection and stream windows before writing the DATA frame. Co-Authored-By: WOZCODE * net.http: fix H2MuxConn send-window broadcast and H2Conn :status rejection Two correctness gaps found by Codex on 90e221f6e4: h2_mux_conn.v: when peer_max_frame shrinks mid-upload, the wmu->fmu recheck block returned excess bytes to c.send_window but omitted fcv.broadcast(). Goroutines already sleeping in fcv.wait() because the connection window was drained had no other wake-up path (no WINDOW_UPDATE arrives for bytes never sent), leaving uploads permanently blocked. h2_conn.v: the all_digits guard on :status correctly blocked the lenient .int() parse but fell through to got_headers = true on rejection, delivering status 0 with no error to the caller. Now returns an error on any :status value that is not exactly 3 ASCII digits, matching RFC 7540 §8.1.2.3. Co-Authored-By: WOZCODE * net.http: harden H2Conn protocol conformance and HPACK encoder compliance Apply twelve correctness fixes to H2Conn and H2HpackEncoder, discovered during the systematic audit of #27413: h2_conn.v — apply_settings: - Validate SETTINGS_INITIAL_WINDOW_SIZE > 2^31-1 (RFC 7540 §6.5.3 FLOW_CONTROL_ERROR) - Validate SETTINGS_MAX_FRAME_SIZE outside [16384, 16777215] (RFC 7540 §6.5.2) - Compute stream_send_window delta into a local before mutating peer state, so a rejected SETTINGS leaves H2Conn in a consistent state (C1 fix) - Update encoder dynamic table limit and schedule a HPACK Dynamic Table Size Update prefix for the next header block (RFC 7541 §6.3) (C4 fix) h2_conn.v — handle_conn_frame WINDOW_UPDATE: - Check zero increment inside each stream_id branch: connection-level is a connection error (GOAWAY), stream-level is a stream error (RST_STREAM); frames on untracked streams are silently ignored rather than killing the connection (RFC 7540 §6.9) (C5 fix) - Validate connection and stream send windows do not exceed 2^31-1 (RFC 7540 §6.9.1 FLOW_CONTROL_ERROR) h2_conn.v — read_response: - Skip 1xx informational HEADERS blocks; set got_headers only on status >= 200 (RFC 7540 §8.1 / RFC 7231 §6.2) - Guard content-length / has_content_length update with !got_headers so trailer HEADERS frames cannot overwrite body_expected and trigger a spurious truncation error (C6 fix) - Check body completeness (body_so_far < body_expected) after loop, skipping HEAD / 204 / 304 and aborted streams (RFC 7230 §3.3.2) - Return PROTOCOL_ERROR on PUSH_PROMISE; we sent SETTINGS_ENABLE_PUSH=0 and silently ignoring the frame would desync the HPACK dynamic table h2_hpack.v — H2HpackEncoder: - Add pending_max_table_size field (sentinel -1); encode() emits a Dynamic Table Size Update prefix when set, then clears it (RFC 7541 §6.3) (C4 fix) h2_mux_conn.v — send_header_block_locked: - Re-read peer_max_frame under fmu on each CONTINUATION iteration to minimise the TOCTOU window (mirrors send_body_on_stream) Co-Authored-By: WOZCODE * h2: fix two TOCTOU gaps found by Codex on #27413 Finding 1 (send_body_on_stream): the wmu->fmu recheck block already handled peer_max_frame reductions but not s.send_window going negative from an INITIAL_WINDOW_SIZE delta applied by the reader between the outer fmu.unlock() and the inner fmu.lock(). Add an s.send_window < 0 guard that trims the chunk and returns the excess to both windows; if the trimmed chunk reaches 0, release wmu and re-wait for capacity. Finding 2 (send_header_block_locked): the CONTINUATION loop was fixed in the prior commit to re-read peer_max_frame per iteration, but the first multi-frame HEADERS write still used the stale max snapshot from function entry. Add a second fmu.lock/read/unlock immediately before the first HEADERS write; clamp first to block.len so the single-frame fast path is taken if max grew large enough to fit the whole block. Also fixes a compile error introduced in Finding 2's fix: max was declared without mut but later reassigned. Co-Authored-By: WOZCODE * net.http: fix H2ServerConn SETTINGS gaps and H2MuxConn trailers/1xx RST h2_server.v: - CONTINUATION flood: cap hpack_block before appending fragment - HPACK encoder: apply SETTINGS_HEADER_TABLE_SIZE to encoder (set_max_size + pending_max_table_size), not the decoder; prevents COMPRESSION_ERROR - apply_settings: add ! return so callers propagate SETTINGS errors - INITIAL_WINDOW_SIZE: reject values > 2^31-1 (FLOW_CONTROL_ERROR §6.5.3) - MAX_FRAME_SIZE: reject values outside [16384, 16777215] (PROTOCOL_ERROR §6.5.2) h2_mux_conn.v: - INITIAL_WINDOW_SIZE delta: two-pass (validate all streams first, then apply + broadcast) so partial window updates never occur on error (RFC 7540 §6.9.2) - on_response_headers: RST_STREAM if a second HEADERS arrives without END_STREAM (trailers must carry END_STREAM, RFC 7540 §8.1) — previously hung wait_response - on_response_headers: RST_STREAM if 1xx HEADERS arrives with END_STREAM (RFC 9113 §8.1) h2_conn.v: - body completeness: < → != so over-delivery is caught, not just truncation; align error message with h2_mux_conn.v wording h2_conn_test.v: - correct Content-Length in test_h2_on_data_fires_per_chunk: body is 14 bytes (foo=3 + bar=3 + baz quux=8), not 12; fix assertion to match Co-Authored-By: WOZCODE * net.http: fix single-frame HEADERS TOCTOU and H2ServerConn window single-pass h2_mux_conn.v: send_header_block_locked single-frame path (block.len <= max) wrote the HEADERS frame using the peer_max_frame snapshot taken at function entry with no re-read before the write. The multi-frame path already had a re-read immediately before its first HEADERS write; this adds the equivalent re-read inside the single-frame branch. If the peer lowers SETTINGS_MAX_FRAME_SIZE between the snapshot and the write the frame can exceed the new limit and the peer rejects the connection with FRAME_SIZE_ERROR. h2_server.v: apply_settings INITIAL_WINDOW_SIZE arm was single-pass: c.peer.initial_window_size was assigned before the stream send_window loop, so a delta overflow on stream N left streams 1..N-1 with inflated windows and the error return emitted PROTOCOL_ERROR instead of the required FLOW_CONTROL_ERROR. Converted to two-pass (validate all streams first, then commit the assignment and apply deltas) matching H2MuxConn.apply_peer_settings. Also sends GOAWAY(.flow_control_error) before returning the error so the peer receives the correct error code. Co-Authored-By: WOZCODE * net.http: reject malformed Content-Length in H2Conn A non-numeric Content-Length value (e.g. '12junk') caused all_digits() to return false on the combined condition, silently leaving has_content_length false and skipping the body-length completeness check. The response was then returned successfully despite the invalid header. Split the condition so a present-but-non-numeric value returns a stream error ('h2: malformed Content-Length: ') instead of being silently ignored. RFC 9113 §8.2.1 requires malformed field values to be rejected. Co-Authored-By: WOZCODE * net.http: emit HPACK Dynamic Table Size Update in H2MuxConn apply_peer_settings RFC 7541 §6.3 requires that when the peer sends SETTINGS_HEADER_TABLE_SIZE, the encoder MUST emit a Dynamic Table Size Update prefix at the start of the next HEADERS block, even when the encoder uses only literals. The h2_settings_header_table_size arm in H2MuxConn.apply_peer_settings was comment-only, so pending_max_table_size was never set and no update was ever emitted. Add the same set_max_size + pending_max_table_size assignment that H2Conn.apply_settings and H2ServerConn.apply_settings already perform, guarded by wmu since H2MuxConn has concurrent writers. Co-Authored-By: WOZCODE * net.http: serialize H2MuxConn GOAWAY flag with the HEADERS write section do_on_stream rechecks the terminal admission flags (closed/goaway/shutting_down) under smu while holding wmu, then releases smu and writes the request HEADERS still under wmu. The GOAWAY handler set c.goaway under smu only, so a GOAWAY could be observed in the narrow window between that recheck and the HEADERS write. The behavior in that window was already functionally correct -- a stream above last_stream_id is failed and retried, and one at or below it is within the range the peer promised to process -- but writing HEADERS for a new stream after a GOAWAY has been received is a conformance deviation from RFC 7540 §6.8 ("Receivers of a GOAWAY frame MUST NOT open additional streams"). Tighten this by taking wmu before smu (the permitted wmu -> smu nesting) in the GOAWAY handler so c.goaway cannot be set while any writer holds wmu; the recheck in do_on_stream then becomes authoritative. The reader already acquires wmu in the SETTINGS and PING ACK paths, so this introduces no new lock-ordering or liveness property -- and it only sets a flag under wmu rather than writing, so it holds the lock more briefly than those existing paths. Found by Codex on #27413. Co-Authored-By: WOZCODE * net.http: reject END_STREAM on a 1xx response in H2Conn.read_response A 1xx informational response is not final and must not end the stream (RFC 9113 §8.1). The synchronous client discarded a 1xx HEADERS frame with a bare `continue`, skipping the `if frame.end_stream { break }` handling, so a 1xx carrying END_STREAM made read_response loop back to next_frame() and wait forever for a final response the stream could no longer send — a malformed server could hang the request instead of getting an error. Reject END_STREAM in the 1xx interim branch, matching the mux path (H2MuxConn.on_response_headers), which already rejects it. Found by Codex on #27413. Co-Authored-By: WOZCODE * net.http: bring H2Conn.read_response to response-validation parity with the mux path The synchronous client validated far less of a server response than the multiplexed H2MuxConn.on_response_headers/on_response_data path, so a malformed server could hang the request or have a bogus response accepted. Close the gaps, matching the mux path (which already enforces each of these): - Trailers (a second HEADERS block) must carry END_STREAM (RFC 9113 §8.1). Previously a trailers frame without END_STREAM fell through and read_response looped in next_frame() forever. - :status must be present and in range; 101 is forbidden in HTTP/2 (§8.1.1, §8.3.1). Previously a sub-100 or >599 status was latched as a valid response, and 101 was treated as a 1xx interim, waiting forever for a "final" HEADERS. - DATA before the first response HEADERS is a protocol error (§8.1). Previously body bytes were delivered with status 0. The 1xx + END_STREAM rejection added in the previous commit is retained. Each guard fails the request (abandoning the connection) rather than hanging or returning a malformed response, consistent with the existing error returns in read_response. Surfaced while reviewing the 1xx fix for #27413. Co-Authored-By: WOZCODE * net.http: serialize peer SETTINGS_MAX_FRAME_SIZE updates with writes (H2MuxConn) apply_peer_settings updated c.peer_max_frame under fmu only. The send paths (send_header_block_locked, send_body_on_stream) re-read peer_max_frame under fmu and then write the frame under wmu, releasing fmu in between while holding wmu across both. A peer lowering SETTINGS_MAX_FRAME_SIZE could therefore land in the fmu-release -> write gap, so a sender would emit a frame larger than the peer's new limit and the connection would be killed with FRAME_SIZE_ERROR. Update peer_max_frame under wmu (then fmu, the permitted wmu -> fmu nesting) so it serializes with the senders that hold wmu, making their re-read authoritative. This mirrors the h2_settings_header_table_size arm, which already takes wmu to fence the HPACK encoder. apply_peer_settings holds no connection lock at entry, so the wmu acquisition cannot invert any held lock. Guarded by detect_peer_max_frame_wmu.sh (exit 1 if the arm sets peer_max_frame without holding wmu). Co-Authored-By: WOZCODE * net.http: require non-nil close_transport in new_h2_mux_conn (H2MuxConn) The H2Transport interface is read/write only (no close()), so the close_transport callback is the sole mechanism that can interrupt the background reader blocked in transport.read() at teardown. It was documented optional and defaulted to nil; teardown_transport guards the call with `!= unsafe { nil }`, so a nil closer made teardown a silent no-op and leaked the reader thread and the connection on idle retirement (shutdown_when_idle) or last-reference release(). drop_ref's own comment warns of exactly this leak, but only the non-nil path avoids it. Require a non-nil closer: new_h2_mux_conn now panics on nil before spawning the reader, so the teardown -> closer -> reader-exit chain is always available. (Adding close() to H2Transport would break "SSLConn satisfies it directly", since SSLConn has shutdown(), not close().) Tests pass a real closer via a new new_test_mux_conn helper; adds test_mux_release_wakes_reader_via_closer to guard the teardown chain. Codex P2 on #27413 (discussion_r3475761576). Co-Authored-By: WOZCODE * net.http: preserve response trailers on the H2MuxConn path A muxed response can be followed by a second HEADERS block carrying trailers (grpc-status, digest, ...). on_response_headers HPACK-decoded that block but the trailer path only marked the stream ended and dropped every decoded field, so callers on H2MuxConn lost trailer metadata even though the stream completed normally. The synchronous H2Conn.read_response appends non-pseudo trailer fields to H2ClientResponse.headers, so this was a sync/mux response-parity gap. Preserve the non-pseudo trailer fields in a per-stream resp_trailers list and flush them into resp.headers when the stream ends in wait_response, mirroring the sync path. (wait_response snapshots resp_headers once at headers_done time, so trailers cannot simply be appended there.) Codex P2 on #27413 (discussion_r3477245531). Adds test_mux_response_trailers_preserved, which fails on the prior code (trailer fields missing from the response headers). Co-Authored-By: WOZCODE * net.http: reject malformed response/trailer header fields (RFC 9113 §8.2) Both HTTP/2 client paths (sync H2Conn.read_response and mux H2MuxConn.on_response_headers) appended received header fields with only :status and Content-Length validation, silently delivering malformed responses to the caller. RFC 9113 requires a message containing an uppercase field name (§8.2.1), a connection-specific field (connection/keep-alive/proxy-connection/transfer-encoding/upgrade, §8.2.2), or an undefined/duplicate/misplaced pseudo-header (§8.3) to be treated as malformed. The request side already strips these (h2_client.v to_h2_request); the response side did not. Add a shared h2_response_field_error helper and validate every received non-pseudo field on both the response-headers and trailers paths in both files: the mux path resets the stream (PROTOCOL_ERROR), the sync path fails the request. Pseudo-header rules (only :status, before regular fields, no duplicates; none in trailers) are enforced at the call sites. Found by a proactive RFC-conformance audit, not an external review. Adds tests on both paths (connection-specific, uppercase, undefined pseudo, trailer pseudo) and updates the trailers-preserved test to send valid (non-pseudo) trailers. Co-Authored-By: WOZCODE * net.http: restrict outgoing TE header to "trailers" (RFC 9113 §8.2.2) to_h2_request stripped connection-specific headers but left TE untouched, so a caller setting `TE: gzip` produced a malformed HTTP/2 request. RFC 9113 §8.2.2 permits TE on an HTTP/2 request only with the value "trailers". Drop any other TE value; keep `te: trailers`. Adds a test. Co-Authored-By: WOZCODE * net.http: honor peer SETTINGS_MAX_HEADER_LIST_SIZE on requests (RFC 9113 §6.5.2) The sync and server paths recorded the peer advisory SETTINGS_MAX_HEADER_LIST_SIZE but neither client path enforced it, and the mux path did not even store it (apply_peer_settings had no arm for it, so it was silently ignored). A request whose header list exceeded the server limit was emitted and rejected only after the round trip. Store the setting on the mux path (new wmu-guarded field + apply_peer_settings arm, matching the sync/server paths) and, on both client paths, compute the §6.5.2 header-list size (sum of name+value+32 per field) before encoding and refuse an over-limit request locally with a non-retryable error (a fresh connection to the same peer carries the same limit). Default is unlimited, so behavior is unchanged unless the peer sets a limit. Adds tests on both paths. Co-Authored-By: WOZCODE * net.http: reject HEADERS after stream end on the H2MuxConn path (RFC 9113 §5.1) on_response_headers treated any second-or-later HEADERS block as trailers, gating only on `was_headers_done`. A HEADERS frame arriving after the stream already ended (a prior END_STREAM set s.ended) is a frame on a closed stream, not more trailers: with back-to-back trailer + extra HEADERS in one read buffer, the reader could append the extra fields to resp_trailers before wait_response flushed, delivering fields the peer sent after closing the stream. The DATA path already guards on s.ended; the HEADERS path did not. Add an early `if s.ended { reset_stream(.stream_closed); return }` guard after locking the stream, before the trailers handling. Legitimate trailers are unaffected (s.ended is still false when they arrive; it is set afterwards). Codex P2 on #27413 (comment 3482020777). Reproduced statically by detect_mux_headers_after_end.sh (a deterministic runtime repro is not feasible because finish_stream may remove the stream first, racing the reader). Co-Authored-By: WOZCODE --------- Co-authored-by: Alexander Medvednikov Co-authored-by: Claude Fable 5 Co-authored-by: WOZCODE Co-authored-by: Richard Wheeler --- vlib/net/http/h2_client.v | 6 + vlib/net/http/h2_client_test.v | 16 + vlib/net/http/h2_conn.v | 232 +++- vlib/net/http/h2_conn_test.v | 50 +- vlib/net/http/h2_frame.v | 7 +- vlib/net/http/h2_hpack.v | 9 +- vlib/net/http/h2_mux_conn.v | 1701 ++++++++++++++++++++++++ vlib/net/http/h2_mux_conn_test.v | 2123 ++++++++++++++++++++++++++++++ vlib/net/http/h2_server.v | 46 +- 9 files changed, 4155 insertions(+), 35 deletions(-) create mode 100644 vlib/net/http/h2_mux_conn.v create mode 100644 vlib/net/http/h2_mux_conn_test.v diff --git a/vlib/net/http/h2_client.v b/vlib/net/http/h2_client.v index f11fc9b45..c224e54a5 100644 --- a/vlib/net/http/h2_client.v +++ b/vlib/net/http/h2_client.v @@ -48,6 +48,12 @@ fn (req &Request) to_h2_request(method Method, authority string, path string, da continue } for val in header.custom_values(key) { + // RFC 9113 §8.2.2: TE may be sent on an HTTP/2 request, but MUST NOT + // carry any value other than 'trailers'. Drop a non-conformant TE + // rather than generate a malformed request. + if lkey == 'te' && val.trim_space().to_lower() != 'trailers' { + continue + } extra << H2HeaderField{lkey, val} } } diff --git a/vlib/net/http/h2_client_test.v b/vlib/net/http/h2_client_test.v index da61302a2..2ae926960 100644 --- a/vlib/net/http/h2_client_test.v +++ b/vlib/net/http/h2_client_test.v @@ -41,6 +41,22 @@ fn test_to_h2_request_strips_hop_by_hop_and_host() { assert !h2req.headers.any(it.name == 'transfer-encoding') } +fn test_to_h2_request_te_only_trailers() { + // RFC 9113 §8.2.2: TE may be sent on an HTTP/2 request but only with the + // value 'trailers'; any other value must be dropped. + req := Request{} + mut h := new_header() + h.add_custom('TE', 'gzip') or {} + h2req := req.to_h2_request(.get, 'h.example', '/', '', h) + assert !h2req.headers.any(it.name == 'te'), 'a non-trailers TE must be dropped' + + mut h2 := new_header() + h2.add_custom('TE', 'trailers') or {} + h2req2 := req.to_h2_request(.get, 'h.example', '/', '', h2) + te := h2req2.headers.filter(it.name == 'te') + assert te.len == 1 && te[0].value == 'trailers', 'te: trailers must be kept' +} + fn test_to_h2_request_collapses_cookies() { mut h := new_header() h.add(.cookie, 'a=1') diff --git a/vlib/net/http/h2_conn.v b/vlib/net/http/h2_conn.v index d4b58084c..6a9a2c01e 100644 --- a/vlib/net/http/h2_conn.v +++ b/vlib/net/http/h2_conn.v @@ -3,12 +3,26 @@ // that can be found in the LICENSE file. module http +// --- HTTP/2 (RFC 7540 / 7541) file map ----------------------------------------- +// The h2 implementation is split by layer; h2_conn.v (this file) is the lead. +// h2_frame.v binary framing layer (RFC 7540 §4, §6): parse/encode +// h2_hpack.v HPACK header compression (RFC 7541) +// h2_hpack_static.v HPACK static table data (RFC 7541 App. A) +// h2_hpack_huffman*.v HPACK Huffman code + table (generated) +// h2_error.v H2ErrorCode for RST_STREAM / GOAWAY +// h2_conn.v synchronous single-stream client connection (this file) +// h2_mux_conn.v multiplexed client: one connection, many concurrent streams +// h2_server.v server-side connection driver +// h2_client.v glue: net.http Request/Response <-> the h2 layer +// vschannel_h2_windows.c.v HTTP/2 over the Windows SChannel TLS transport +// Each *.v has a sibling *_test.v with hermetic in-memory-transport tests. + // This file implements a minimal HTTP/2 client connection (RFC 7540) on top of // the framing and HPACK layers. It is intentionally synchronous and handles a // single request at a time: it sends one request, then reads frames until that // stream completes, servicing connection-level frames (SETTINGS, PING, -// WINDOW_UPDATE, GOAWAY) inline. Stream multiplexing and a background reader -// are a follow-up; this is the smallest useful, testable client. +// WINDOW_UPDATE, GOAWAY) inline. Concurrent multiplexing over one connection is +// provided separately by h2_mux_conn.v; this remains the smallest synchronous client. // h2_client_preface is the fixed sequence a client sends to start an HTTP/2 // connection, immediately followed by a SETTINGS frame (RFC 7540 Section 3.5). @@ -137,6 +151,15 @@ pub fn (mut c H2Conn) do(req H2ClientRequest) !H2ClientResponse { for h in req.headers { fields << h } + // RFC 9113 §6.5.2: honor the peer's advisory SETTINGS_MAX_HEADER_LIST_SIZE — + // refuse an over-limit request locally instead of having the server reject it + // after the round trip. + if c.peer.max_header_list_size != max_u32 { + size := h2_header_list_size(fields) + if size > u64(c.peer.max_header_list_size) { + return error('h2: request header list (${size} bytes) exceeds peer SETTINGS_MAX_HEADER_LIST_SIZE (${c.peer.max_header_list_size})') + } + } block := c.encoder.encode(fields) has_body := req.body.len > 0 c.send_header_block(stream_id, block, !has_body)! @@ -172,6 +195,7 @@ fn (mut c H2Conn) read_response(stream_id u32, req H2ClientRequest) !H2ClientRes mut got_headers := false mut body_so_far := u64(0) mut body_expected := u64(0) + mut has_content_length := false for { frame := c.next_frame()! if c.handle_conn_frame(frame)! { @@ -183,13 +207,98 @@ fn (mut c H2Conn) read_response(stream_id u32, req H2ClientRequest) !H2ClientRes continue } fragment := c.collect_header_block(frame.fragment, frame.end_headers, stream_id)! - for f in c.decoder.decode(fragment)! { - if f.name == ':status' { - resp.status = f.value.int() - } else if !f.name.starts_with(':') { + // Decode once — HPACK table state is advanced here. + decoded := c.decoder.decode(fragment)! + if got_headers { + // A second HEADERS block carries trailers, which MUST end the + // stream (RFC 9113 §8.1). Without END_STREAM read_response would + // loop forever waiting for a stream end that never comes; the mux + // path resets such a stream, so the synchronous client fails too. + if !frame.end_stream { + return error('h2: trailers HEADERS frame must carry END_STREAM') + } + for f in decoded { + // RFC 9113 §8.1: trailers MUST NOT contain pseudo-header fields; + // the §8.2 field-name rules apply. A malformed field makes the + // response malformed — fail the request (mirrors the mux reset). + if f.name.starts_with(':') { + return error('h2: malformed trailers: pseudo-header ${f.name}') + } + reason := h2_response_field_error(f.name) + if reason != '' { + return error('h2: malformed trailers: ${reason}') + } resp.headers << f - if f.name == 'content-length' { + } + break + } + // First (interim or final) response HEADERS. Find :status. A + // response MUST carry a valid :status (RFC 9113 §8.3.1), and 101 is + // forbidden in HTTP/2 (§8.1.1). + mut status := 0 + mut status_seen := false + for f in decoded { + if f.name == ':status' { + if f.value.len == 3 && all_digits(f.value) { + status = f.value.int() + } else { + return error('h2: malformed :status value: ${f.value}') + } + status_seen = true + break + } + } + if !status_seen || status < 100 || status > 599 || status == 101 { + // Missing / out-of-range / 101 status: fail rather than latch a + // bogus status or (for 101 or a sub-200 interim) loop forever + // waiting for a "final" HEADERS. Mirrors the mux path, which + // resets the stream with PROTOCOL_ERROR. + return error('h2: response with a missing or invalid :status: ${status}') + } + if status >= 100 && status < 200 { + // 1xx informational: discard and continue waiting for the + // final HEADERS block. Do not set got_headers here. + // A 1xx is not a final response and must not end the stream + // (RFC 9113 §8.1); END_STREAM here is malformed. Fail rather + // than loop forever waiting for a final response the stream can + // no longer send. (The mux path rejects this as a stream-level + // PROTOCOL_ERROR; the synchronous client has no other stream to + // keep alive, so a connection-level error is appropriate here.) + if frame.end_stream { + return error('h2: server set END_STREAM on a 1xx informational response') + } + continue + } + // Final response (status >= 200): populate, skipping pseudo-headers. + resp.status = status + mut seen_regular := false + mut seen_status := false + for f in decoded { + if f.name.starts_with(':') { + // RFC 9113 §8.3: in a response only :status is valid; pseudo- + // headers MUST precede regular fields and MUST NOT be duplicated. + // An undefined pseudo, :status after a regular field, or a + // second :status is malformed. + if f.name != ':status' || seen_regular || seen_status { + return error('h2: malformed response: invalid pseudo-header ${f.name}') + } + seen_status = true + continue + } + seen_regular = true + // RFC 9113 §8.2: reject uppercase/empty names and connection-specific + // fields rather than delivering a malformed response to the caller. + reason := h2_response_field_error(f.name) + if reason != '' { + return error('h2: malformed response: ${reason}') + } + resp.headers << f + if f.name == 'content-length' { + if all_digits(f.value) { body_expected = f.value.u64() + has_content_length = true + } else { + return error('h2: malformed Content-Length: ${f.value}') } } } @@ -202,6 +311,12 @@ fn (mut c H2Conn) read_response(stream_id u32, req H2ClientRequest) !H2ClientRes if frame.stream_id != stream_id { continue } + if !got_headers { + // DATA before the response HEADERS is a protocol error + // (RFC 9113 §8.1); the mux path rejects it. Fail rather than + // deliver body bytes for a response that has no status yet. + return error('h2: DATA frame before response HEADERS') + } if frame.data.len > 0 { body_so_far += u64(frame.data.len) // Append the chunk to the response body unless the copy @@ -220,9 +335,15 @@ fn (mut c H2Conn) read_response(stream_id u32, req H2ClientRequest) !H2ClientRes if req.on_data != unsafe { nil } { req.on_data(frame.data, body_so_far, body_expected, resp.status)! } - // Replenish flow control so the peer keeps sending. - c.send_window_update(0, u32(frame.data.len))! - c.send_window_update(stream_id, u32(frame.data.len))! + } + // Replenish flow control using flow_size (full wire payload including + // padding), per RFC 7540 §6.9.1. Credit unconditionally when flow_size>0: + // a padding-only DATA frame (data.len==0, flow_size>0) still consumes the + // peer's send window and must be credited back. (Formerly inside the + // data.len>0 block — padding-only frames leaked window silently.) + if frame.flow_size > 0 { + c.send_window_update(0, u32(frame.flow_size))! + c.send_window_update(stream_id, u32(frame.flow_size))! } if frame.end_stream { break @@ -247,14 +368,28 @@ fn (mut c H2Conn) read_response(stream_id u32, req H2ClientRequest) !H2ClientRes return error('h2: stream reset by peer (${h2_error_code_name(frame.error_code)})') } } + H2PushPromiseFrame { + // We sent SETTINGS_ENABLE_PUSH=0 in the preface; receiving + // PUSH_PROMISE is a connection error (RFC 7540 §8.2 PROTOCOL_ERROR). + // Ignoring it without decoding the embedded HPACK block would also + // desync the dynamic table. + return error('h2: unexpected PUSH_PROMISE (server push was disabled)') + } else { - // PRIORITY / PUSH_PROMISE / stray CONTINUATION / unknown: ignore. + // PRIORITY / stray CONTINUATION / unknown: ignore. } } } if !got_headers { return error('h2: stream closed without a response') } + // Verify body completeness when Content-Length was advertised (RFC 7230 §3.3.2). + // Skip HEAD responses and 204/304 which carry no body by definition. + // Skip early-cancelled streams where we sent RST_STREAM ourselves. + body_allowed := req.method != 'HEAD' && resp.status != 204 && resp.status != 304 + if has_content_length && body_allowed && !c.aborted && body_so_far != body_expected { + return error('h2: response body ${body_so_far} bytes does not match Content-Length ${body_expected}') + } return resp } @@ -272,6 +407,9 @@ fn (mut c H2Conn) collect_header_block(first []u8, end_headers bool, stream_id u return error('h2: CONTINUATION on the wrong stream') } fragment << frame.fragment + if fragment.len > h2_max_recv_header_block { + return error('h2: response header block exceeds ${h2_max_recv_header_block} bytes') + } if frame.end_headers { break } @@ -288,7 +426,7 @@ fn (mut c H2Conn) handle_conn_frame(frame H2Frame) !bool { match frame { H2SettingsFrame { if !frame.ack { - c.apply_settings(frame.settings) + c.apply_settings(frame.settings)! c.send_frame(H2SettingsFrame{ ack: true })! @@ -305,11 +443,34 @@ fn (mut c H2Conn) handle_conn_frame(frame H2Frame) !bool { return true } H2WindowUpdateFrame { + inc := frame.window_size_increment if frame.stream_id == 0 { - c.send_window += i64(frame.window_size_increment) + if inc == 0 { + // RFC 7540 §6.9: connection-level zero increment is a connection + // error (PROTOCOL_ERROR). + return error('h2: connection WINDOW_UPDATE with zero increment (RFC 7540 §6.9 PROTOCOL_ERROR)') + } + new_window := c.send_window + i64(inc) + if new_window > i64(0x7fff_ffff) { + // RFC 7540 §6.9.1: exceeding 2^31-1 is a FLOW_CONTROL_ERROR. + return error('h2: connection flow-control window exceeded 2^31-1 (RFC 7540 §6.9.1)') + } + c.send_window = new_window } else if frame.stream_id == c.cur_stream_id { - c.stream_send_window += i64(frame.window_size_increment) + if inc == 0 { + // RFC 7540 §6.9: stream-level zero increment is a stream error + // (RST_STREAM). H2Conn has no multi-stream tracking so we treat + // it as connection-fatal to avoid accepting a broken stream state. + return error('h2: stream WINDOW_UPDATE with zero increment (RFC 7540 §6.9 PROTOCOL_ERROR)') + } + new_window := c.stream_send_window + i64(inc) + if new_window > i64(0x7fff_ffff) { + return error('h2: stream flow-control window exceeded 2^31-1 (RFC 7540 §6.9.1)') + } + c.stream_send_window = new_window } + // Zero-increment on an untracked stream: silently ignore — H2Conn + // cannot send RST_STREAM for a stream it does not own. return true } H2GoawayFrame { @@ -322,11 +483,19 @@ fn (mut c H2Conn) handle_conn_frame(frame H2Frame) !bool { } } -fn (mut c H2Conn) apply_settings(settings []H2Setting) { +fn (mut c H2Conn) apply_settings(settings []H2Setting) ! { for s in settings { match s.id { h2_settings_header_table_size { c.peer.header_table_size = s.value + // RFC 7541 §6.3: SETTINGS_HEADER_TABLE_SIZE from the server + // constrains our ENCODER's dynamic table (outgoing request headers). + // Evict entries exceeding the new limit immediately so we never + // reference indices the peer has already evicted; the pending flag + // causes encode() to emit the required Dynamic Table Size Update + // prefix at the start of the next header block. + c.encoder.dyn_table.set_max_size(int(s.value)) + c.encoder.pending_max_table_size = int(s.value) } h2_settings_enable_push { c.peer.enable_push = s.value != 0 @@ -335,20 +504,36 @@ fn (mut c H2Conn) apply_settings(settings []H2Setting) { c.peer.max_concurrent_streams = s.value } h2_settings_initial_window_size { - // RFC 7540 Section 6.9.2: a change to the initial window size - // retroactively adjusts the active stream's send window by the - // delta. + if s.value > u32(0x7fff_ffff) { + // RFC 7540 §6.5.3: values above 2^31-1 are a FLOW_CONTROL_ERROR. + return error('h2: peer SETTINGS_INITIAL_WINDOW_SIZE ${s.value} exceeds 2^31-1') + } + // RFC 7540 §6.9.2: a change to the initial window size + // retroactively adjusts the active stream's send window by the delta. + // Validate before mutating so a rejected SETTINGS leaves state consistent. delta := i64(s.value) - i64(c.peer.initial_window_size) + new_window := c.stream_send_window + delta + if new_window > i64(0x7fff_ffff) { + // RFC 7540 §6.9.2: if applying the delta pushes the stream + // window above 2^31-1 it is a FLOW_CONTROL_ERROR. + return error('h2: SETTINGS_INITIAL_WINDOW_SIZE delta overflows stream send window') + } c.peer.initial_window_size = s.value - c.stream_send_window += delta + c.stream_send_window = new_window } h2_settings_max_frame_size { + if s.value < h2_default_max_frame_size || s.value > u32(0x00ff_ffff) { + // RFC 7540 §6.5.2: valid range is 2^14..2^24-1. Values outside + // this range are a connection SETTINGS_ERROR; a zero value would + // also make our HEADERS/DATA chunk step zero and hang senders. + return error('h2: peer SETTINGS_MAX_FRAME_SIZE ${s.value} out of range [16384, 16777215]') + } c.peer.max_frame_size = s.value } h2_settings_max_header_list_size { c.peer.max_header_list_size = s.value } - else {} // unknown settings are ignored (RFC 7540 Section 6.5.2) + else {} // unknown settings are ignored (RFC 7540 §6.5.2) } } } @@ -447,8 +632,11 @@ fn (mut c H2Conn) next_frame() !H2Frame { return c.read_frame()! } -// read_frame reads and decodes one frame from the transport, enforcing our -// advertised max frame size. +// read_frame reads and decodes one frame from the transport, enforcing the +// receive limit we advertised to the peer in our own SETTINGS. This is +// h2_default_max_frame_size, which H2Conn always sends and never renegotiates. +// (c.peer.max_frame_size is the peer's receive limit — our outbound cap — +// and must not be used here.) fn (mut c H2Conn) read_frame() !H2Frame { c.fill_at_least(h2_frame_header_len)! header := h2_parse_frame_header(c.rbuf)! diff --git a/vlib/net/http/h2_conn_test.v b/vlib/net/http/h2_conn_test.v index bad54f7be..72dca5070 100644 --- a/vlib/net/http/h2_conn_test.v +++ b/vlib/net/http/h2_conn_test.v @@ -455,7 +455,7 @@ fn make_capture_fn(cap &ChunkCapture) H2DataFn { fn test_h2_on_data_fires_per_chunk() { mut cap := &ChunkCapture{} - inbound := build_streamed_response('200', '12', [ + inbound := build_streamed_response('200', '14', [ 'foo'.bytes(), 'bar'.bytes(), 'baz quux'.bytes(), @@ -478,7 +478,7 @@ fn test_h2_on_data_fires_per_chunk() { // body_so_far is cumulative including the current chunk. assert cap.running == [u64(3), u64(6), u64(14)] // content-length is reported. - assert cap.expected == [u64(12), u64(12), u64(12)] + assert cap.expected == [u64(14), u64(14), u64(14)] // Status was known by the first callback (headers arrived first). assert cap.status == [200, 200, 200] } @@ -552,3 +552,49 @@ fn test_h2_stop_receiving_limit_breaks_early() { } assert false, 'expected error on reuse after early termination' } + +// RFC 9113 §8.2: the synchronous client must reject a response carrying a +// malformed field (connection-specific header or uppercase field name) rather +// than delivering it — parity with the mux path. Conformance gap G1. +fn test_h2_conn_rejects_malformed_response_fields() { + // A connection-specific header (§8.2.2) is forbidden in HTTP/2. + inbound1 := build_server_stream([H2HeaderField{':status', '200'}, + H2HeaderField{'transfer-encoding', 'chunked'}], []) + mut c1 := new_h2_conn(&MockTransport{ inbound: inbound1 }) + if _ := c1.do(H2ClientRequest{ authority: 'h.example' }) { + assert false, 'connection-specific response header was accepted' + } else { + assert err.msg().contains('transfer-encoding'), 'unexpected error: ${err.msg()}' + } + // An uppercase field name (§8.2.1) is malformed. + inbound2 := build_server_stream([H2HeaderField{':status', '200'}, + H2HeaderField{'Content-Type', 'text/plain'}], []) + mut c2 := new_h2_conn(&MockTransport{ inbound: inbound2 }) + if _ := c2.do(H2ClientRequest{ authority: 'h.example' }) { + assert false, 'uppercase response header name was accepted' + } else { + assert err.msg().contains('uppercase'), 'unexpected error: ${err.msg()}' + } +} + +// RFC 9113 §6.5.2: the client honors the peer's advisory +// SETTINGS_MAX_HEADER_LIST_SIZE and refuses an over-limit request rather than +// emitting it. Conformance gap G4 (set white-box: on the sync path the peer's +// SETTINGS arrive only with the response, after the request is built). +fn test_h2_conn_respects_peer_max_header_list_size() { + mut c := new_h2_conn(&MockTransport{ + inbound: build_server_stream([H2HeaderField{':status', '200'}], []) + }) + c.peer.max_header_list_size = 40 // tiny: even the pseudo-headers exceed it + if _ := c.do(H2ClientRequest{ + method: 'GET' + scheme: 'https' + authority: 'example.com' + path: '/a-fairly-long-path-to-exceed-the-limit' + }) + { + assert false, 'over-limit request was sent' + } else { + assert err.msg().contains('MAX_HEADER_LIST_SIZE'), 'unexpected error: ${err.msg()}' + } +} diff --git a/vlib/net/http/h2_frame.v b/vlib/net/http/h2_frame.v index 2be36d1c6..ae7c651d2 100644 --- a/vlib/net/http/h2_frame.v +++ b/vlib/net/http/h2_frame.v @@ -58,8 +58,12 @@ pub: pub struct H2DataFrame { pub: stream_id u32 - data []u8 + data []u8 // payload with any padding stripped end_stream bool + // flow_size is the full received payload length (pad-length byte + data + + // padding) that counts toward flow control (RFC 7540 6.9.1). For outgoing + // frames it is left 0 and unused; the parser sets it for received frames. + flow_size int } pub struct H2HeadersFrame { @@ -247,6 +251,7 @@ pub fn h2_parse_frame(header H2FrameHeader, payload []u8) !H2Frame { stream_id: header.stream_id data: body.clone() end_stream: header.flags & h2_flag_end_stream != 0 + flow_size: payload.len } } h2_frame_headers { diff --git a/vlib/net/http/h2_hpack.v b/vlib/net/http/h2_hpack.v index 102a5b81e..57fb15f39 100644 --- a/vlib/net/http/h2_hpack.v +++ b/vlib/net/http/h2_hpack.v @@ -205,12 +205,19 @@ fn h2_hpack_find_static(name string, value string) (int, bool) { // decoder state trivially in sync while remaining fully interoperable. pub struct H2HpackEncoder { pub mut: - dyn_table H2DynTable + dyn_table H2DynTable + pending_max_table_size int = -1 // -1 = no pending update; ≥0 = emit size update on next encode } // encode returns the HPACK header block for `fields`. pub fn (mut e H2HpackEncoder) encode(fields []H2HeaderField) []u8 { mut out := []u8{} + if e.pending_max_table_size >= 0 { + // RFC 7541 §6.3: emit Dynamic Table Size Update at the start of the + // first header block after a peer-requested table size change. + h2_hpack_write_int(mut out, u64(e.pending_max_table_size), 5, 0x20) + e.pending_max_table_size = -1 + } for f in fields { e.encode_field(mut out, f) } diff --git a/vlib/net/http/h2_mux_conn.v b/vlib/net/http/h2_mux_conn.v new file mode 100644 index 000000000..d3d12bcbb --- /dev/null +++ b/vlib/net/http/h2_mux_conn.v @@ -0,0 +1,1701 @@ +// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. +module http + +import sync +import time + +// This file implements a multiplexed HTTP/2 client connection: one connection +// carries many concurrent request streams. A background reader thread (the +// only reader of the transport, and the sole owner of the HPACK decoder and +// read buffer) demuxes incoming frames to per-stream state; request threads +// wait on their stream's condition variable. It complements the synchronous +// single-stream H2Conn in h2_conn.v, which remains for the one-shot paths. +// +// Locking protocol — a thread holds at most one of {smu, fmu, wmu} at a time, +// with two permitted nestings: wmu → smu and smu → fmu (so wmu → smu → fmu). +// No lock is ever taken in the reverse direction, which keeps the order +// acyclic. A stream's own mu is never held while taking a connection lock. +// The smu → fmu nesting is what serializes stream registration (which assigns +// the stream's initial send window) against WINDOW_UPDATE credit and SETTINGS +// initial-window deltas, so early credit can never be lost. +// wmu transport writes, the HPACK encoder, stream-id allocation + +// registration + HEADERS send (one atomic critical section), and +// the lazy connection preface. +// fmu/fcv flow control: the connection send window, every stream's send +// window, and the peer's initial-window/max-frame mirrors. fcv is +// broadcast whenever a window can grow and when the connection +// dies, so blocked senders always wake. +// smu connection state: the stream map, refcount, active stream count, +// goaway/closed/shutting_down, and the peer max-streams mirror. + +// h2_max_recv_header_block caps the total size of a received HEADERS(+ +// CONTINUATION) block. Without it a peer can stream unbounded CONTINUATION +// frames and exhaust memory (the CONTINUATION-flood DoS, CVE-2024-27316 class). +const h2_max_recv_header_block = 1024 * 1024 + +// h2_max_pending_preface_acks caps how many control-frame ACKs (SETTINGS + PING) +// may be deferred while waiting to send the lazy client preface. A well-behaved +// server sends only its preface SETTINGS (and perhaps a PING) before ours, so a +// small bound is ample; without it a peer that holds the connection pre-handshake +// could flood PING/SETTINGS and grow the deferred-ACK buffers without limit. +const h2_max_pending_preface_acks = 64 + +// h2_err_retryable_code tags stream errors where the request provably never +// reached server processing (GOAWAY-unprocessed, connection closed before the +// request was sent, admission refused), so it is safe to retry on a fresh +// connection even for non-idempotent methods. +pub const h2_err_retryable_code = -20012 + +// h2_retryable_error builds an error carrying h2_err_retryable_code. +fn h2_retryable_error(reason string) IError { + return error_with_code('h2: ${reason}', h2_err_retryable_code) +} + +// all_digits reports whether s is non-empty and every byte is an ASCII digit. +// Header values must be validated with this before lenient string.int()/.u64() +// parsing, which otherwise accept malformed input like '200 OK' or '12junk'. +fn all_digits(s string) bool { + if s.len == 0 { + return false + } + for ch in s { + if ch < `0` || ch > `9` { + return false + } + } + return true +} + +// h2_conn_specific_headers are connection-specific header fields that MUST NOT +// appear in any HTTP/2 message (RFC 9113 §8.2.2). A received response or trailer +// carrying one is malformed. (TE is the request-only exception and is handled on +// the send side, so it is not listed here.) +const h2_conn_specific_headers = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', + 'upgrade'] + +// h2_response_field_error returns a non-empty reason when a regular (non-pseudo) +// received header field is malformed per RFC 9113 §8.2, or '' when it is valid. +// Field names must be non-empty and lowercase (§8.2.1), and connection-specific +// fields are forbidden (§8.2.2). A malformed field makes the whole message +// malformed (§8.1.1); the mux path resets the stream and the sync path fails the +// request rather than delivering it. Pseudo-header validity is checked at the +// call site (the set of valid pseudo-headers differs between headers/trailers). +fn h2_response_field_error(name string) string { + if name.len == 0 { + return 'empty header field name' + } + for ch in name { + if ch >= `A` && ch <= `Z` { + return 'uppercase header field name "${name}"' + } + } + if name in h2_conn_specific_headers { + return 'connection-specific header field "${name}"' + } + return '' +} + +// h2_header_list_size returns the RFC 9113 §6.5.2 size of a header list: the sum +// over all fields of (name length + value length + 32 octets of per-field +// overhead). Used to honor the peer's advisory SETTINGS_MAX_HEADER_LIST_SIZE. +fn h2_header_list_size(fields []H2HeaderField) u64 { + mut total := u64(0) + for f in fields { + total += u64(f.name.len) + u64(f.value.len) + 32 + } + return total +} + +// H2MuxStream is the client-side state of one in-flight request stream. +@[heap] +struct H2MuxStream { +mut: + id u32 + // --- response state, guarded by mu, signaled via cv --- + mu &sync.Mutex = unsafe { nil } + cv &sync.Cond = unsafe { nil } + status int + resp_headers []H2HeaderField + resp_trailers []H2HeaderField // non-pseudo fields from a trailing HEADERS block + headers_done bool + chunks [][]u8 // DATA payloads appended by the reader, drained by the requester + body_rcvd u64 // cumulative DATA bytes received + ended bool // END_STREAM, RST, or connection death + err string // non-empty: the stream failed + retryable bool // the failure is safe to retry on a fresh connection + sent_headers bool // the request HEADERS reached the transport + cancelled bool // requester abandoned the stream; drop+credit late DATA + send_closed bool // we closed our send side (sent END_STREAM or RST_STREAM) + // --- send state, guarded by the connection's fmu --- + send_window i64 + send_dead bool // RST/GOAWAY: wake a body sender blocked on flow-control credit + // --- receive state, guarded by mu --- + recv_window i64 = i64(h2_default_initial_window) // our receive window (what we advertised) +} + +fn new_h2_mux_stream() &H2MuxStream { + mu := sync.new_mutex() + return &H2MuxStream{ + mu: mu + cv: sync.new_cond(mu) + } +} + +// fail marks the stream failed and wakes its requester. +fn (mut s H2MuxStream) fail(msg string, retryable bool) { + s.mu.lock() + if !s.ended { + s.err = msg + s.retryable = retryable + s.ended = true + s.cv.signal() + } + s.mu.unlock() +} + +// H2MuxConn is a multiplexed client-side HTTP/2 connection, safe for +// concurrent requests from multiple threads. +@[heap] +pub struct H2MuxConn { +mut: + transport H2Transport + // close_transport is called exactly once during teardown and MUST close or + // interrupt the transport so the reader thread (blocked in transport.read()) + // can exit. It is required (non-nil): the H2Transport interface has no close() + // of its own, so this is the only way to unblock the reader. new_h2_mux_conn + // panics on a nil closer. + close_transport fn () = unsafe { nil } + // --- guarded by wmu --- + wmu &sync.Mutex = sync.new_mutex() + encoder H2HpackEncoder + next_stream_id u32 = 1 + handshaked bool + pending_settings_acks int // count of SETTINGS frames received before our preface; each needs one ACK + pending_ping_acks [][]u8 // PING frames received before our preface; each needs an ACK with the same data + peer_max_header_list_size u32 = max_u32 // peer SETTINGS_MAX_HEADER_LIST_SIZE (advisory, §6.5.2) + // --- guarded by fmu, fcv signals growth/death --- + fmu &sync.Mutex = unsafe { nil } + fcv &sync.Cond = unsafe { nil } + send_window i64 = i64(h2_default_initial_window) + peer_initial_window i64 = i64(h2_default_initial_window) + peer_max_frame u32 = h2_default_max_frame_size + fmu_dead bool // mirror of `closed` for blocked senders + // --- guarded by smu --- + smu &sync.Mutex = sync.new_mutex() + streams map[u32]&H2MuxStream + refs int = 1 // the owner's (pool's) reference; +1 per in-flight request + active_streams int + max_streams u32 = 100 // our own cap on concurrent streams + peer_max_streams u32 = max_u32 + goaway bool + goaway_last u32 + closed bool // the reader has exited; only the reader sets this + shutting_down bool + transport_torn_down bool // close_transport has been (or is being) called + conn_err string + idle_since time.Time + // --- guarded by recv_wmu --- + // Inbound flow-control: tracks how much more data the peer is allowed to + // send us (our advertised receive windows). Debited on DATA arrival, + // replenished when we send WINDOW_UPDATE. recv_wmu is always acquired + // solo — never while holding any other connection lock. + recv_wmu &sync.Mutex = sync.new_mutex() + conn_recv_window i64 = i64(h2_default_initial_window) + // --- reader-thread private (no locks) --- + decoder H2HpackDecoder + rbuf []u8 +} + +// new_h2_mux_conn creates a multiplexed connection over `transport` and starts +// its background reader. The connection preface is sent lazily with the first +// request. `close_transport` is REQUIRED (non-nil): it is called once at final +// teardown and MUST close/interrupt the transport so the blocked reader exits. +// A nil closer is a programming error and panics here, because the reader can +// only be unblocked by closing the transport (see the field comment). +// +// CONCURRENCY REQUIREMENT: the background reader calls `transport.read()` while +// request threads call `transport.write()`, so `transport` MUST be safe for a +// read and a write to run simultaneously on separate threads. A raw TCP socket +// is (it is full-duplex); a TLS connection is NOT — one OpenSSL/mbedTLS `SSL` +// object must not be read and written at the same time. Wiring this to a TLS +// backend therefore requires a concurrent-safe transport adapter (a serialized +// or non-blocking I/O loop); a single blocking I/O mutex is insufficient because +// it would deadlock the blocked reader. That adapter is provided when the pooled +// transport is wired up (see the HTTP connection-pooling Phase 3 work); until +// then this type is exercised only over a full-duplex in-memory pipe in tests. +pub fn new_h2_mux_conn(transport H2Transport, close_transport fn ()) &H2MuxConn { + if close_transport == unsafe { nil } { + // The reader thread spawned below blocks in transport.read(); the + // H2Transport interface has no close(), so this callback is the only way + // to interrupt that read at teardown. Without it an idle retirement or + // last-reference release would leak the reader thread and the connection. + panic('new_h2_mux_conn: close_transport must be non-nil so teardown can wake the reader') + } + fmu := sync.new_mutex() + mut c := &H2MuxConn{ + transport: transport + close_transport: close_transport + fmu: fmu + fcv: sync.new_cond(fmu) + idle_since: time.now() + } + spawn c.read_loop() + return c +} + +// can_take_new_request reports whether a new request may be admitted on this +// connection right now (it may still be refused later under smu). +pub fn (mut c H2MuxConn) can_take_new_request() bool { + c.smu.lock() + defer { + c.smu.unlock() + } + limit := if c.peer_max_streams < c.max_streams { c.peer_max_streams } else { c.max_streams } + return !c.closed && !c.goaway && !c.shutting_down && u32(c.active_streams) < limit +} + +// shutdown_when_idle asks the connection to retire: no new requests are +// admitted, and once no requests are in flight and the owner's reference is +// released, the transport is closed. +pub fn (mut c H2MuxConn) shutdown_when_idle() { + c.smu.lock() + c.shutting_down = true + c.smu.unlock() +} + +// release drops the owner's reference. The connection tears its transport +// down once the reader has exited and all references are gone. +pub fn (mut c H2MuxConn) release() { + c.drop_ref() +} + +fn (mut c H2MuxConn) drop_ref() { + c.smu.lock() + c.refs-- + last_ref := c.refs <= 0 + c.smu.unlock() + if last_ref { + // Tear the transport down even if the reader has not exited yet: closing + // it interrupts the reader's blocking read so it can exit. Otherwise an + // idle connection that is shut down and released leaks the reader thread + // and the socket, because the reader only sets `closed` after a read + // error/timeout, which never arrives on a silent transport. + c.teardown_transport() + } +} + +// teardown_transport runs the close_transport callback exactly once. Calling it +// while the reader is still blocked in transport.read() interrupts that read, so +// the reader observes the closed transport and exits via fail_conn. +fn (mut c H2MuxConn) teardown_transport() { + c.smu.lock() + already := c.transport_torn_down + c.transport_torn_down = true + c.smu.unlock() + if !already && c.close_transport != unsafe { nil } { + c.close_transport() + } +} + +// --- request side ----------------------------------------------------------- + +// do sends one request over the connection, concurrently with other streams, +// and returns its response. Errors carrying h2_err_retryable_code are safe to +// retry on a fresh connection. +pub fn (mut c H2MuxConn) do(req H2ClientRequest) !H2ClientResponse { + // Admission. + c.smu.lock() + if c.closed { + reason := if c.conn_err != '' { c.conn_err } else { 'connection is closed' } + c.smu.unlock() + return h2_retryable_error(reason) + } + if c.goaway || c.shutting_down { + c.smu.unlock() + return h2_retryable_error('connection is shutting down') + } + limit := if c.peer_max_streams < c.max_streams { c.peer_max_streams } else { c.max_streams } + if u32(c.active_streams) >= limit { + c.smu.unlock() + return h2_retryable_error('connection is at its concurrent stream limit') + } + c.refs++ + c.active_streams++ + c.smu.unlock() + + mut s := new_h2_mux_stream() + resp := c.do_on_stream(mut s, req) or { + c.finish_stream(mut s) + return err + } + c.finish_stream(mut s) + return resp +} + +// finish_stream removes the stream from the connection and releases the +// request's reference (possibly triggering teardown). +fn (mut c H2MuxConn) finish_stream(mut s H2MuxStream) { + c.smu.lock() + c.streams.delete(s.id) + c.active_streams-- + c.idle_since = time.now() + c.smu.unlock() + // Credit any DATA bytes that were queued in s.chunks but never drained. + // On the success path wait_response empties chunks before returning, so + // queued is 0. On the error path (do_on_stream returned early, e.g. after + // a RST_STREAM arrived mid-upload), chunks may hold bytes whose flow-size + // was already debited from conn_recv_window; without this credit those bytes + // are silently dropped, permanently shrinking the connection receive window. + // Setting cancelled prevents a DATA frame in flight from re-queuing onto + // the now-deregistered stream and leaking more bytes. + s.mu.lock() + s.cancelled = true + mut queued := u64(0) + for ch in s.chunks { + queued += u64(ch.len) + } + s.chunks.clear() + s.mu.unlock() + if queued > 0 { + c.send_conn_window_update(u32(queued)) or { c.note_write_failure() } + } + c.drop_ref() +} + +fn (mut c H2MuxConn) do_on_stream(mut s H2MuxStream, req H2ClientRequest) !H2ClientResponse { + mut fields := [ + H2HeaderField{':method', req.method}, + H2HeaderField{':scheme', req.scheme}, + H2HeaderField{':authority', req.authority}, + H2HeaderField{':path', req.path}, + ] + for h in req.headers { + fields << h + } + has_body := req.body.len > 0 + + // Stream-id allocation, registration, HPACK encoding and the HEADERS(+ + // CONTINUATION) send form one wmu critical section: ids must hit the wire + // in increasing order, header blocks must be contiguous, and the stream + // must be registered before its first byte is sent so the reader can + // always deliver the response. + c.wmu.lock() + c.handshake_locked() or { + // Preface write failed: tear down the transport so the reader exits + // and the pool stops admitting new work to this dead connection. + // Without this, closed/shutting_down stay false and the pool can keep + // dispatching to a connection whose write side is already broken. + c.wmu.unlock() + c.note_write_failure() + return h2_retryable_error('connection handshake failed: ${err.msg()}') + } + // RFC 9113 §6.5.2: honor the peer's advisory SETTINGS_MAX_HEADER_LIST_SIZE. + // Refuse an over-limit request here rather than emit it and have the server + // reject it (e.g. 431). Not retryable: a fresh connection to the same peer + // carries the same limit. Read under wmu (apply_peer_settings sets it there). + peer_max_list := c.peer_max_header_list_size + if peer_max_list != max_u32 && h2_header_list_size(fields) > u64(peer_max_list) { + c.wmu.unlock() + return error('h2: request header list (${h2_header_list_size(fields)} bytes) exceeds peer SETTINGS_MAX_HEADER_LIST_SIZE (${peer_max_list})') + } + if c.next_stream_id > u32(0x7fff_ffff) { + // RFC 7540 §5.1.1: client stream IDs are odd and must not exceed 2^31-1. + // Retire this connection and let the caller open a fresh one. + c.smu.lock() + c.shutting_down = true + c.smu.unlock() + c.wmu.unlock() + return h2_retryable_error('stream ID space exhausted') + } + s.id = c.next_stream_id + c.next_stream_id += 2 + // Mark the HEADERS as sent *before* the stream becomes visible to the + // reader: pessimistic, so a connection death racing this section can never + // classify a request whose HEADERS may have reached the wire as safe to + // replay. (The stream is still private here, so no lock is needed.) + s.sent_headers = true + c.smu.lock() // wmu -> smu -> fmu is the permitted lock nesting + // A terminal event (GOAWAY, or fail_conn after the reader saw the transport + // die) can land while we are blocked on wmu, after do()'s admission check + // already passed. Such an event fails every stream in the map, but this one + // is not registered yet, so it would slip through and wait_response could + // block forever. Recheck under smu — the same lock those events use to set + // these flags — and abort before registering or sending. The HEADERS never + // hit the wire here, so the request is safe to retry on a fresh connection. + // Also recheck the peer's stream limit: SETTINGS_MAX_CONCURRENT_STREAMS can + // arrive while we are blocked on wmu, lowering the limit below active_streams. + if c.closed || c.goaway || c.shutting_down { + reason := if c.conn_err != '' { c.conn_err } else { 'connection is shutting down' } + c.smu.unlock() + c.wmu.unlock() + return h2_retryable_error(reason) + } + recheck_limit := if c.peer_max_streams < c.max_streams { + c.peer_max_streams + } else { + c.max_streams + } + if u32(c.active_streams) > recheck_limit { + c.smu.unlock() + c.wmu.unlock() + return h2_retryable_error('peer lowered concurrent stream limit; retrying on a fresh connection') + } + // If this was the last valid client stream ID (RFC 7540 §5.1.1: max 2^31-1), + // retire the connection so can_take_new_request() returns false immediately. + // Without this, the pool dispatches one extra request that hits the admission + // check at the top of do_on_stream and fails with a retryable error. + if c.next_stream_id > u32(0x7fff_ffff) { + c.shutting_down = true + } + c.fmu.lock() + // The initial send window must be assigned atomically with registration: + // the WINDOW_UPDATE and SETTINGS handlers also nest smu -> fmu, so credit + // or a delta arriving right after our HEADERS serializes after this + // assignment instead of being overwritten by it. + s.send_window = c.peer_initial_window + c.streams[s.id] = s + c.fmu.unlock() + c.smu.unlock() + block := c.encoder.encode(fields) + c.send_header_block_locked(s.id, block, !has_body) or { + c.wmu.unlock() + c.note_write_failure() + // The HEADERS may have partially hit the wire; not safe to blind-retry + // unless the transport wrote nothing, which we cannot distinguish here. + return error('h2: failed to send request headers: ${err.msg()}') + } + c.wmu.unlock() + + if has_body { + c.send_body_on_stream(mut s, req.body)! + } else { + // The HEADERS carried END_STREAM, so our send side is now closed. + s.mu.lock() + s.send_closed = true + s.mu.unlock() + } + return c.wait_response(mut s, req) +} + +// handshake_locked sends the connection preface and our SETTINGS once. +// Callers must hold wmu. +fn (mut c H2MuxConn) handshake_locked() ! { + if c.handshaked { + return + } + mut buf := h2_client_preface.bytes() + buf << H2Frame(H2SettingsFrame{ + settings: [ + H2Setting{h2_settings_enable_push, 0}, + H2Setting{h2_settings_initial_window_size, h2_default_initial_window}, + H2Setting{h2_settings_max_frame_size, h2_default_max_frame_size}, + ] + }).encode() + c.write_all_locked(buf)! + c.handshaked = true + // If the reader processed server frames before we sent our preface, ACKs were + // deferred to preserve client-preface-first ordering (RFC 7540 §3.5). Flush + // them now: one SETTINGS ACK per received non-ACK SETTINGS, then any PING ACKs. + for _ in 0 .. c.pending_settings_acks { + c.write_all_locked(H2Frame(H2SettingsFrame{ ack: true }).encode())! + } + c.pending_settings_acks = 0 + for data in c.pending_ping_acks { + c.write_all_locked(H2Frame(H2PingFrame{ ack: true, data: data }).encode())! + } + c.pending_ping_acks.clear() +} + +// send_header_block_locked writes a header block as HEADERS(+CONTINUATION) +// frames. Callers must hold wmu. +fn (mut c H2MuxConn) send_header_block_locked(stream_id u32, block []u8, end_stream bool) ! { + c.fmu.lock() + mut max := int(c.peer_max_frame) + c.fmu.unlock() + if block.len <= max { + // Re-read peer_max_frame right before writing to close the TOCTOU window: + // the peer may have lowered SETTINGS_MAX_FRAME_SIZE since the check above. + c.fmu.lock() + max = int(c.peer_max_frame) + c.fmu.unlock() + if block.len <= max { + c.write_all_locked(H2Frame(H2HeadersFrame{ + stream_id: stream_id + fragment: block + end_headers: true + end_stream: end_stream + }).encode())! + return + } + // block no longer fits in one frame under the refreshed limit; + // fall through to the multi-frame path. + } + // Re-read peer_max_frame under fmu immediately before the first HEADERS write + // to minimise the TOCTOU window between the size-check above and this write. + c.fmu.lock() + max = int(c.peer_max_frame) + c.fmu.unlock() + // Clamp to block.len in case max grew large enough to fit the whole block. + first := if max < block.len { max } else { block.len } + c.write_all_locked(H2Frame(H2HeadersFrame{ + stream_id: stream_id + fragment: block[..first] + end_headers: first == block.len + end_stream: end_stream + }).encode())! + if first == block.len { + return + } + mut off := first + for off < block.len { + // Re-read peer_max_frame under fmu before each CONTINUATION to minimise + // the TOCTOU window: the peer could send a smaller SETTINGS_MAX_FRAME_SIZE + // between iterations and enforce it on the next frame we send. + c.fmu.lock() + cur_max := int(c.peer_max_frame) + c.fmu.unlock() + mut next := off + cur_max + if next > block.len { + next = block.len + } + c.write_all_locked(H2Frame(H2ContinuationFrame{ + stream_id: stream_id + fragment: block[off..next] + end_headers: next == block.len + }).encode())! + off = next + } +} + +// send_body_on_stream writes the request body as DATA frames, bounded by both +// the connection and stream send windows. +fn (mut c H2MuxConn) send_body_on_stream(mut s H2MuxStream, body []u8) ! { + mut off := 0 + for off < body.len { + // Reserve a window-bounded chunk under fmu (waiting for WINDOW_UPDATE + // room when both windows are exhausted), then write it under wmu. + c.fmu.lock() + for !c.fmu_dead && !s.send_dead && (c.send_window <= 0 || s.send_window <= 0) { + c.fcv.wait() + } + if c.fmu_dead || s.send_dead { + stream_dead := s.send_dead + c.fmu.unlock() + if stream_dead { + // send_dead is always set together with a terminal stream state, + // so distinguish the two by whether fail() recorded an error: + // - no error → the server sent END_STREAM (early final response) + // while we still owed body; abandon the upload and close our + // half (below) so wait_response can still deliver the response. + // - error set → RST_STREAM, GOAWAY, or connection death; surface + // it here, preserving retryability so a request the server never + // processed can be replayed on a fresh connection. + s.mu.lock() + failure := s.err + failure_retryable := s.retryable + s.mu.unlock() + if failure != '' { + if failure_retryable { + return h2_retryable_error(failure) + } + return error('h2: ${failure}') + } + // Early final response: the server completed and closed its half + // while we still owed body. RST_STREAM(CANCEL) closes our half so + // the server releases the stream instead of holding it half-open + // and counting it against its concurrency limit (RFC 9113 §8.1). + // The stream stays registered in c.streams, so wait_response still + // returns the already-received response from the s reference. + c.wmu.lock() + c.write_all_locked(H2Frame(H2RstStreamFrame{ + stream_id: s.id + error_code: u32(H2ErrorCode.cancel) + }).encode()) or { + c.wmu.unlock() + c.note_write_failure() + return + } + c.wmu.unlock() + s.mu.lock() + s.send_closed = true + s.mu.unlock() + return + } + return error('h2: connection closed while sending the request body') + } + mut chunk := body.len - off + if chunk > int(c.peer_max_frame) { + chunk = int(c.peer_max_frame) + } + if i64(chunk) > c.send_window { + chunk = int(c.send_window) + } + if i64(chunk) > s.send_window { + chunk = int(s.send_window) + } + c.send_window -= i64(chunk) + s.send_window -= i64(chunk) + c.fmu.unlock() + + mut next := off + chunk + c.wmu.lock() + // Re-cap chunk under wmu→fmu (permitted order): the reader may have + // processed SETTINGS_MAX_FRAME_SIZE and sent the ACK between our + // fmu.unlock() above and wmu.lock() here; re-read and return any excess. + c.fmu.lock() + if chunk > int(c.peer_max_frame) { + excess := i64(chunk) - i64(c.peer_max_frame) + c.send_window += excess + s.send_window += excess + chunk = int(c.peer_max_frame) + next = off + chunk + // Wake any goroutines sleeping in fcv.wait() because c.send_window + // was drained; no WINDOW_UPDATE is coming for these bytes since they + // were never sent. + c.fcv.broadcast() + } + // Also revalidate stream send window: an INITIAL_WINDOW_SIZE reduction + // applies a negative delta to s.send_window under fmu, so a negative + // value here means we over-claimed against the peer's new window. Return + // the excess bytes to both windows so the loop can re-wait for capacity. + if s.send_window < 0 { + trim := if i64(chunk) < -s.send_window { i64(chunk) } else { -s.send_window } + chunk -= int(trim) + s.send_window += trim + c.send_window += trim + next = off + chunk + c.fcv.broadcast() + } + c.fmu.unlock() + if chunk == 0 { + c.wmu.unlock() + continue + } + c.write_all_locked(H2Frame(H2DataFrame{ + stream_id: s.id + data: body[off..next] + end_stream: next == body.len + }).encode()) or { + c.wmu.unlock() + c.note_write_failure() + return error('h2: failed to send request body: ${err.msg()}') + } + c.wmu.unlock() + off = next + } + // The final DATA frame carried END_STREAM, so our send side is now closed. + s.mu.lock() + s.send_closed = true + s.mu.unlock() +} + +// wait_response drains the stream until it ends, honoring the request's +// streaming callback and stop limits. Callbacks run on the requester's thread. +fn (mut c H2MuxConn) wait_response(mut s H2MuxStream, req H2ClientRequest) !H2ClientResponse { + mut resp := H2ClientResponse{} + mut body_expected := u64(0) + mut has_content_length := false + mut got_headers := false + mut body_so_far := u64(0) + mut cancelled := false + s.mu.lock() + for { + // Drain everything currently buffered. + if !got_headers && s.headers_done { + resp.status = s.status + for f in s.resp_headers { + resp.headers << f + // on_response_headers already rejects a non-numeric Content-Length, + // but guard the lenient u64() at the parse site too so this stays + // correct even if that upstream check is ever changed. + if f.name == 'content-length' && all_digits(f.value) { + body_expected = f.value.u64() + has_content_length = true + } + } + got_headers = true + } + mut drained := u64(0) + for s.chunks.len > 0 { + chunk := s.chunks[0] + s.chunks.delete(0) + body_so_far += u64(chunk.len) + drained += u64(chunk.len) + if req.stop_copying_limit < 0 + || i64(body_so_far) - i64(chunk.len) < req.stop_copying_limit { + if req.stop_copying_limit >= 0 && i64(body_so_far) > req.stop_copying_limit { + remaining := req.stop_copying_limit - (i64(body_so_far) - i64(chunk.len)) + if remaining > 0 { + resp.body << chunk[..int(remaining)] + } + } else { + resp.body << chunk + } + } + if req.on_data != unsafe { nil } { + // Run the user callback outside the stream lock so it can + // block without stalling the reader's delivery. + s.mu.unlock() + req.on_data(chunk, body_so_far, body_expected, resp.status) or { + // The callback aborted the request: credit the connection + // window for what we consumed this round and RST the stream, + // just like the stop_receiving_limit path, so the connection + // window does not leak and the peer stops sending. + if drained > 0 { + c.send_conn_window_update(u32(drained)) or {} + } + c.cancel_stream(mut s) + return err + } + s.mu.lock() + } + if req.stop_receiving_limit >= 0 && i64(body_so_far) >= req.stop_receiving_limit { + cancelled = true + break + } + } + ended := s.ended + serr := s.err + retryable := s.retryable + if cancelled { + s.mu.unlock() + // The connection-level window must still be credited for the bytes + // this round consumed, or the connection's receive window shrinks + // permanently for every other stream. + if drained > 0 { + c.send_conn_window_update(u32(drained)) or {} + } + c.cancel_stream(mut s) + if !got_headers { + return error('h2: stream cancelled before a response arrived') + } + return resp + } + if drained > 0 { + // Replenish flow control for what was just consumed, outside s.mu. + s.mu.unlock() + c.send_window_updates(s.id, u32(drained)) or {} + s.mu.lock() + // New chunks may have arrived while unlocked; loop and re-drain. + if s.chunks.len > 0 || (s.ended && !ended) { + continue + } + } + if ended { + // Surface any trailers (a second HEADERS block) into the response + // headers, mirroring the synchronous H2Conn.read_response path. The + // stream has ended, so resp_trailers is complete; copy under the lock. + for f in s.resp_trailers { + resp.headers << f + } + s.mu.unlock() + if serr != '' { + if retryable { + return h2_retryable_error(serr) + } + return error('h2: ${serr}') + } + if !got_headers { + return error('h2: stream closed without a response') + } + // RFC 9110 §8.6: a Content-Length must match the bytes received. + // Skip for responses defined to carry no body — HEAD requests and + // 204/304 status codes — where Content-Length describes the absent + // representation rather than transmitted DATA. + body_allowed := req.method != 'HEAD' && resp.status != 204 && resp.status != 304 + if has_content_length && body_allowed && body_so_far != body_expected { + return error('h2: response body length ${body_so_far} does not match Content-Length ${body_expected}') + } + return resp + } + s.cv.wait() + } + return resp +} + +// cancel_stream aborts a stream early (stop_receiving_limit): the stream is +// deregistered first and then RST_STREAM is sent, so any in-flight late DATA +// for it is handled by the reader's unknown-stream backstop (connection-level +// WINDOW_UPDATE), keeping the connection fully usable for other streams. +fn (mut c H2MuxConn) cancel_stream(mut s H2MuxStream) { + // Deregister first, then send RST_STREAM: once the stream is gone from + // c.streams, any DATA the reader already had in flight for it hits the + // unknown-stream backstop, which credits the connection-level receive + // window. If we sent RST first, that in-flight DATA could still find the + // registered stream and be queued without a WINDOW_UPDATE (the cancelled + // requester has stopped draining), permanently leaking connection window. + c.smu.lock() + c.streams.delete(s.id) + c.smu.unlock() + // DATA already queued before cancellation consumed the connection receive + // window but will never be drained; credit it back (its padding was already + // credited on receipt, so only the chunk data remains). Setting `cancelled` + // under s.mu also makes a frame still in flight credit-and-drop in + // on_response_data instead of queuing onto a stream nobody drains. Without + // this, repeated early cancellations permanently shrink the connection window. + s.mu.lock() + s.cancelled = true + mut queued := u64(0) + for ch in s.chunks { + queued += u64(ch.len) + } + s.chunks.clear() + s.mu.unlock() + if queued > 0 { + // A write failure here means the transport is already dead; tear it down + // immediately so the pool stops reusing it. note_write_failure is + // sufficient — the RST_STREAM is skipped since the peer will not receive + // it on a dead transport. + c.send_conn_window_update(u32(queued)) or { + c.note_write_failure() + return + } + } + c.wmu.lock() + c.write_all_locked(H2Frame(H2RstStreamFrame{ + stream_id: s.id + error_code: u32(H2ErrorCode.cancel) + }).encode()) or { + c.wmu.unlock() + c.note_write_failure() + return + } + c.wmu.unlock() +} + +// send_conn_window_update replenishes only the connection-level receive +// window (used when the stream itself is being cancelled). +fn (mut c H2MuxConn) send_conn_window_update(n u32) ! { + if n == 0 { + return + } + // Credit our tracked window before sending the frame so the budget is + // never in deficit for longer than necessary. + c.recv_wmu.lock() + c.conn_recv_window += i64(n) + c.recv_wmu.unlock() + buf := H2Frame(H2WindowUpdateFrame{ + stream_id: 0 + window_size_increment: n + }).encode() + c.wmu.lock() + c.write_all_locked(buf) or { + c.wmu.unlock() + c.note_write_failure() + return err + } + c.wmu.unlock() +} + +// send_window_updates replenishes both the connection and stream receive +// windows after the requester consumed `n` body bytes. +fn (mut c H2MuxConn) send_window_updates(stream_id u32, n u32) ! { + if n == 0 { + return + } + // Credit both tracked receive windows before sending the frames. + c.recv_wmu.lock() + c.conn_recv_window += i64(n) + c.recv_wmu.unlock() + mut stream_alive := false + c.smu.lock() + if mut s := c.streams[stream_id] { + s.mu.lock() + s.recv_window += i64(n) + s.mu.unlock() + stream_alive = true + } + c.smu.unlock() + mut buf := H2Frame(H2WindowUpdateFrame{ + stream_id: 0 + window_size_increment: n + }).encode() + if stream_alive { + buf << H2Frame(H2WindowUpdateFrame{ + stream_id: stream_id + window_size_increment: n + }).encode() + } + c.wmu.lock() + c.write_all_locked(buf) or { + c.wmu.unlock() + c.note_write_failure() + return err + } + c.wmu.unlock() +} + +// write_all_locked writes all of `data` to the transport. Callers hold wmu. +fn (mut c H2MuxConn) write_all_locked(data []u8) ! { + mut sent := 0 + for sent < data.len { + n := c.transport.write(data[sent..])! + if n <= 0 { + return error('transport write returned ${n}') + } + sent += n + } +} + +// note_write_failure handles a transport write failure: it stops admitting new +// requests and tears the transport down. Closing it interrupts the reader's +// blocking read so it runs fail_conn and fails every other in-flight stream, +// instead of leaving them hung when the transport's read side does not also +// break (a write-only failure). teardown_transport is once-guarded, so this +// never double-closes against the reader's own teardown. +fn (mut c H2MuxConn) note_write_failure() { + c.smu.lock() + c.shutting_down = true + c.smu.unlock() + // Interrupt the reader's blocking read if a closer can, then fail the + // connection directly. fail_conn is idempotent (guarded by c.closed), so a + // later reader invocation is a safe no-op. Calling it unconditionally — not + // only when close_transport is nil — wakes every in-flight stream + // immediately, closing both the window before the reader notices the dead + // transport and the case of a closer that cannot interrupt a blocking read. + c.teardown_transport() + c.fail_conn('transport write failure') +} + +// --- reader side ------------------------------------------------------------- + +// read_loop runs on the connection's background thread: it is the only reader +// of the transport and demuxes every incoming frame. +fn (mut c H2MuxConn) read_loop() { + for { + frame := c.mux_read_frame() or { + if is_transport_timeout_error(err) { + if c.reader_should_exit() { + c.fail_conn('connection retired while idle') + return + } + continue + } + c.fail_conn('connection lost: ${err.msg()}') + return + } + c.dispatch_frame(frame) or { + c.fail_conn(err.msg()) + return + } + } +} + +// reader_should_exit lets an idle reader retire the connection on shutdown. +fn (mut c H2MuxConn) reader_should_exit() bool { + c.smu.lock() + defer { + c.smu.unlock() + } + return c.shutting_down && c.active_streams == 0 +} + +// is_transport_timeout_error recognizes read-timeout errors, which wake the +// reader for shutdown checks rather than killing the connection. +fn is_transport_timeout_error(err IError) bool { + msg := err.msg().to_lower() + return msg.contains('timed out') || msg.contains('timeout') +} + +// mux_read_frame reads and decodes one frame from the transport, enforcing +// the receive limit we advertised to the peer in our own SETTINGS. This is +// h2_default_max_frame_size, which H2MuxConn always sends and never renegotiates. +// (c.peer_max_frame is the peer's receive limit — our outbound cap — and must +// not be used here.) +fn (mut c H2MuxConn) mux_read_frame() !H2Frame { + c.mux_fill_at_least(h2_frame_header_len)! + header := h2_parse_frame_header(c.rbuf)! + if header.length > h2_default_max_frame_size { + return error('frame larger than SETTINGS_MAX_FRAME_SIZE (${header.length})') + } + total := h2_frame_header_len + int(header.length) + c.mux_fill_at_least(total)! + frame := h2_parse_frame(header, c.rbuf[h2_frame_header_len..total])! + c.rbuf = c.rbuf[total..].clone() + return frame +} + +// mux_fill_at_least reads from the transport until rbuf holds n bytes. +fn (mut c H2MuxConn) mux_fill_at_least(n int) ! { + for c.rbuf.len < n { + mut tmp := []u8{len: h2_conn_read_chunk} + got := c.transport.read(mut tmp)! + if got <= 0 { + return error('connection closed by peer') + } + c.rbuf << tmp[..got] + } +} + +fn (mut c H2MuxConn) dispatch_frame(frame H2Frame) ! { + match frame { + H2SettingsFrame { + if !frame.ack { + c.apply_peer_settings(frame.settings)! + c.wmu.lock() + if !c.handshaked { + // RFC 7540 §3.5: no frame may precede the client connection + // preface. Defer the ACK; handshake_locked() will flush it + // immediately after sending the preface. Use a counter so + // multiple SETTINGS frames each get their own ACK. + c.pending_settings_acks++ + over := c.pending_settings_acks + c.pending_ping_acks.len > h2_max_pending_preface_acks + c.wmu.unlock() + if over { + return error('h2: too many control frames before the client preface') + } + } else { + c.write_all_locked(H2Frame(H2SettingsFrame{ + ack: true + }).encode()) or { + c.wmu.unlock() + return error('failed to ack SETTINGS: ${err.msg()}') + } + c.wmu.unlock() + } + } + } + H2PingFrame { + if !frame.ack { + c.wmu.lock() + if !c.handshaked { + // RFC 7540 §3.5: client preface must be the first bytes sent. + // Defer the ACK; handshake_locked() will flush it after the preface. + c.pending_ping_acks << frame.data + over := c.pending_settings_acks + c.pending_ping_acks.len > h2_max_pending_preface_acks + c.wmu.unlock() + if over { + return error('h2: too many control frames before the client preface') + } + } else { + c.write_all_locked(H2Frame(H2PingFrame{ + ack: true + data: frame.data + }).encode()) or { + c.wmu.unlock() + return error('failed to ack PING: ${err.msg()}') + } + c.wmu.unlock() + } + } + } + H2WindowUpdateFrame { + inc := frame.window_size_increment + if frame.stream_id == 0 { + // RFC 7540 6.9: a 0 increment is a connection PROTOCOL_ERROR; + // a window past 2^31-1 is a connection FLOW_CONTROL_ERROR. + if inc == 0 { + return error('h2: connection WINDOW_UPDATE with a zero increment') + } + c.fmu.lock() + new_window := c.send_window + i64(inc) + overflow := new_window > i64(0x7fff_ffff) + if !overflow { + c.send_window = new_window + c.fcv.broadcast() + } + c.fmu.unlock() + if overflow { + return error('h2: connection flow-control window exceeded 2^31-1') + } + } else { + // Stream-level versions of the same rules are stream errors: + // RST_STREAM the offending stream instead of killing the conn. + if inc == 0 { + c.reset_stream(frame.stream_id, .protocol_error, + 'WINDOW_UPDATE with a zero increment') + return + } + // Hold smu across the lookup and the credit (smu -> fmu), so + // this serializes with stream registration and the credit can + // never be overwritten by the initial-window assignment. + c.smu.lock() + mut sref := c.streams[frame.stream_id] or { &H2MuxStream(unsafe { nil }) } + mut overflow := false + if sref != unsafe { nil } { + c.fmu.lock() + new_window := sref.send_window + i64(inc) + overflow = new_window > i64(0x7fff_ffff) + if !overflow { + sref.send_window = new_window + c.fcv.broadcast() + } + c.fmu.unlock() + } + c.smu.unlock() + if overflow { + c.reset_stream(frame.stream_id, .flow_control_error, + 'stream flow-control window exceeded 2^31-1') + } + } + } + H2GoawayFrame { + // Take wmu before smu (the permitted wmu -> smu nesting) so setting + // c.goaway serializes with do_on_stream's terminal-flag recheck, which + // runs under smu while holding wmu. Without this, a GOAWAY landing + // between that recheck (smu released at the end of the registration + // section) and the HEADERS write (still under wmu) lets the client + // open one more stream after observing GOAWAY (RFC 7540 6.8). Holding + // wmu here means c.goaway cannot be set while any writer is mid-section, + // so the recheck is authoritative. + c.wmu.lock() + c.smu.lock() + c.goaway = true + c.goaway_last = frame.last_stream_id + mut above := []&H2MuxStream{} + for id, st in c.streams { + if id > frame.last_stream_id { + above << st + } + } + c.smu.unlock() + c.wmu.unlock() + for mut st in above { + // Streams above last_stream_id were not processed by the + // server, so they are safe to retry elsewhere (RFC 7540 6.8). + st.fail('request not processed (GOAWAY)', true) + c.wake_send(mut st) + } + if frame.error_code != u32(H2ErrorCode.no_error) { + return error('connection error (GOAWAY ${h2_error_code_name(frame.error_code)})') + } + } + H2HeadersFrame { + c.on_response_headers(frame)! + } + H2DataFrame { + c.on_response_data(frame)! + } + H2RstStreamFrame { + mut s := c.lookup_stream(frame.stream_id) + if s != unsafe { nil } { + // REFUSED_STREAM means the server did not process the request + // (RFC 7540 8.1.4), so it is safe to replay on a fresh connection + // even for a non-idempotent method; any other reset code is not. + retryable := frame.error_code == u32(H2ErrorCode.refused_stream) + s.fail('stream reset by peer (${h2_error_code_name(frame.error_code)})', retryable) + c.wake_send(mut s) + } + } + H2ContinuationFrame { + // CONTINUATION outside a header block is a connection error; the + // in-block ones are consumed by on_response_headers. + return error('unexpected CONTINUATION frame') + } + H2PushPromiseFrame { + // We advertise SETTINGS_ENABLE_PUSH=0, so any PUSH_PROMISE is a + // connection error (RFC 7540 6.6 / 8.2). Failing the connection + // here also avoids the dropped fragment desyncing our HPACK decoder. + return error('h2: unexpected PUSH_PROMISE (server push is disabled)') + } + else { + // PRIORITY / unknown frame types: ignored per RFC 7540. + } + } +} + +// apply_peer_settings folds the peer's SETTINGS into the connection, +// including the retroactive initial-window delta for every open stream +// (RFC 7540 6.9.2). Out-of-range values that would corrupt our framing or +// flow-control math are rejected as a connection error (the caller turns the +// error into fail_conn), per RFC 7540 6.5.2 / 6.5.3. +fn (mut c H2MuxConn) apply_peer_settings(settings []H2Setting) ! { + for st in settings { + match st.id { + h2_settings_header_table_size { + // RFC 7541 §6.3: even if our encoder uses only literals, we MUST + // emit a Dynamic Table Size Update prefix at the start of the next + // HEADERS block when the peer lowers this limit. encode() emits + // the update when pending_max_table_size >= 0. + c.wmu.lock() + c.encoder.dyn_table.set_max_size(int(st.value)) + c.encoder.pending_max_table_size = int(st.value) + c.wmu.unlock() + } + h2_settings_enable_push { + if st.value > 1 { + // RFC 7540 6.5.2: ENABLE_PUSH must be 0 or 1. + return error('h2: peer SETTINGS_ENABLE_PUSH ${st.value} is not 0 or 1') + } + // We never use server push, so the value is otherwise irrelevant. + } + h2_settings_max_concurrent_streams { + c.smu.lock() + c.peer_max_streams = st.value + c.smu.unlock() + } + h2_settings_max_header_list_size { + // RFC 9113 §6.5.2: advisory cap on the size of the header list we + // send. Store it (under wmu, with the other send-side header state) + // so do_on_stream can refuse an over-limit request locally instead + // of having the server reject it after the round trip. + c.wmu.lock() + c.peer_max_header_list_size = st.value + c.wmu.unlock() + } + h2_settings_initial_window_size { + if st.value > u32(0x7fff_ffff) { + // RFC 7540 6.5.3: values above 2^31-1 are a FLOW_CONTROL_ERROR; + // they also overflow our i64 send-window arithmetic. + return error('h2: peer SETTINGS_INITIAL_WINDOW_SIZE ${st.value} exceeds 2^31-1') + } + // smu is held across the fmu section so the snapshot of open + // streams and the delta application are atomic with respect to + // stream registration (which nests the same way) — a stream can + // neither miss the delta nor receive it twice. + c.smu.lock() + c.fmu.lock() + delta := i64(st.value) - c.peer_initial_window + c.peer_initial_window = i64(st.value) + // RFC 7540 §6.9.2: validate ALL stream windows before applying any + // delta so that an overflow on stream N does not leave streams 1..N-1 + // in a partially updated state. Pre-validation also ensures the + // broadcast below is always reached for a positive delta. + for _, s in c.streams { + if s.send_window + delta > i64(0x7fff_ffff) { + c.fmu.unlock() + c.smu.unlock() + return error('h2: SETTINGS_INITIAL_WINDOW_SIZE delta overflows stream ${s.id} send window (RFC 7540 §6.9.2 FLOW_CONTROL_ERROR)') + } + } + for _, mut s in c.streams { + s.send_window += delta + } + if delta > 0 { + c.fcv.broadcast() + } + c.fmu.unlock() + c.smu.unlock() + } + h2_settings_max_frame_size { + if st.value < h2_default_max_frame_size || st.value > u32(0x00ff_ffff) { + // RFC 7540 6.5.2: valid range is 2^14..2^24-1. A value such as + // 0 would make our HEADERS/DATA chunk step 0 and hang the send + // path in a zero-length-frame loop, so fail the connection. + return error('h2: peer SETTINGS_MAX_FRAME_SIZE ${st.value} out of range [16384, 16777215]') + } + // Take wmu (then fmu, the permitted wmu -> fmu nesting) before + // lowering the cap. The send paths re-read peer_max_frame under fmu + // and then write the frame under wmu, holding wmu across both; an + // fmu-only update could land in the fmu-release -> write gap and let + // a sender emit a frame larger than the peer's new limit + // (FRAME_SIZE_ERROR). Serializing on wmu makes the senders' re-read + // authoritative, mirroring the header_table_size arm above. + // apply_peer_settings holds no connection lock at entry, so this + // wmu acquisition cannot invert any held lock. + c.wmu.lock() + c.fmu.lock() + c.peer_max_frame = st.value + c.fmu.unlock() + c.wmu.unlock() + } + else {} // unknown settings are ignored (RFC 7540 6.5.2) + } + } +} + +// on_response_headers assembles a complete header block (reading any +// CONTINUATION frames inline — the reader owns the read path), decodes it, +// and delivers it to the stream. Blocks for unknown streams are still decoded +// to keep the connection's HPACK dynamic table in sync, then dropped. +fn (mut c H2MuxConn) on_response_headers(frame H2HeadersFrame) ! { + mut fragment := frame.fragment.clone() + if !frame.end_headers { + for { + cont := c.mux_read_frame() or { + return error('connection lost inside a header block: ${err.msg()}') + } + if cont is H2ContinuationFrame { + if cont.stream_id != frame.stream_id { + return error('CONTINUATION on the wrong stream') + } + fragment << cont.fragment + if fragment.len > h2_max_recv_header_block { + return error('h2: response header block exceeds ${h2_max_recv_header_block} bytes') + } + if cont.end_headers { + break + } + } else { + return error('expected a CONTINUATION frame') + } + } + } + fields := c.decoder.decode(fragment)! + mut s := c.lookup_stream(frame.stream_id) + if s == unsafe { nil } { + return c.check_unknown_stream(frame.stream_id) + } + s.mu.lock() + // RFC 9113 §5.1: a HEADERS frame after the stream has already ended (a prior + // END_STREAM, or RST/connection death that set s.ended) is a frame on a closed + // stream, not more trailers. Without this, back-to-back trailer + extra HEADERS + // in one read buffer would be appended to resp.headers before wait_response + // flushes, delivering fields the peer sent after closing the stream. Reset + // instead (the DATA path makes the same check via s.ended). + if s.ended { + s.mu.unlock() + c.reset_stream(frame.stream_id, .stream_closed, + 'HEADERS frame after stream end on stream ${frame.stream_id}') + return + } + was_headers_done := s.headers_done + if !s.headers_done { + mut status := 0 + mut status_valid := false + for f in fields { + if f.name == ':status' { + // RFC 9113 §8.3.1: :status is exactly three digits. string.int() + // is lenient ('200 OK' -> 200), so validate the raw value before + // converting; otherwise a malformed status is accepted as valid. + if f.value.len == 3 && all_digits(f.value) { + status = f.value.int() + status_valid = true + } + break + } + } + // A response MUST carry a valid :status (RFC 9113 §8.3.1), and HTTP/2 + // forbids 101 (§8.1.1). A missing, malformed, out-of-range, or 101 status + // is a stream-level PROTOCOL_ERROR — reset it rather than treat it as a + // 1xx interim and wait forever for a "final" HEADERS that never arrives. + // (Trailers legitimately omit :status, but they are handled below since + // headers_done is already set by then.) + if !status_valid || status < 100 || status > 599 || status == 101 { + s.mu.unlock() + c.reset_stream(frame.stream_id, .protocol_error, + 'response with a missing or invalid :status') + return + } + // RFC 9110 §15.2 / RFC 9113 §8.1: a server may send 1xx interim responses + // (100 Continue, 103 Early Hints) before the final response. They are not + // the final response and carry no body, so ignore them and keep waiting + // for the final (>= 200) HEADERS rather than latching the interim status + // and headers — which would make the real final HEADERS look like trailers. + if status >= 200 { + s.status = status + mut seen_regular := false + mut seen_status := false + for f in fields { + if f.name.starts_with(':') { + // RFC 9113 §8.3: the only valid response pseudo-header is :status + // (consumed above); pseudo-headers MUST precede regular fields and + // MUST NOT be duplicated. Any other ':' field, :status after a + // regular field, or a second :status makes the response malformed. + if f.name != ':status' || seen_regular || seen_status { + s.mu.unlock() + c.reset_stream(frame.stream_id, .protocol_error, + 'malformed response: invalid pseudo-header ${f.name}') + return + } + seen_status = true + continue + } + seen_regular = true + // RFC 9113 §8.2: reject malformed field names (uppercase, empty) and + // connection-specific fields rather than delivering them to the caller. + reason := h2_response_field_error(f.name) + if reason != '' { + s.mu.unlock() + c.reset_stream(frame.stream_id, .protocol_error, + 'malformed response: ${reason}') + return + } + // RFC 9110 §8.6 / RFC 9113 §8.1.1: a malformed Content-Length makes + // the message malformed (a stream-level PROTOCOL_ERROR). u64() is + // lenient ('12junk' -> 12, '0x10' -> 16), so validate it strictly + // here before wait_response trusts it for the body-length check. + if f.name == 'content-length' && !all_digits(f.value) { + s.mu.unlock() + c.reset_stream(frame.stream_id, .protocol_error, + 'malformed Content-Length in response') + return + } + s.resp_headers << f + } + s.headers_done = true + } + } + // Trailers without END_STREAM violate RFC 7540 §8.1 (trailers must carry + // END_STREAM); reset the stream rather than hanging wait_response forever. + if was_headers_done && !frame.end_stream { + s.mu.unlock() + c.reset_stream(frame.stream_id, .protocol_error, + 'trailers HEADERS frame must carry END_STREAM') + return + } + // A 1xx informational HEADERS with END_STREAM is also forbidden (RFC 9113 §8.1). + if !s.headers_done && frame.end_stream { + s.mu.unlock() + c.reset_stream(frame.stream_id, .protocol_error, '1xx response must not carry END_STREAM') + return + } + // A second HEADERS block on the stream carries trailers (RFC 9113 §8.1). + // Preserve its non-pseudo fields (grpc-status, digest, ...) so callers on + // the mux path keep the trailer metadata the synchronous + // H2Conn.read_response surfaces. Pseudo-header fields are forbidden in + // trailers (§8.1) and any malformed field name (§8.2) makes the message + // malformed, so both reset the stream. wait_response flushes the kept fields + // into resp.headers when the stream ends. (The trailers-without-END_STREAM + // case was already reset above, so a trailer block here always carries + // END_STREAM.) + if was_headers_done { + for f in fields { + // RFC 9113 §8.1: trailers MUST NOT contain pseudo-header fields, and the + // §8.2 field-name rules apply as for any header block. + if f.name.starts_with(':') { + s.mu.unlock() + c.reset_stream(frame.stream_id, .protocol_error, + 'malformed trailers: pseudo-header ${f.name}') + return + } + reason := h2_response_field_error(f.name) + if reason != '' { + s.mu.unlock() + c.reset_stream(frame.stream_id, .protocol_error, 'malformed trailers: ${reason}') + return + } + s.resp_trailers << f + } + } + if frame.end_stream { + s.ended = true + } + s.cv.signal() + s.mu.unlock() + if frame.end_stream { + // The server closed the stream: wake any body sender that is blocked + // waiting for flow-control credit, so it does not hang forever when + // the peer withholds WINDOW_UPDATEs after an early final response. + c.wake_send(mut s) + } +} + +// on_response_data delivers a DATA payload to its stream, or — for recently +// cancelled/completed streams — keeps the connection-level flow-control +// account exact by returning the credit directly (mirroring the server's +// unknown-stream backstop). +fn (mut c H2MuxConn) on_response_data(frame H2DataFrame) ! { + // Padding (the pad-length byte + padding) counts toward flow control + // (RFC 7540 6.9.1) but is never delivered to the app, so credit it back + // immediately; the data bytes are credited as the requester drains them. + pad_overhead := frame.flow_size - frame.data.len + // Enforce the connection-level receive window (RFC 7540 §6.9). Debit + // first; credit paths (drain, unknown-stream, cancelled) restore it. + // recv_wmu is always acquired solo, so no lock-order hazard here. + c.recv_wmu.lock() + c.conn_recv_window -= i64(frame.flow_size) + conn_ok := c.conn_recv_window >= 0 + c.recv_wmu.unlock() + if !conn_ok { + return error('h2: peer exceeded connection-level receive window (RFC 7540 §6.9 FLOW_CONTROL_ERROR)') + } + mut s := c.lookup_stream(frame.stream_id) + if s == unsafe { nil } { + c.check_unknown_stream(frame.stream_id)! + // Stream is gone: credit the whole received payload to the connection. + if frame.flow_size > 0 { + c.send_conn_window_update(u32(frame.flow_size)) or {} + } + return + } + s.mu.lock() + if s.cancelled { + // The requester abandoned this stream (it has been deregistered, but the + // reader still held a reference). Account for the whole payload at the + // connection level and drop it, so the window does not leak. Checking + // the flag under s.mu — the same lock cancel_stream sets it under — + // makes this race-free against a frame in flight during cancellation. + s.mu.unlock() + if frame.flow_size > 0 { + c.send_conn_window_update(u32(frame.flow_size)) or {} + } + return + } + if !s.headers_done { + // RFC 7540 §8.1: an HTTP/2 response must begin with HEADERS. DATA before + // the final header block is malformed — RST this stream (PROTOCOL_ERROR) + // rather than queue bytes a requester would see with status 0. Credit the + // frame back to the connection window (debited above, never delivered). + s.mu.unlock() + if frame.flow_size > 0 { + c.send_conn_window_update(u32(frame.flow_size)) or {} + } + c.reset_stream(frame.stream_id, .protocol_error, + 'DATA before response HEADERS on stream ${frame.stream_id}') + return + } + // RFC 7540 §5.1: a DATA frame after the peer's END_STREAM is a stream-closed + // error. If we have also closed our send side the stream is "closed" — a + // connection error (the spec's MUST); otherwise it is "half-closed (remote)" + // — a STREAM error, so one peer's misbehavior does not tear down every other + // multiplexed stream. Checked before the flow-control debit: a half-closed + // (remote) receiver is no longer obligated to maintain the window (§6.9.1). + if s.ended { + send_closed := s.send_closed + s.mu.unlock() + if send_closed { + return error('h2: DATA frame after END_STREAM on closed stream ${frame.stream_id} (RFC 7540 §5.1)') + } + if frame.flow_size > 0 { + c.send_conn_window_update(u32(frame.flow_size)) or {} + } + c.reset_stream(frame.stream_id, .stream_closed, + 'DATA after END_STREAM on stream ${frame.stream_id}') + return + } + // Enforce the stream-level receive window (RFC 7540 §6.9.1). Padding + // counts against stream flow control the same as data, so debit + // whenever flow_size > 0 regardless of whether there are data bytes. + if frame.flow_size > 0 { + s.recv_window -= i64(frame.flow_size) + if s.recv_window < 0 { + s.mu.unlock() + // RFC 7540 §6.9.1: a stream-level flow-control violation is a STREAM + // error — RST just this stream (like the WINDOW_UPDATE overflow path) + // rather than failing the whole connection and every other multiplexed + // stream. Credit this frame's bytes back to the connection window + // (debited above, never delivered); reset_stream credits queued chunks. + c.send_conn_window_update(u32(frame.flow_size)) or {} + c.reset_stream(frame.stream_id, .flow_control_error, + 'peer exceeded stream ${frame.stream_id} receive window') + return + } + } + if frame.data.len > 0 { + s.chunks << frame.data.clone() + s.body_rcvd += u64(frame.data.len) + } + if frame.end_stream { + s.ended = true + } + s.cv.signal() + s.mu.unlock() + if pad_overhead > 0 { + c.send_window_updates(s.id, u32(pad_overhead)) or {} + } + if frame.end_stream { + // The response ended on this DATA frame (an early final response with a + // body, e.g. a 413/auth rejection). Wake any body sender still blocked on + // flow-control credit, mirroring on_response_headers; otherwise it hangs + // when the peer withholds further WINDOW_UPDATEs. + c.wake_send(mut s) + } +} + +// check_unknown_stream distinguishes late frames for cancelled/finished +// streams (fine) from protocol garbage (connection error). +fn (mut c H2MuxConn) check_unknown_stream(stream_id u32) ! { + c.wmu.lock() + known_range := stream_id < c.next_stream_id + c.wmu.unlock() + if stream_id % 2 == 1 && known_range { + return + } + return error('frame for an unknown stream ${stream_id}') +} + +// wake_send marks a stream's send side dead and wakes a body sender that is +// blocked waiting for flow-control credit, so a RST_STREAM or GOAWAY terminates +// the upload instead of hanging it. The caller must hold no connection lock +// (it takes fmu). fail() must already have recorded the terminal error. +fn (mut c H2MuxConn) wake_send(mut s H2MuxStream) { + c.fmu.lock() + s.send_dead = true + c.fcv.broadcast() + c.fmu.unlock() +} + +// reset_stream deregisters a stream, sends RST_STREAM(code), and fails its +// requester — used by the reader for a stream-level protocol/flow-control error. +// Deregistering before the RST lets any in-flight late DATA hit the +// unknown-stream backstop (which credits the connection window). +fn (mut c H2MuxConn) reset_stream(stream_id u32, code H2ErrorCode, reason string) { + c.smu.lock() + mut s := c.streams[stream_id] or { &H2MuxStream(unsafe { nil }) } + c.streams.delete(stream_id) + c.smu.unlock() + c.wmu.lock() + c.write_all_locked(H2Frame(H2RstStreamFrame{ + stream_id: stream_id + error_code: u32(code) + }).encode()) or { + c.wmu.unlock() + c.note_write_failure() + // The stream was removed from c.streams above, so fail_conn() will not + // find it. Wake it directly so the requester does not block forever. + if s != unsafe { nil } { + s.fail('h2: transport write failure', false) + c.wake_send(mut s) + } + return + } + c.wmu.unlock() + if s != unsafe { nil } { + // Credit back any DATA bytes already queued but never drained, + // mirroring cancel_stream, so the connection receive window stays + // accurate after a per-stream reset. + s.mu.lock() + mut queued := u64(0) + for ch in s.chunks { + queued += u64(ch.len) + } + s.chunks.clear() + s.mu.unlock() + if queued > 0 { + c.send_conn_window_update(u32(queued)) or {} + } + s.fail('h2: ${reason}', false) + c.wake_send(mut s) + } +} + +fn (mut c H2MuxConn) lookup_stream(stream_id u32) &H2MuxStream { + c.smu.lock() + defer { + c.smu.unlock() + } + if s := c.streams[stream_id] { + return s + } + return &H2MuxStream(unsafe { nil }) +} + +// fail_conn marks the connection dead, fails every in-flight stream, and wakes +// all blocked senders. Normally called by the reader on transport error; also +// called by note_write_failure when close_transport is nil and cannot interrupt +// the reader. Guards against double-invocation via the c.closed check under smu. +// Teardown is routed through teardown_transport, which runs close_transport +// exactly once, so this path and drop_ref's last-reference teardown can never +// double-close. +fn (mut c H2MuxConn) fail_conn(msg string) { + c.smu.lock() + if c.closed { + c.smu.unlock() + return + } + c.closed = true + c.conn_err = msg + mut open := []&H2MuxStream{} + for _, s in c.streams { + open << s + } + c.streams.clear() + pending_teardown := c.refs <= 0 + c.smu.unlock() + for mut s in open { + s.mu.lock() + retryable := !s.sent_headers + s.mu.unlock() + s.fail(msg, retryable) + } + c.fmu.lock() + c.fmu_dead = true + c.fcv.broadcast() + c.fmu.unlock() + if pending_teardown { + c.teardown_transport() + } +} diff --git a/vlib/net/http/h2_mux_conn_test.v b/vlib/net/http/h2_mux_conn_test.v new file mode 100644 index 000000000..54174ae5a --- /dev/null +++ b/vlib/net/http/h2_mux_conn_test.v @@ -0,0 +1,2123 @@ +// Hermetic tests for the multiplexed HTTP/2 client connection (h2_mux_conn.v): +// concurrent interleaved streams, flow-control blocking, GOAWAY mid-flight, +// per-stream cancellation that must not poison the connection, CONTINUATION +// assembly, and waking every waiter when the connection dies. The peer is a +// scripted in-process HTTP/2 server over an in-memory blocking pipe. +module http + +import sync +import time + +// MuxPipeBuf is a one-way in-memory FIFO with blocking, socket-like reads. +struct MuxPipeBuf { +mut: + mu &sync.Mutex = sync.new_mutex() + data []u8 + closed bool +} + +fn (mut p MuxPipeBuf) write(buf []u8) !int { + p.mu.lock() + defer { + p.mu.unlock() + } + if p.closed { + return error('pipe: write to closed pipe') + } + p.data << buf + return buf.len +} + +fn (mut p MuxPipeBuf) read(mut buf []u8) !int { + for { + p.mu.lock() + if p.data.len > 0 { + mut n := p.data.len + if n > buf.len { + n = buf.len + } + for i in 0 .. n { + buf[i] = p.data[i] + } + p.data = p.data[n..].clone() + p.mu.unlock() + return n + } + if p.closed { + p.mu.unlock() + return error('eof') + } + p.mu.unlock() + time.sleep(time.millisecond) + } + return 0 +} + +fn (mut p MuxPipeBuf) close() { + p.mu.lock() + p.closed = true + p.mu.unlock() +} + +// MuxPipeEnd is one half of a bidirectional pipe. +@[heap] +struct MuxPipeEnd { +mut: + incoming &MuxPipeBuf + outgoing &MuxPipeBuf +} + +fn (mut p MuxPipeEnd) read(mut buf []u8) !int { + return p.incoming.read(mut buf)! +} + +fn (mut p MuxPipeEnd) write(buf []u8) !int { + return p.outgoing.write(buf)! +} + +fn (mut p MuxPipeEnd) close_both() { + p.incoming.close() + p.outgoing.close() +} + +fn new_mux_pipe() (&MuxPipeEnd, &MuxPipeEnd) { + mut a := &MuxPipeBuf{} + mut b := &MuxPipeBuf{} + client := &MuxPipeEnd{ + incoming: b + outgoing: a + } + server := &MuxPipeEnd{ + incoming: a + outgoing: b + } + return client, server +} + +// new_test_mux_conn builds a mux conn over the client pipe end with a real +// (non-nil) close_transport, satisfying new_h2_mux_conn's required-closer +// contract. The closer closes the in-memory pipe, mirroring how a real transport +// adapter would close its socket so teardown wakes the blocked reader. +fn new_test_mux_conn(mut cend MuxPipeEnd) &H2MuxConn { + return new_h2_mux_conn(cend, fn [mut cend] () { + cend.close_both() + }) +} + +// MuxTestPeer is the scripted server side: it parses real frames off the pipe +// (with its own HPACK state) and records what it saw for the test to assert. +@[heap] +struct MuxTestPeer { +mut: + end &MuxPipeEnd + rbuf []u8 + decoder H2HpackDecoder + encoder H2HpackEncoder + mu &sync.Mutex = sync.new_mutex() + // stream id -> request :path, in arrival order + paths map[u32]string + stream_ids []u32 + failure string + data_total map[u32]u64 // DATA bytes received per stream + rst_streams []u32 + conn_window_updates u64 // sum of WINDOW_UPDATE increments on stream 0 +} + +fn (mut p MuxTestPeer) fail(msg string) { + p.mu.lock() + if p.failure == '' { + p.failure = msg + } + p.mu.unlock() +} + +fn (mut p MuxTestPeer) failure_msg() string { + p.mu.lock() + defer { + p.mu.unlock() + } + return p.failure +} + +fn (mut p MuxTestPeer) read_exact(n int) ![]u8 { + for p.rbuf.len < n { + mut tmp := []u8{len: 4096} + got := p.end.read(mut tmp)! + if got <= 0 { + return error('peer: pipe closed') + } + p.rbuf << tmp[..got] + } + out := p.rbuf[..n].clone() + p.rbuf = p.rbuf[n..].clone() + return out +} + +fn (mut p MuxTestPeer) read_preface() ! { + got := p.read_exact(h2_client_preface.len)! + if got.bytestr() != h2_client_preface { + return error('peer: bad connection preface') + } +} + +fn (mut p MuxTestPeer) next_frame() !H2Frame { + head := p.read_exact(h2_frame_header_len)! + header := h2_parse_frame_header(head)! + payload := p.read_exact(int(header.length))! + return h2_parse_frame(header, payload)! +} + +fn (mut p MuxTestPeer) write_frame(f H2Frame) ! { + p.end.write(f.encode())! +} + +// pump consumes one client frame, updating the peer's records. HEADERS blocks +// are decoded (tracking :path); WINDOW_UPDATE / SETTINGS / DATA / RST are +// tallied. Returns the frame. +fn (mut p MuxTestPeer) pump() !H2Frame { + f := p.next_frame()! + match f { + H2SettingsFrame { + if !f.ack { + p.write_frame(H2SettingsFrame{ + ack: true + })! + } + } + H2HeadersFrame { + mut fragment := f.fragment.clone() + if !f.end_headers { + for { + cont := p.next_frame()! + if cont is H2ContinuationFrame { + fragment << cont.fragment + if cont.end_headers { + break + } + } else { + return error('peer: expected CONTINUATION') + } + } + } + fields := p.decoder.decode(fragment)! + mut path := '' + for fld in fields { + if fld.name == ':path' { + path = fld.value + } + } + p.mu.lock() + p.paths[f.stream_id] = path + p.stream_ids << f.stream_id + p.mu.unlock() + } + H2DataFrame { + p.mu.lock() + p.data_total[f.stream_id] = (p.data_total[f.stream_id] or { u64(0) }) + u64(f.data.len) + p.mu.unlock() + } + H2RstStreamFrame { + p.mu.lock() + p.rst_streams << f.stream_id + p.mu.unlock() + } + H2WindowUpdateFrame { + if f.stream_id == 0 { + p.mu.lock() + p.conn_window_updates += u64(f.window_size_increment) + p.mu.unlock() + } + } + else {} + } + + return f +} + +// wait_for_headers pumps until `n` request header blocks have arrived, +// returning the stream ids in arrival order. +fn (mut p MuxTestPeer) wait_for_headers(n int) ![]u32 { + for { + p.mu.lock() + if p.stream_ids.len >= n { + ids := p.stream_ids.clone() + p.mu.unlock() + return ids + } + p.mu.unlock() + p.pump()! + } + return []u32{} +} + +// respond_headers sends a 200 response header block for `stream_id`. +fn (mut p MuxTestPeer) respond_headers(stream_id u32, end_stream bool) ! { + block := p.encoder.encode([H2HeaderField{':status', '200'}, + H2HeaderField{'content-type', 'text/plain'}]) + p.write_frame(H2HeadersFrame{ + stream_id: stream_id + fragment: block + end_headers: true + end_stream: end_stream + })! +} + +// --- worker plumbing ---------------------------------------------------------- + +@[heap] +struct MuxResults { +mut: + mu &sync.Mutex = sync.new_mutex() + bodies map[string]string + statuses map[string]int + errs map[string]string + codes map[string]int +} + +fn (mut r MuxResults) set_ok(path string, resp H2ClientResponse) { + r.mu.lock() + r.bodies[path] = resp.body.bytestr() + r.statuses[path] = resp.status + r.mu.unlock() +} + +fn (mut r MuxResults) set_err(path string, e IError) { + r.mu.lock() + r.errs[path] = e.msg() + r.codes[path] = e.code() + r.mu.unlock() +} + +fn mux_worker(mut c H2MuxConn, req H2ClientRequest, mut out MuxResults) { + resp := c.do(req) or { + out.set_err(req.path, err) + return + } + out.set_ok(req.path, resp) +} + +// --- tests -------------------------------------------------------------------- + +// Three concurrent GETs on one connection; the peer interleaves their DATA +// frames. Each requester must receive exactly its own body. +fn test_mux_concurrent_interleaved_streams() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut workers := []thread{} + for i in 0 .. 3 { + workers << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/w${i}' }, mut out) + } + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(3) or { + peer.fail('headers: ${err.msg()}') + return + } + for id in ids { + peer.respond_headers(id, false) or { + peer.fail('respond: ${err.msg()}') + return + } + } + // Interleave the bodies: one chunk per stream per round. + for round in 0 .. 2 { + for id in ids { + peer.mu.lock() + path := peer.paths[id] or { '' } + peer.mu.unlock() + peer.write_frame(H2DataFrame{ + stream_id: id + data: '${path}#${round};'.bytes() + end_stream: round == 1 + }) or { + peer.fail('data: ${err.msg()}') + return + } + } + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + assert out.errs.len == 0, 'worker errors: ${out.errs}' + for i in 0 .. 3 { + assert out.statuses['/w${i}'] == 200 + assert out.bodies['/w${i}'] == '/w${i}#0;/w${i}#1;' + } + cend.close_both() +} + +// A large request body must block on the 65535-byte connection/stream send +// windows and resume when the peer grants WINDOW_UPDATEs. +fn test_mux_flow_control_blocks_and_resumes() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + body_len := 100000 + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ + method: 'POST' + authority: 't' + path: '/up' + body: []u8{len: body_len, init: `B`} + }, mut out) + peer_thread := spawn fn [body_len] (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + id := ids[0] + // Drain DATA until the client exhausts the 65535-byte windows. + for { + peer.mu.lock() + got := peer.data_total[id] or { u64(0) } + peer.mu.unlock() + if got >= u64(h2_default_initial_window) { + break + } + peer.pump() or { + peer.fail('pump: ${err.msg()}') + return + } + } + peer.mu.lock() + at_block := peer.data_total[id] or { u64(0) } + peer.mu.unlock() + if at_block != u64(h2_default_initial_window) { + peer.fail('expected the client to stop at exactly 65535 sent bytes, got ${at_block}') + return + } + // Grant room on both windows; the client must finish the body. + peer.write_frame(H2WindowUpdateFrame{ + stream_id: 0 + window_size_increment: u32(body_len) + }) or { + peer.fail('wu0: ${err.msg()}') + return + } + peer.write_frame(H2WindowUpdateFrame{ + stream_id: id + window_size_increment: u32(body_len) + }) or { + peer.fail('wu: ${err.msg()}') + return + } + for { + peer.mu.lock() + got := peer.data_total[id] or { u64(0) } + peer.mu.unlock() + if got >= u64(body_len) { + break + } + peer.pump() or { + peer.fail('pump2: ${err.msg()}') + return + } + } + peer.respond_headers(id, true) or { + peer.fail('respond: ${err.msg()}') + return + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + assert out.errs.len == 0, 'worker errors: ${out.errs}' + assert out.statuses['/up'] == 200 + cend.close_both() +} + +// A peer that RST_STREAMs an upload after the client has exhausted its send +// windows must wake the body sender blocked on flow-control credit, so do() +// returns the reset error instead of hanging forever. +fn test_mux_rst_wakes_blocked_body_sender() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + body_len := 100000 + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ + method: 'POST' + authority: 't' + path: '/up' + body: []u8{len: body_len, init: `B`} + }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + id := ids[0] + // Let the client send until both windows are exhausted; it then blocks + // in send_body_on_stream waiting for a WINDOW_UPDATE that never comes. + for { + peer.mu.lock() + got := peer.data_total[id] or { u64(0) } + peer.mu.unlock() + if got >= u64(h2_default_initial_window) { + break + } + peer.pump() or { + peer.fail('pump: ${err.msg()}') + return + } + } + // Reset the stream instead of granting credit. + peer.write_frame(H2RstStreamFrame{ + stream_id: id + error_code: u32(H2ErrorCode.cancel) + }) or { + peer.fail('rst: ${err.msg()}') + return + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + upload_err := out.errs['/up'] or { '' } + assert upload_err != '', 'expected the reset upload to fail, not hang' + assert upload_err.contains('reset'), 'expected a reset error, got: ${upload_err}' + cend.close_both() +} + +// An early final response that carries a body ends with END_STREAM on the DATA +// frame, not on HEADERS. A body sender blocked on flow control must still be +// woken (by on_response_data), abandon the upload with RST_STREAM, and have the +// response delivered — not hang waiting for a WINDOW_UPDATE that never comes. +fn test_mux_early_final_response_with_body_wakes_blocked_upload() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + body_len := 100000 + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ + method: 'POST' + authority: 't' + path: '/up' + body: []u8{len: body_len, init: `B`} + }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + id := ids[0] + // Let the client exhaust both send windows; it then blocks in + // send_body_on_stream waiting for a WINDOW_UPDATE that never comes. + for { + peer.mu.lock() + got := peer.data_total[id] or { u64(0) } + peer.mu.unlock() + if got >= u64(h2_default_initial_window) { + break + } + peer.pump() or { + peer.fail('pump: ${err.msg()}') + return + } + } + // Send a complete final response WITH a body, so END_STREAM lands on the + // DATA frame rather than HEADERS. No WINDOW_UPDATE is ever granted. + block := peer.encoder.encode([H2HeaderField{':status', '413'}, + H2HeaderField{'content-type', 'text/plain'}]) + peer.write_frame(H2HeadersFrame{ + stream_id: id + fragment: block + end_headers: true + end_stream: false + }) or { + peer.fail('resp headers: ${err.msg()}') + return + } + peer.write_frame(H2DataFrame{ + stream_id: id + data: 'rejected'.bytes() + end_stream: true + }) or { + peer.fail('resp data: ${err.msg()}') + return + } + // The client must wake, abandon the upload, and close its half with + // RST_STREAM; pump until we observe it (or the pipe closes). + for { + f := peer.pump() or { return } + if f is H2RstStreamFrame { + return + } + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + upload_err := out.errs['/up'] or { '' } + assert upload_err == '', 'early final response must not error the request, got: ${upload_err}' + assert out.statuses['/up'] or { 0 } == 413, 'expected the 413 response to be delivered' + assert out.bodies['/up'] or { '' } == 'rejected', 'expected the response body to be delivered' + peer.mu.lock() + saw_rst := u32(1) in peer.rst_streams + peer.mu.unlock() + assert saw_rst, 'client must RST_STREAM to close its abandoned upload half' + cend.close_both() +} + +// RFC 9113 §8.3.1: a response HEADERS block without a valid :status is malformed. +// The client must reset the stream (PROTOCOL_ERROR) and surface an error to the +// requester rather than treating it as a 1xx interim and waiting forever. +fn test_mux_response_missing_status_resets_stream() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ + method: 'GET' + authority: 't' + path: '/get' + }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + id := ids[0] + // A response header block with no :status pseudo-header (malformed); the + // stream is left open so the client cannot fall back to "stream closed". + block := peer.encoder.encode([H2HeaderField{'content-type', 'text/plain'}]) + peer.write_frame(H2HeadersFrame{ + stream_id: id + fragment: block + end_headers: true + end_stream: false + }) or { + peer.fail('resp headers: ${err.msg()}') + return + } + // The client must reset the stream; pump until we observe the RST. + for { + f := peer.pump() or { return } + if f is H2RstStreamFrame { + return + } + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + get_err := out.errs['/get'] or { '' } + assert get_err != '', 'expected an error for a response missing :status, not a hang' + assert get_err.contains('status'), 'expected a :status protocol error, got: ${get_err}' + peer.mu.lock() + saw_rst := u32(1) in peer.rst_streams + peer.mu.unlock() + assert saw_rst, 'client must RST_STREAM a malformed (no :status) response' + cend.close_both() +} + +// string.int() is lenient ('200 OK' -> 200), so a non-three-digit :status must +// be rejected by an explicit digit check rather than accepted as a 200 success. +fn test_mux_response_malformed_status_resets_stream() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ + method: 'GET' + authority: 't' + path: '/get' + }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + id := ids[0] + // A :status that int() would parse leniently to 200, but is malformed. + block := peer.encoder.encode([H2HeaderField{':status', '200 OK'}, + H2HeaderField{'content-type', 'text/plain'}]) + peer.write_frame(H2HeadersFrame{ + stream_id: id + fragment: block + end_headers: true + end_stream: false + }) or { + peer.fail('resp headers: ${err.msg()}') + return + } + for { + f := peer.pump() or { return } + if f is H2RstStreamFrame { + return + } + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + get_err := out.errs['/get'] or { '' } + assert get_err != '', 'expected an error for a malformed :status, not a success' + assert get_err.contains('status'), 'expected a :status protocol error, got: ${get_err}' + delivered := out.statuses['/get'] or { -1 } + assert delivered == -1, 'a malformed :status must not be delivered as a success (got ${delivered})' + cend.close_both() +} + +// A stream reset with REFUSED_STREAM means the server did not process the +// request, so it must surface a retryable error (RFC 7540 8.1.4) — even for a +// non-idempotent method — so the pool can replay it on a fresh connection. A +// reset with any other code (e.g. CANCEL) must stay non-retryable. +fn test_mux_refused_stream_reset_is_retryable() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ + method: 'POST' + authority: 't' + path: '/refused' + body: 'x'.bytes() + }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + peer.write_frame(H2RstStreamFrame{ + stream_id: ids[0] + error_code: u32(H2ErrorCode.refused_stream) + }) or { + peer.fail('rst: ${err.msg()}') + return + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + assert out.errs['/refused'] or { '' } != '', 'expected the refused POST to fail' + assert out.codes['/refused'] or { 0 } == h2_err_retryable_code, 'REFUSED_STREAM reset must be retryable, got code ${out.codes['/refused']}' + cend.close_both() +} + +// Padding in a DATA frame counts toward flow control (RFC 7540 6.9.1) even +// though it is never delivered to the app. The client must credit the full +// received payload (pad-length byte + data + padding) back via WINDOW_UPDATE, +// or the connection receive window leaks and eventually stalls. +fn test_mux_padded_data_credits_full_flow_control() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + data := 'hi'.bytes() + pad := []u8{len: 100} + payload_len := 1 + data.len + pad.len // pad-length byte + data + padding + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/p' }, mut out) + peer_thread := spawn fn [data, pad, payload_len] (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + peer.respond_headers(ids[0], false) or { + peer.fail('respond: ${err.msg()}') + return + } + mut payload := [u8(pad.len)] + payload << data + payload << pad + raw := h2_frame_bytes(h2_frame_data, h2_flag_padded | h2_flag_end_stream, ids[0], payload) + peer.end.write(raw) or { + peer.fail('data: ${err.msg()}') + return + } + // Pump until the connection-level WINDOW_UPDATEs sum to the full padded + // payload; with the fix this terminates (data + padding both credited). + for { + peer.mu.lock() + got := peer.conn_window_updates + peer.mu.unlock() + if got >= u64(payload_len) { + break + } + peer.pump() or { + peer.fail('pump: ${err.msg()}') + return + } + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + assert out.statuses['/p'] == 200 + assert out.bodies['/p'] == 'hi' + assert peer.conn_window_updates == u64(payload_len), 'padding must count toward connection flow control' + cend.close_both() +} + +// A received PUSH_PROMISE must fail the connection: we advertise ENABLE_PUSH=0, +// so it is a PROTOCOL_ERROR (RFC 7540 6.6 / 8.2). The peer also sends a valid +// response — with the guard the request fails; without it the push is ignored +// and the request would wrongly succeed. +fn test_mux_push_promise_fails_connection() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/pp' }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + block := peer.encoder.encode([H2HeaderField{':status', '200'}]) + peer.write_frame(H2PushPromiseFrame{ + stream_id: ids[0] + promised_stream_id: 2 + fragment: block + end_headers: true + }) or { + peer.fail('push: ${err.msg()}') + return + } + peer.respond_headers(ids[0], true) or {} + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + assert out.statuses['/pp'] or { 0 } != 200, 'a PUSH_PROMISE must fail the connection, not be ignored' + assert out.errs['/pp'] or { '' } != '' + cend.close_both() +} + +// An invalid SETTINGS_ENABLE_PUSH value (not 0/1) must fail the connection +// (RFC 7540 6.5.2). The peer sends ENABLE_PUSH=2 then a valid response; the +// request must fail rather than succeed. +fn test_mux_invalid_enable_push_fails_connection() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/ep' }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + peer.write_frame(H2SettingsFrame{ + settings: [ + H2Setting{ + id: h2_settings_enable_push + value: 2 + }, + ] + }) or { + peer.fail('settings: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + peer.respond_headers(ids[0], true) or {} + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + assert out.statuses['/ep'] or { 0 } != 200, 'an invalid ENABLE_PUSH must fail the connection' + assert out.errs['/ep'] or { '' } != '' + cend.close_both() +} + +// A stream-level WINDOW_UPDATE with a zero increment is a stream error (RFC +// 7540 6.9): that one stream must be RST and failed, but the connection and +// other concurrent streams must survive. +fn test_mux_zero_window_update_resets_only_that_stream() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut wa := []thread{} + wa << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/a' }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(2) or { + peer.fail('headers: ${err.msg()}') + return + } + mut a := u32(0) + mut b := u32(0) + peer.mu.lock() + for id in ids { + if peer.paths[id] or { '' } == '/a' { + a = id + } else { + b = id + } + } + peer.mu.unlock() + // Illegal zero-increment WINDOW_UPDATE on stream A: A must be reset. + peer.write_frame(H2WindowUpdateFrame{ + stream_id: a + window_size_increment: 0 + }) or { + peer.fail('wu: ${err.msg()}') + return + } + // B is served to completion; the connection must remain usable. + peer.respond_headers(b, false) or { + peer.fail('respond b: ${err.msg()}') + return + } + peer.write_frame(H2DataFrame{ + stream_id: b + data: 'b-ok'.bytes() + end_stream: true + }) or { + peer.fail('data b: ${err.msg()}') + return + } + }(mut peer) + time.sleep(50 * time.millisecond) + mut wb := []thread{} + wb << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/b' }, mut out) + wa.wait() + wb.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + assert out.errs['/a'] or { '' } != '', 'the zero-increment stream must fail' + assert out.statuses['/b'] or { 0 } == 200, 'the other stream and the connection must survive' + assert out.bodies['/b'] == 'b-ok' + cend.close_both() +} + +// GOAWAY with last_stream_id between two active streams: the lower id +// completes, the higher fails with a retryable error, and new requests are +// refused (also retryable). +fn test_mux_goaway_mid_flight() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut w1 := []thread{} + w1 << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/g0' }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + // First stream arrives, then wait for the second before answering, so + // both are unambiguously in flight when GOAWAY is sent. + ids := peer.wait_for_headers(2) or { + peer.fail('headers: ${err.msg()}') + return + } + mut sorted := ids.clone() + sorted.sort() + low := sorted[0] + high := sorted[1] + peer.write_frame(H2GoawayFrame{ + last_stream_id: low + error_code: u32(H2ErrorCode.no_error) + }) or { + peer.fail('goaway: ${err.msg()}') + return + } + // The processed stream still completes. + peer.respond_headers(low, false) or { + peer.fail('respond: ${err.msg()}') + return + } + peer.write_frame(H2DataFrame{ + stream_id: low + data: 'survivor'.bytes() + end_stream: true + }) or { + peer.fail('data: ${err.msg()}') + return + } + _ = high + }(mut peer) + // Make sure the first stream is registered before starting the second, so + // the id order is deterministic. + time.sleep(50 * time.millisecond) + mut w2 := []thread{} + w2 << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/g1' }, mut out) + w1.wait() + w2.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + assert out.statuses['/g0'] == 200 + assert out.bodies['/g0'] == 'survivor' + assert out.errs['/g1'] or { '' } != '' + assert out.codes['/g1'] or { 0 } == h2_err_retryable_code + // New requests on a GOAWAYed connection are refused as retryable. + conn.do(H2ClientRequest{ authority: 't', path: '/late' }) or { + assert err.code() == h2_err_retryable_code + cend.close_both() + return + } + assert false, 'a request on a GOAWAYed connection must fail' +} + +// Cancelling one stream (stop_receiving_limit) must not poison the +// connection: the peer's late DATA for the cancelled stream is absorbed, and +// a second stream completes afterwards. +fn test_mux_cancel_one_stream_other_lives() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut wa := []thread{} + wa << spawn mux_worker(mut conn, H2ClientRequest{ + authority: 't' + path: '/cancelme' + stop_receiving_limit: 5 + }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(2) or { + peer.fail('headers: ${err.msg()}') + return + } + mut a := u32(0) + mut b := u32(0) + peer.mu.lock() + for id in ids { + if peer.paths[id] or { '' } == '/cancelme' { + a = id + } else { + b = id + } + } + peer.mu.unlock() + // Over-deliver on stream A so the client cancels it. + peer.respond_headers(a, false) or { + peer.fail('respond a: ${err.msg()}') + return + } + peer.write_frame(H2DataFrame{ + stream_id: a + data: 'aaaaaaaaaa'.bytes() + }) or { + peer.fail('data a: ${err.msg()}') + return + } + // Wait for the client's RST_STREAM(A). + for { + peer.mu.lock() + rst := a in peer.rst_streams + peer.mu.unlock() + if rst { + break + } + peer.pump() or { + peer.fail('pump: ${err.msg()}') + return + } + } + // Late DATA for the cancelled stream: the client must absorb it and + // keep the connection healthy. + peer.write_frame(H2DataFrame{ + stream_id: a + data: 'late-data!'.bytes() + }) or { + peer.fail('late data: ${err.msg()}') + return + } + // Now serve stream B to completion. + peer.respond_headers(b, false) or { + peer.fail('respond b: ${err.msg()}') + return + } + peer.write_frame(H2DataFrame{ + stream_id: b + data: 'b-survives'.bytes() + end_stream: true + }) or { + peer.fail('data b: ${err.msg()}') + return + } + }(mut peer) + time.sleep(50 * time.millisecond) + mut wb := []thread{} + wb << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/lives' }, mut out) + wa.wait() + wb.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + // The cancelled stream returns its truncated body without an error. + assert out.errs['/cancelme'] or { '' } == '' + assert out.bodies['/cancelme'] == 'aaaaaaaaaa' + // The other stream is unaffected — the connection was not poisoned. (This + // exercises the deregister-before-RST ordering in cancel_stream: the late + // DATA for the cancelled stream is absorbed by the unknown-stream backstop, + // which credits the connection window so other streams keep flowing.) + assert out.errs['/lives'] or { '' } == '' + assert out.bodies['/lives'] == 'b-survives' + cend.close_both() +} + +// Cancelling a stream (stop_receiving_limit) must credit the connection window +// for ALL DATA received on it — including chunks queued before the cancel that +// are discarded undrained — or the connection window leaks on every early +// cancellation. The peer sends three 10-byte chunks; whatever the client does +// not deliver it must still credit back, so the connection WINDOW_UPDATEs sum +// to the full 30 bytes sent. +fn test_mux_cancel_credits_all_received_data() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + total := 30 + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ + authority: 't' + path: '/c' + stop_receiving_limit: 5 + }, mut out) + peer_thread := spawn fn [total] (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + peer.respond_headers(ids[0], false) or { + peer.fail('respond: ${err.msg()}') + return + } + for _ in 0 .. 3 { + peer.write_frame(H2DataFrame{ + stream_id: ids[0] + data: 'xxxxxxxxxx'.bytes() + }) or { + peer.fail('data: ${err.msg()}') + return + } + } + // Pump until every sent DATA byte has been credited back on the + // connection window; with the fix this terminates regardless of how the + // chunks raced against the cancel (drained, queued-then-discarded, or + // late via the backstop). + for { + peer.mu.lock() + got := peer.conn_window_updates + peer.mu.unlock() + if got >= u64(total) { + break + } + peer.pump() or { + peer.fail('pump: ${err.msg()}') + return + } + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + assert peer.conn_window_updates == u64(total), 'cancel must credit every received DATA byte' + cend.close_both() +} + +// A peer SETTINGS_HEADER_TABLE_SIZE only bounds the dynamic table our encoder +// may use for request headers; it must not shrink the table we advertised for +// decoding the server's responses. Applying it to the response decoder would +// break valid dynamic references in later responses, so the decoder limit must +// stay at its advertised default. +fn test_mux_peer_header_table_size_keeps_decoder_limit() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/h' }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + // The peer limits the table it uses to DECODE our request headers to 0. + peer.write_frame(H2SettingsFrame{ + settings: [ + H2Setting{ + id: h2_settings_header_table_size + value: 0 + }, + ] + }) or { + peer.fail('settings: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + id := ids[0] + peer.respond_headers(id, false) or { + peer.fail('respond: ${err.msg()}') + return + } + peer.write_frame(H2DataFrame{ + stream_id: id + data: 'ok'.bytes() + end_stream: true + }) or { + peer.fail('data: ${err.msg()}') + return + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + assert out.statuses['/h'] == 200 + // The response round-trip guarantees the reader processed the peer SETTINGS + // first; our response decoder must still be at the advertised default. + assert conn.decoder.max_dynamic_size == h2_hpack_default_table_size + cend.close_both() +} + +// A POST whose upload is flow-control blocked when GOAWAY repudiates its stream +// (id above last_stream_id) must surface a retryable error, not a plain one, so +// the pool can replay a request the server never processed. +fn test_mux_goaway_preserves_retryable_for_blocked_upload() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + body_len := 100000 + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ + method: 'POST' + authority: 't' + path: '/up' + body: []u8{len: body_len, init: `B`} + }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + id := ids[0] + // Let the upload exhaust both windows so the sender blocks in + // send_body_on_stream waiting for a WINDOW_UPDATE. + for { + peer.mu.lock() + got := peer.data_total[id] or { u64(0) } + peer.mu.unlock() + if got >= u64(h2_default_initial_window) { + break + } + peer.pump() or { + peer.fail('pump: ${err.msg()}') + return + } + } + // GOAWAY repudiating the in-flight stream (last_stream_id 0 < id), with + // no connection error: the stream is retryable and the blocked sender + // must wake with that classification preserved. + peer.write_frame(H2GoawayFrame{ + last_stream_id: 0 + error_code: u32(H2ErrorCode.no_error) + }) or { + peer.fail('goaway: ${err.msg()}') + return + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + assert out.errs['/up'] or { '' } != '', 'expected the repudiated upload to fail, not hang' + assert out.codes['/up'] or { 0 } == h2_err_retryable_code, 'upload error was not retryable: ${out.errs['/up']}' + cend.close_both() +} + +// An out-of-range peer SETTINGS_MAX_FRAME_SIZE (here 0) must fail the connection +// (RFC 7540 6.5.2), not be accepted — a zero frame size would make the send +// path's chunk step 0 and hang it in a zero-length-frame loop. The peer also +// sends a valid response afterwards: with the guard the request fails on the +// bad SETTINGS (processed first); without it the request would wrongly succeed. +fn test_mux_invalid_max_frame_size_fails_connection() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/x' }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + peer.write_frame(H2SettingsFrame{ + settings: [ + H2Setting{ + id: h2_settings_max_frame_size + value: 0 + }, + ] + }) or { + peer.fail('settings: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + // A perfectly valid response — the client must still have failed the + // connection on the illegal SETTINGS processed before it. + peer.respond_headers(ids[0], false) or {} + peer.write_frame(H2DataFrame{ + stream_id: ids[0] + data: 'nope'.bytes() + end_stream: true + }) or {} + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + assert out.statuses['/x'] or { 0 } != 200, 'request must not succeed on an illegal SETTINGS_MAX_FRAME_SIZE' + err_msg := out.errs['/x'] or { '' } + assert err_msg != '', 'expected the connection to fail, not hang or succeed' + assert err_msg.contains('MAX_FRAME_SIZE'), 'unexpected error: ${err_msg}' + cend.close_both() +} + +// A response header block split across HEADERS + CONTINUATION is reassembled. +fn test_mux_continuation_assembly() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/cont' }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + id := ids[0] + block := peer.encoder.encode([H2HeaderField{':status', '200'}, + H2HeaderField{'x-long-header', 'v'.repeat(64)}]) + half := block.len / 2 + peer.write_frame(H2HeadersFrame{ + stream_id: id + fragment: block[..half] + end_headers: false + }) or { + peer.fail('headers: ${err.msg()}') + return + } + peer.write_frame(H2ContinuationFrame{ + stream_id: id + fragment: block[half..] + end_headers: true + }) or { + peer.fail('cont: ${err.msg()}') + return + } + peer.write_frame(H2DataFrame{ + stream_id: id + data: 'ok'.bytes() + end_stream: true + }) or { + peer.fail('data: ${err.msg()}') + return + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + assert out.errs.len == 0, 'worker errors: ${out.errs}' + assert out.statuses['/cont'] == 200 + assert out.bodies['/cont'] == 'ok' + cend.close_both() +} + +// When the connection dies with several streams in flight, every waiter must +// wake with an error (no hangs). +fn test_mux_conn_death_wakes_all_waiters() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut workers := []thread{} + for i in 0 .. 4 { + workers << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/d${i}' }, mut out) + } + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + peer.wait_for_headers(4) or { + peer.fail('headers: ${err.msg()}') + return + } + // Kill the connection with all four streams mid-flight. + peer.end.close_both() + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + assert out.errs.len == 4, 'all four waiters must fail, got: ${out.errs}' + // A subsequent request on the dead connection fails fast and retryably. + conn.do(H2ClientRequest{ authority: 't', path: '/postmortem' }) or { + assert err.code() == h2_err_retryable_code + return + } + assert false, 'a request on a dead connection must fail' +} + +// When the connection preface write fails, note_write_failure must be called so +// the connection is torn down and the pool stops admitting new work. Both the +// failing request and any immediately subsequent one must return retryable errors. +fn test_mux_preface_write_failure_tears_down() { + mut cend, mut pend := new_mux_pipe() + // Close the write side so the very first transport write (the preface) fails. + cend.outgoing.close() + mut conn := new_test_mux_conn(mut cend) + mut out := &MuxResults{} + mut w1 := []thread{} + w1 << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/x' }, mut out) + w1.wait() + assert out.errs['/x'] or { '' } != '', 'request on broken transport must fail' + assert out.codes['/x'] or { 0 } == h2_err_retryable_code, 'preface write failure must be retryable' + // note_write_failure must have set shutting_down; the second request must + // fail retryably rather than be admitted to the dead connection. + mut out2 := &MuxResults{} + mut w2 := []thread{} + w2 << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/y' }, mut out2) + w2.wait() + assert out2.errs['/y'] or { '' } != '', 'second request on torn-down connection must fail' + assert out2.codes['/y'] or { 0 } == h2_err_retryable_code, 'second request must also be retryable' + cend.incoming.close() // unblock the reader thread so it exits cleanly + pend.close_both() +} + +// Applying a positive SETTINGS_INITIAL_WINDOW_SIZE delta to a stream that +// already has extra credit from a WINDOW_UPDATE can overflow the stream's send +// window above 2^31-1, which is a connection FLOW_CONTROL_ERROR (RFC 7540 +// §6.9.2). The connection must be failed, not silently keep the invalid window. +fn test_mux_settings_initial_window_delta_overflow_fails_connection() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut workers := []thread{} + // A simple GET keeps the stream registered in c.streams (waiting for a + // response that never arrives), so the SETTINGS delta has a stream to hit. + workers << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/ov' }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + // Raise the stream's send window close to 2^31-1 via WINDOW_UPDATE, + // then raise SETTINGS_INITIAL_WINDOW_SIZE so the delta pushes the window + // over the limit. Stream send_window ≈ 65535 + 0x7fff_0000 = 0x7fff_ffff; + // delta = 0x7fff_ffff - 65535 = 0x7fff_0000 → new window ≈ 0xfffe_0000 + // which exceeds 0x7fff_ffff → FLOW_CONTROL_ERROR. + peer.write_frame(H2WindowUpdateFrame{ + stream_id: 1 + window_size_increment: u32(0x7fff_0000) + }) or { + peer.fail('wu: ${err.msg()}') + return + } + peer.write_frame(H2SettingsFrame{ + settings: [H2Setting{h2_settings_initial_window_size, u32(0x7fff_ffff)}] + }) or { + peer.fail('settings: ${err.msg()}') + return + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + err_msg := out.errs['/ov'] or { '' } + assert err_msg != '', 'expected a connection FLOW_CONTROL_ERROR, got success or hang' + assert err_msg.contains('FLOW_CONTROL_ERROR'), 'unexpected error: ${err_msg}' + cend.close_both() +} + +// A server that ignores our advertised receive window and sends more DATA than +// it allows must trigger a FLOW_CONTROL_ERROR: the connection must be failed, +// not silently buffer unbounded data. on_data blocks the requester from draining +// (and sending WINDOW_UPDATE) while the peer floods the connection window. +fn test_mux_peer_exceeding_recv_window_fails_connection() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + // Deplete the tracked connection-level receive window to 1 byte so the + // very first DATA frame (2 bytes) immediately overflows it and triggers + // FLOW_CONTROL_ERROR. Doing this before any goroutine sends frames means + // no WINDOW_UPDATE is ever sent that could re-fill the window first, so + // the result is fully deterministic with no concurrent timing race. + conn.recv_wmu.lock() + conn.conn_recv_window = 1 + conn.recv_wmu.unlock() + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ + authority: 't' + path: '/flood' + }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + peer.respond_headers(ids[0], false) or { + peer.fail('respond: ${err.msg()}') + return + } + // 2-byte DATA frame: connection window is 1 byte, so this exceeds it + // and must trigger FLOW_CONTROL_ERROR on the client. + peer.write_frame(H2DataFrame{ + stream_id: ids[0] + data: [u8(0), 0] + }) or { peer.fail('data: ${err.msg()}') } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + err_msg := out.errs['/flood'] or { '' } + assert err_msg != '', 'expected FLOW_CONTROL_ERROR, got success or hang' + assert err_msg.contains('FLOW_CONTROL_ERROR'), 'unexpected error: ${err_msg}' + cend.close_both() +} + +// A STREAM-level receive-window violation (RFC 7540 §6.9.1) must reset only that +// stream, not tear down the whole connection: the offending request fails, but +// the connection stays alive (closed stays false) so other streams are unharmed. +fn test_mux_stream_window_violation_resets_only_that_stream() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + // Inflate the connection window so the oversized DATA trips ONLY the + // stream-level window check, not the connection-level one. + conn.recv_wmu.lock() + conn.conn_recv_window = 10_000_000 + conn.recv_wmu.unlock() + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ + authority: 't' + path: '/flood' + }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer, mut conn H2MuxConn) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + id := ids[0] + peer.respond_headers(id, false) or { + peer.fail('respond: ${err.msg()}') + return + } + // White-box: shrink just this stream's receive window so the next DATA + // frame exceeds it while the connection window stays valid. No DATA has + // arrived yet, so the requester has not credited the window back. + conn.smu.lock() + if mut s := conn.streams[id] { + s.mu.lock() + s.recv_window = 1 + s.mu.unlock() + } + conn.smu.unlock() + peer.write_frame(H2DataFrame{ + stream_id: id + data: [u8(0), 0] + }) or { + peer.fail('data: ${err.msg()}') + return + } + // The client must RST only this stream; pump until we see it. + for { + f := peer.pump() or { return } + if f is H2RstStreamFrame { + return + } + } + }(mut peer, mut conn) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + err_msg := out.errs['/flood'] or { '' } + assert err_msg != '', 'stream-window violation must error the request' + assert err_msg.contains('FLOW_CONTROL_ERROR') || err_msg.contains('receive window'), 'unexpected error: ${err_msg}' + // The connection must survive — closed is set only when the reader exits on a + // connection-level failure, which a stream reset must not cause. + conn.smu.lock() + closed := conn.closed + conn.smu.unlock() + assert !closed, 'a stream-level flow-control violation must NOT close the connection' + cend.close_both() +} + +// DATA after END_STREAM on a fully "closed" stream (we also sent our END_STREAM) +// is a connection error (RFC 7540 §5.1 MUST). +fn test_mux_data_after_end_stream_on_closed_stream_fails_connection() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + // White-box: register a stream in the "closed" state — both the peer (ended) + // and we (send_closed) have finished sending. + mut s := new_h2_mux_stream() + s.id = 1 + s.headers_done = true + s.ended = true + s.send_closed = true + conn.smu.lock() + conn.streams[1] = s + conn.smu.unlock() + // A DATA frame on the closed stream must fail the whole connection. + peer.write_frame(H2DataFrame{ stream_id: 1, data: [u8(0)] }) or { + assert false, 'write: ${err.msg()}' + } + mut closed := false + for _ in 0 .. 2000 { + conn.smu.lock() + closed = conn.closed + conn.smu.unlock() + if closed { + break + } + time.sleep(time.millisecond) + } + assert closed, 'DATA after END_STREAM on a closed stream must fail the connection' + cend.close_both() +} + +// DATA after END_STREAM on a "half-closed (remote)" stream (the peer ended but +// we have NOT closed our send side) is a STREAM error: RST that stream, keep the +// connection alive (RFC 7540 §5.1). +fn test_mux_data_after_end_stream_half_closed_resets_only_stream() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + // White-box: register a stream where the peer has ended but our send side is + // still open (send_closed = false) — "half-closed (remote)". + mut s := new_h2_mux_stream() + s.id = 1 + s.headers_done = true + s.ended = true + s.send_closed = false + conn.smu.lock() + conn.streams[1] = s + conn.smu.unlock() + peer.write_frame(H2DataFrame{ stream_id: 1, data: [u8(0)] }) or { + assert false, 'write: ${err.msg()}' + } + // The client must RST just this stream; pump until we see it. + mut saw_rst := false + for _ in 0 .. 50 { + f := peer.pump() or { break } + if f is H2RstStreamFrame { + saw_rst = true + break + } + } + assert saw_rst, 'half-closed-remote DATA-after-END_STREAM must RST the stream' + conn.smu.lock() + closed := conn.closed + conn.smu.unlock() + assert !closed, 'a stream-level reset must NOT close the connection' + cend.close_both() +} + +// A peer that floods control frames before the client preface (the connection +// stays pre-handshake until the first request) must not grow the deferred-ACK +// buffers without bound — the connection is failed once the cap is exceeded. +fn test_mux_preface_control_flood_fails_connection() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + // No request is made, so the lazy client preface is never sent; every PING + // is deferred. Send more than the cap allows. + for _ in 0 .. (h2_max_pending_preface_acks + 5) { + peer.write_frame(H2PingFrame{ data: [u8(1), 2, 3, 4, 5, 6, 7, 8] }) or { + assert false, 'write: ${err.msg()}' + } + } + mut closed := false + for _ in 0 .. 2000 { + conn.smu.lock() + closed = conn.closed + conn.smu.unlock() + if closed { + break + } + time.sleep(time.millisecond) + } + assert closed, 'a pre-preface control-frame flood must fail the connection' + cend.close_both() +} + +// A malformed Content-Length (string.u64() would leniently parse '12junk' -> 12) +// makes the response malformed; it must be rejected, not accepted as a success. +fn test_mux_malformed_content_length_resets_stream() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/x' }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + block := peer.encoder.encode([H2HeaderField{':status', '200'}, + H2HeaderField{'content-length', '12junk'}]) + peer.write_frame(H2HeadersFrame{ + stream_id: ids[0] + fragment: block + end_headers: true + end_stream: false + }) or { + peer.fail('resp: ${err.msg()}') + return + } + for { + f := peer.pump() or { return } + if f is H2RstStreamFrame { + return + } + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + err_msg := out.errs['/x'] or { '' } + assert err_msg != '', 'malformed Content-Length must error the request, not succeed' + assert err_msg.to_lower().contains('content-length'), 'unexpected error: ${err_msg}' + cend.close_both() +} + +// RFC 7540 §8.1: a response must begin with HEADERS. A DATA frame before any +// response HEADERS is malformed — reset that stream, but keep the connection. +fn test_mux_data_before_headers_resets_stream() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + mut out := &MuxResults{} + mut workers := []thread{} + workers << spawn mux_worker(mut conn, H2ClientRequest{ authority: 't', path: '/x' }, mut out) + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + // DATA with no preceding response HEADERS (malformed). + peer.write_frame(H2DataFrame{ + stream_id: ids[0] + data: 'oops'.bytes() + }) or { + peer.fail('data: ${err.msg()}') + return + } + for { + f := peer.pump() or { return } + if f is H2RstStreamFrame { + return + } + } + }(mut peer) + workers.wait() + peer_thread.wait() + assert peer.failure_msg() == '' + err_msg := out.errs['/x'] or { '' } + assert err_msg != '', 'DATA before HEADERS must error the request' + conn.smu.lock() + closed := conn.closed + conn.smu.unlock() + assert !closed, 'DATA before HEADERS is a stream error; the connection must survive' + cend.close_both() +} + +// Releasing the last reference on an idle connection must wake the background +// reader through the required close_transport callback. With no requests and no +// inbound frames the reader is blocked in transport.read(); only closing the +// transport can unblock it (the H2Transport interface has no close()). This +// exercises the teardown -> closer -> reader-exit chain WITHOUT the test closing +// the pipe manually, guarding the Codex P2 fix (discussion_r3475761576): a nil +// closer is now rejected, so teardown can always close the transport. (Note: the +// nil-closer leak itself is reproduced statically by detect_mux_requires_closer.sh, +// since a nil closer now panics and cannot be exercised at runtime.) +fn test_mux_release_wakes_reader_via_closer() { + mut cend, _ := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + // No requests, no frames: the reader is blocked in transport.read(). + conn.shutdown_when_idle() + conn.release() // last ref -> teardown_transport -> close_transport closes the pipe + // The reader observes the closed transport and exits via fail_conn(closed=true). + mut woke := false + for _ in 0 .. 200 { + conn.smu.lock() + woke = conn.closed + conn.smu.unlock() + if woke { + break + } + time.sleep(5 * time.millisecond) + } + assert woke, 'reader did not exit after release(): close_transport failed to wake it' +} + +// A trailing HEADERS block (trailers) must surface its non-pseudo fields in the +// response headers, matching the synchronous H2Conn.read_response. Regression for +// Codex P2 (discussion_r3477245531): the mux path HPACK-decoded the trailer block +// but dropped every field, only marking the stream ended — so callers lost +// grpc-status, digest, etc. even though the stream completed normally. +fn test_mux_response_trailers_preserved() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + id := ids[0] + // Final response headers (no END_STREAM: a body and trailers follow). + peer.respond_headers(id, false) or { + peer.fail('respond: ${err.msg()}') + return + } + peer.write_frame(H2DataFrame{ + stream_id: id + data: 'hi'.bytes() + end_stream: false + }) or { + peer.fail('data: ${err.msg()}') + return + } + // A trailing HEADERS block carrying END_STREAM, with valid (non-pseudo) + // trailer fields that must be preserved. (Pseudo-headers in trailers are + // malformed per §8.1 and are covered by the rejection test below.) + trailer_block := peer.encoder.encode([H2HeaderField{'grpc-status', '0'}, + H2HeaderField{'grpc-message', 'ok'}]) + peer.write_frame(H2HeadersFrame{ + stream_id: id + fragment: trailer_block + end_headers: true + end_stream: true + }) or { + peer.fail('trailers: ${err.msg()}') + return + } + }(mut peer) + resp := conn.do(H2ClientRequest{ authority: 't', path: '/g' }) or { + assert false, 'request failed: ${err}' + return + } + peer_thread.wait() + assert peer.failure_msg() == '' + assert resp.status == 200 + assert resp.body.bytestr() == 'hi' + mut grpc_status := '' + mut grpc_message := '' + mut pseudo_in_headers := false + for h in resp.headers { + match h.name { + 'grpc-status' { grpc_status = h.value } + 'grpc-message' { grpc_message = h.value } + else {} + } + + if h.name.starts_with(':') { + pseudo_in_headers = true + } + } + assert grpc_status == '0', 'trailer grpc-status missing from response headers: ${resp.headers}' + assert grpc_message == 'ok', 'trailer grpc-message missing from response headers: ${resp.headers}' + assert !pseudo_in_headers, 'pseudo-header field leaked into trailers: ${resp.headers}' + cend.close_both() +} + +// mux_response_rejected runs one request whose response carries `resp_fields` and +// asserts the request fails with an error containing `want` (the stream was reset +// as malformed), rather than delivering the response. +fn mux_response_rejected(resp_fields []H2HeaderField, want string) { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + peer_thread := spawn fn (mut peer MuxTestPeer, resp_fields []H2HeaderField) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + block := peer.encoder.encode(resp_fields) + peer.write_frame(H2HeadersFrame{ + stream_id: ids[0] + fragment: block + end_headers: true + end_stream: true + }) or { + peer.fail('resp: ${err.msg()}') + return + } + // Drain the client's RST_STREAM until the pipe closes. + for { + peer.pump() or { return } + } + }(mut peer, resp_fields) + mut got := '' + if resp := conn.do(H2ClientRequest{ authority: 't', path: '/x' }) { + got = '<>' + } else { + got = err.msg() + } + cend.close_both() // unblock the peer's pump loop so its thread exits + peer_thread.wait() + assert peer.failure_msg() == '' + assert got.contains(want), 'expected "${want}" in the rejection, got: ${got}' +} + +// RFC 9113 §8.2: a response carrying a malformed field — a connection-specific +// header (transfer-encoding/connection/...) or an uppercase field name — is +// malformed and must be rejected, not delivered to the caller. Regression for +// the proactive RFC-conformance audit (gap G1): both client paths previously +// appended received fields with only :status / content-length validation. +fn test_mux_response_rejects_malformed_fields() { + mux_response_rejected([H2HeaderField{':status', '200'}, + H2HeaderField{'transfer-encoding', 'chunked'}], 'transfer-encoding') + mux_response_rejected([H2HeaderField{':status', '200'}, + H2HeaderField{'Content-Type', 'text/plain'}], 'uppercase') + // An undefined response pseudo-header is also malformed (§8.3.1). + mux_response_rejected([H2HeaderField{':status', '200'}, H2HeaderField{':custom', 'x'}], + 'pseudo-header') +} + +// RFC 9113 §8.1: a trailing HEADERS block carrying a pseudo-header (or any +// malformed field) is malformed and must reset the stream, not be dropped. +fn test_mux_trailers_reject_pseudo() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + mut peer := &MuxTestPeer{ + end: pend + } + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + ids := peer.wait_for_headers(1) or { + peer.fail('headers: ${err.msg()}') + return + } + id := ids[0] + peer.respond_headers(id, false) or { + peer.fail('respond: ${err.msg()}') + return + } + // Trailers must not contain a pseudo-header. + block := peer.encoder.encode([H2HeaderField{':status', '200'}]) + peer.write_frame(H2HeadersFrame{ + stream_id: id + fragment: block + end_headers: true + end_stream: true + }) or { + peer.fail('trailers: ${err.msg()}') + return + } + for { + peer.pump() or { return } + } + }(mut peer) + mut got := '' + if resp := conn.do(H2ClientRequest{ authority: 't', path: '/g' }) { + got = '<>' + } else { + got = err.msg() + } + cend.close_both() + peer_thread.wait() + assert peer.failure_msg() == '' + assert got.contains('malformed trailers'), 'trailers pseudo-header not rejected: ${got}' +} + +// RFC 9113 §6.5.2: the client honors the peer's advisory +// SETTINGS_MAX_HEADER_LIST_SIZE and refuses an over-limit request locally rather +// than emitting it. Conformance gap G4. The peer sends no SETTINGS here, so the +// reader never writes peer_max_header_list_size — setting it directly before the +// request is race-free. +fn test_mux_request_respects_peer_max_header_list_size() { + mut cend, mut pend := new_mux_pipe() + mut conn := new_test_mux_conn(mut cend) + conn.peer_max_header_list_size = 40 // tiny: even the pseudo-headers exceed it + mut peer := &MuxTestPeer{ + end: pend + } + peer_thread := spawn fn (mut peer MuxTestPeer) { + peer.read_preface() or { + peer.fail('preface: ${err.msg()}') + return + } + for { + peer.pump() or { return } + } + }(mut peer) + mut got := '' + if _ := conn.do(H2ClientRequest{ + method: 'GET' + scheme: 'https' + authority: 'example.com' + path: '/a-fairly-long-path-to-exceed-the-limit' + }) + { + got = '<>' + } else { + got = err.msg() + } + cend.close_both() + peer_thread.wait() + assert peer.failure_msg() == '' + assert got.contains('MAX_HEADER_LIST_SIZE'), 'over-limit request not rejected: ${got}' +} diff --git a/vlib/net/http/h2_server.v b/vlib/net/http/h2_server.v index df7884fa2..0b71d8fe8 100644 --- a/vlib/net/http/h2_server.v +++ b/vlib/net/http/h2_server.v @@ -212,7 +212,11 @@ fn (mut c H2ServerConn) apply_settings(settings []H2Setting) ! { for s in settings { match s.id { h2_settings_header_table_size { + // Constrains our *encoder* (the server's response-header encoder + // whose output the client decodes with its advertised table size). c.peer.header_table_size = s.value + c.encoder.dyn_table.set_max_size(int(s.value)) + c.encoder.pending_max_table_size = int(s.value) } h2_settings_enable_push { c.peer.enable_push = s.value != 0 @@ -228,9 +232,17 @@ fn (mut c H2ServerConn) apply_settings(settings []H2Setting) ! { 'SETTINGS_INITIAL_WINDOW_SIZE ${s.value} exceeds 2^31-1') or {} return error('h2 server: SETTINGS_INITIAL_WINDOW_SIZE ${s.value} exceeds 2^31-1 (FLOW_CONTROL_ERROR)') } - // RFC 7540 §6.9.2: a change to the initial window size adjusts - // the send window of every active stream by the delta. + // RFC 7540 §6.9.2: validate ALL stream windows before mutating any + // so that a delta overflow on stream N does not leave streams 1..N-1 + // partially updated. Send FLOW_CONTROL_ERROR (not PROTOCOL_ERROR). delta := i64(s.value) - i64(c.peer.initial_window_size) + for _, st in c.streams { + if st.send_window + delta > i64(0x7fff_ffff) { + c.send_goaway(.flow_control_error, + 'SETTINGS_INITIAL_WINDOW_SIZE delta overflows stream ${st.id} send window') or {} + return error('h2 server: SETTINGS_INITIAL_WINDOW_SIZE delta overflows stream ${st.id} send window (RFC 7540 §6.9.2 FLOW_CONTROL_ERROR)') + } + } c.peer.initial_window_size = s.value for _, mut st in c.streams { st.send_window += delta @@ -276,6 +288,9 @@ fn (mut c H2ServerConn) on_continuation(frame H2ContinuationFrame, mut handler H mut s := c.streams[frame.stream_id] or { return error('h2 server: CONTINUATION for unknown stream ${frame.stream_id}') } + if s.hpack_block.len + frame.fragment.len > h2_max_recv_header_block { + return error('h2 server: request header block exceeds ${h2_max_recv_header_block} bytes') + } s.hpack_block << frame.fragment if frame.end_headers { s.end_headers = true @@ -299,9 +314,10 @@ fn (mut c H2ServerConn) finalize_headers(mut s H2ServerStream, mut handler Handl fn (mut c H2ServerConn) on_data(frame H2DataFrame, mut handler Handler) ! { mut s := c.streams[frame.stream_id] or { // DATA for an unknown stream (likely already RST'd); just drop and - // keep flow control consistent. - if frame.data.len > 0 { - c.send_window_update(0, u32(frame.data.len))! + // keep flow control consistent. Credit flow_size (full wire bytes + // including padding) per RFC 7540 §6.9.1. + if frame.flow_size > 0 { + c.send_window_update(0, u32(frame.flow_size))! } return } @@ -310,15 +326,23 @@ fn (mut c H2ServerConn) on_data(frame H2DataFrame, mut handler Handler) ! { } if frame.data.len > 0 { if s.body.len + frame.data.len > h2_server_max_request_body { + // Credit the connection window before resetting so the peer is + // not penalised for bytes it legitimately sent within its window. + c.send_window_update(0, u32(frame.flow_size)) or {} c.send_rst_stream(s.id, .refused_stream)! c.streams.delete(s.id) return } s.body << frame.data - // Replenish the connection window; per-stream we replenish on - // completion since we hold the body in memory. - c.send_window_update(0, u32(frame.data.len))! - c.send_window_update(s.id, u32(frame.data.len))! + } + // Replenish flow control using flow_size (full wire payload including padding), + // per RFC 7540 §6.9.1. Credit unconditionally when flow_size>0: a padding-only + // DATA frame (data.len==0, flow_size>0) still consumes the peer's send window + // and must be credited back. (Formerly inside the data.len>0 block — padding-only + // frames leaked window silently.) + if frame.flow_size > 0 { + c.send_window_update(0, u32(frame.flow_size))! + c.send_window_update(s.id, u32(frame.flow_size))! } if frame.end_stream { s.end_stream = true @@ -527,6 +551,10 @@ fn (mut c H2ServerConn) send_goaway(code H2ErrorCode, msg string) ! { fn (mut c H2ServerConn) read_frame() !H2Frame { c.fill_at_least(h2_frame_header_len)! header := h2_parse_frame_header(c.rbuf)! + // Check against our own advertised receive limit (sent in send_initial_settings). + // H2ServerConn always advertises h2_default_max_frame_size and never renegotiates + // it, so the constant is correct here. c.peer.max_frame_size is the client's + // receive limit (our outbound cap) and must not be used for inbound checking. if header.length > h2_default_max_frame_size { return error('h2 server: frame larger than SETTINGS_MAX_FRAME_SIZE (${header.length})') } -- 2.39.5