Skip to content

fix(h2,sse,eventloop): close out the HTTP/2 review issues (#234-#238), SSE id sanitization (#265) and a growth-stable connection table (#343) - #384

Merged
cryo2010 merged 15 commits into
mainfrom
fix/h2-sse-accept-hardening
Oct 1, 2026
Merged

cryo2010 merged 15 commits into
mainfrom
fix/h2-sse-accept-hardening

Conversation

@cryo2010

@cryo2010 cryo2010 commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Closes out the five HTTP/2 review issues that PR #242 fixed but never closed (its body cited the range in prose, so GitHub left #234 through #238 open), with a re-audit of every claim against current main, the residual gaps that audit and an adversarial review found, and the regression coverage none of them had. Also fixes the SSE id sanitization gap and replaces the count-and-log mitigation for #343 with the growth-stable connection table the issue asked for. One commit per issue, plus review follow-ups.

Fixes #234
Fixes #235
Fixes #236
Fixes #237
Fixes #238
Fixes #265
Fixes #343

HTTP/2 control-frame budget (#234)

Audit of the eight vectors: PING ACK, received GOAWAY, unknown frame types, the self-dependent PRIORITY ordering and the RST-on-idle rule, closed-stream DATA, the per-request reset and per-entry SETTINGS charging were all fixed in #242 / #335. Three charges were still missing, all the same shape (an overhead frame reaching its reply or early return before noteControlFrame):

  • a SETTINGS ACK returned unbudgeted, exactly as a PING ACK once did;
  • a zero-increment WINDOW_UPDATE on a closed stream id answered with a RST_STREAM per frame and tore nothing down, so it repeated for the life of the connection;
  • a stream-level WINDOW_UPDATE was never charged at all.

The adversarial review then showed two things about the credit model from #335. The increment-1 data dribble (SETTINGS_INITIAL_WINDOW_SIZE=0, then WINDOW_UPDATE(sid, 1) per frame, each forcing a 1-byte DATA frame and a scheduler pass) was still free, because an increment of 1 always makes the stream sendable, and credit is earned per 256 bytes sent but spent per update, so a 16-byte-event SSE stream whose client acked every frame tripped ENHANCE_YOUR_CALM at the default budget after about 1070 events. The model now earns two credits per DATA frame emitted (one stream-level plus one connection-level update per frame is the finest acknowledgement a correct client can send) in addition to one per 256 bytes, and a dribble (increment under 256 bytes that leaves the window under 256 bytes while bytes wait on it) is charged straight to the budget on both levels, never to the credit pool, because the 1-byte frames it forces would otherwise bank the credit that pays for it. A frame that trips the budget no longer draws a RST_STREAM after its own GOAWAY.

tests/test_http2_budget.nim (17 tests) floods one vector per test at a budget of 20 and asserts GOAWAY(ENHANCE_YOUR_CALM) with a bounded RST_STREAM count, covers the RFC 9113 5.1 idle-stream rule, per-entry SETTINGS charging, the one-GET-per-burst interleave, both dribbles, and the converses: a few control frames per request, a 16-byte SSE stream acked on both levels per frame, and a 200 KiB download acked in 4096-byte increments all complete without a GOAWAY.

Buffered-body memory cap (#235)

No production change needed: #242's independent aggregate of un-dispatched buffered bytes, max(h2ConnWindow, maxBodySize) (never below the 64 KiB default receive window), is released on every teardown path and refuses the crossing stream with REFUSED_STREAM, which is retryable because the handler never ran. Credit stays eager on purpose: deferring it would deadlock any upload larger than the connection window. The debug-only counter audit now re-derives the aggregate from a full stream scan, so a later teardown path that forgets a release fails the suite instead of silently shrinking what the connection accepts. tests/test_http2_backpressure.nim trickles 32 POST streams past a 512 KiB cap (3 MiB pinned with the check removed), proves a cancelled stream returns its reservation, and uploads 512 KiB across a 256 KiB connection window.

Zero-window response stall (#236)

#242 armed the body deadline from the input path when a stream owes response bytes blocked on the peer window, but h2Deadline had exactly one call site, so a stall that began outside an inbound event was never armed. A streamed sendFile parks its first chunk from the outbox under a file-chunk pin, which does not re-process input, and a silent client never has buffered bytes to trigger it: a 2 MiB download to a client that absorbed 16 KiB of window and went quiet produced no GOAWAY and no close at all (reproduced). The per-second timeout sweep now arms the same deadline from the same predicate. Covered for a buffered response and for a streamed sendFile, plus the converse that a client returning credit is never cut off.

Streaming content-length reconciliation (#237)

#242 reconciled at END_STREAM and in the trailers path, but a streaming route relays as it goes, so an over-length body had already been handed to the sink chunk by chunk (and a client that never sent the terminating frame never saw a reset at all). The stream is now reset with PROTOCOL_ERROR as soon as bodyReceived exceeds the declared length, before the chunk reaches the sink; a declared body with END_STREAM on the request HEADERS is reset before dispatch instead of dispatching a handler that is flushed a clean empty body; and the HTTP/3 backend applies the same early check. A streaming request HTTP/3 has already rejected mid-batch is no longer dispatched from the ready queue either (reachable under packet reordering, not with curl on loopback, which the h3 tests document). Flow-control credit is balanced on every new reset path (48 rejected over-length streams pushing 6x the connection window, then a full-window upload, completes).

Trailer field validation (#238)

Inbound trailer names and values already went through validFieldName / validFieldValue and the connection-specific set since #242; content-length in a request trailer section was still stored and is now rejected (h1 already dropped it). The adversarial review found the outbound half was three different implementations: h2 emitted content-length and te in a trailing HEADERS, h3 emitted content-length, h1 emitted te. One shared predicate in fieldrules.nim now filters response trailers on all three protocols, and h2/h3 validate and lowercase trailer names as HPACK/QPACK require. tests/test_http2_request_body.nim (28 tests) covers both issues at the frame level, including the issue's a\r\nSet-Cookie: pwn=1 payload; tests/test_trailers.nim covers the response side on h1 and h2 (h3 via curl).

SSE id sanitization (#265)

sseSanitize strips NUL alongside CR and LF for id, event and comment text (not data, where a client appends it to the payload), and an id or event that sanitizes to empty is not emitted, since an empty id: field is a Last-Event-ID reset to a client and "\r\n" or "\0" asked for no such thing. An explicit reset stays inexpressible because id = "" is already the no-field default; documented in the docstring and README, along with the fact that an id therefore does not round-trip byte for byte. Asserted against exact wire bytes in tests/test_sse_streaming.nim.

Growth-stable connection table (#343)

LoopCore.conns was a flat seq[Connection]; growing it for a high fd moved every element, so handleAccept refused the connection whenever a blocking: worker held addr conns[fd], and #349 made that refusal counted and logged. It is now a ConnTable of fixed 1024-slot blocks that are never resized or moved: a Nim seq is a header plus a separately allocated payload, growing the outer seq[seq[Connection]] byte-copies the inner headers and leaves every payload where it was, so a worker's ptr Connection survives any number of later growths and the refusal, its counters and its log line are gone. =copy on the table is a compile error, and conn() asserts the loop-thread rule in debug builds, because the outer block list now reallocates under growth where the old flat table stood still while anything was pinned. tests/test_conn_table.nim (built with -d:vortexConnBlock=8 from a per-suite .nims) pins the structural invariant and the live scenario: 24 connections served through several growth events while a worker holds a pin that an atomic proves is live, then the pinned response arrives on its own connection; the suite also runs under ASan and now in CI's ThreadSanitizer job.

Follow-ups found during the audits (not changed here)

  1. The deadlines for a trickling uploader (fix(h2): buffered request bodies escape the connection-window memory cap (~2 GiB per connection) #235) and a peer granting 1 byte of window every few seconds (fix(h2): connection pinned forever when responses stall on peer flow control (deadline cleared) #236) are idle-based, as bodyTimeout and writeTimeout are documented to be; a progress-based variant is a cross-protocol behaviour change worth its own issue. Memory is capped either way and maxConnections caps slots.
  2. An h2 stream whose deferred response never arrives has no deadline (h2Deadline returns dkNone; dkResponse is h1 only).
  3. HTTP/3: when nghttp3 itself fails a message mid-batch and the shim runs failConn, a handler already queued for that stream still runs (memory-safe, response discarded). Closing it needs a shim-visible "connection failed" query, i.e. a C++ shim change.
  4. h2/h3 accept and store the wider RFC 9110 6.5.1 trailer superset that h1 drops (host, cache-control, authorization, ...); only the framing and connection fields are rejected. Full parity is a behaviour change worth its own issue.
  5. Extended CONNECT with a content-length header is accepted and never reconciled on h1, h2 and h3 alike.
  6. Reads are still not gated on pendingOut; writeTimeout (30 s since fix: protocol hardening round 2 (h2 GOAWAY on close, h3 WebSocket accept races, body-pause leak, writeTimeout default) #349) plus the now-complete budget bound wbuf for every vector in fix(h2): maxControlFrames budget bypasses (PING ACK, WINDOW_UPDATE, self-dep PRIORITY, closed-stream DATA, per-request reset, SETTINGS amplification) #234.

Validation

  • NIM_COMPRESS=1 bash tests/run.sh (the CI test job's command, orc, gzip+brotli+zstd on) on the final branch: 92 suites, 747 checks, 0 failures, including the 17 budget, 6 backpressure, 28 request-body, 6 connection-table, 7 trailer and 25 HTTP/3 cases.
  • ThreadSanitizer: test_thread_race and test_blocking_race clean on the segmented table; test_conn_table joins the TSan job in this PR.
  • Adversarial review (three reviewers, then fixes): every new suite run 3+ times under orc, arc and ASan+UBSan, several under 28-40 CPU burners (load average up to 51), zero flakes. Each production guard was reverted one at a time and failed exactly the tests that claim it.
  • CI: all 35 checks green on the first run (orc, arc, ASan+UBSan, TSan including the new connection-table suite, helgrind, valgrind, chronos, deflate, the compression variants, h1spec/h2spec/h3spec, h3websocket, interop, the three proxies, fuzz, redbot, zap, testssl, h2load, h3load).
  • Stress smoke: one complete pass of VORTEX_PROTO=all VORTEX_SERVER=all VORTEX_SECONDS=10 VORTEX_REPORT_SECONDS=2 nimble stress (5 workloads x h1/h2/h3 x sync/async/async-await/chronos/chronos-await, 75 cells, canary plus chaos sidecar, 64 MiB streams): 75/75 passed, every checksum verified, no panic, assertion, crash or checksum-mismatch signature in any server log. The h2 SSE cells (374k events per cell with per-frame client acks) and the h2 streaming upload and download cells cover the credit-floor, early-reset and sweep-arm paths under load.

…t end to end

The control-frame budget audit of #234 was mostly applied in #242 and
reworked into the credit/decay model in #335, but the issue stayed open
and nothing in tests/ exercised it. Re-reading every vector against the
current codec found three charges still missing, all of the same shape:
an overhead frame that reaches its reply or its early return before
noteControlFrame ever runs.

A SETTINGS ACK returned unbudgeted, exactly as a PING ACK once did. We
emit our SETTINGS once, so at most one ACK is ever solicited and the rest
are pure overhead, so they are now charged like the PING ACK they mirror.

A zero-increment WINDOW_UPDATE naming a stream id we have seen answered
with a 13-byte RST_STREAM and returned before the charge. On an open
stream that is self-limiting (the reply tears the stream down), but on an
already-closed id it tears nothing down, so the peer could repeat it for
the life of the connection, one RST per frame, against a receive buffer
it never drains. It is charged before the reply, and the reply is skipped
once the charge has closed the connection.

A stream-level WINDOW_UPDATE was never charged at all. That left the
cheapest flood of the whole set: 13 bytes a frame against a stream the
server owes no bytes on (a request whose announced body never arrives, or
a producer that has not written yet), where the scheduler pass the update
forces can emit nothing. Such an update now goes through the same
credit-then-charge path as the connection-level and closed-stream ones,
so a client returning flow-control credit for response DATA it consumed
still rides the credit those bytes earned (#335) while a peer we send
nothing to trips the budget. An update that genuinely unblocks a send is
untouched, and the stream is enqueued either way so a later connection
WINDOW_UPDATE still finds it ready.

tests/test_http2_budget.nim is the regression suite the issue never had.
With maxControlFrames lowered to 20 it floods, one vector per test, PING
ACKs, SETTINGS ACKs, received GOAWAYs, unknown frame types, stream-level
WINDOW_UPDATEs with increment 1, zero-increment WINDOW_UPDATEs on a
closed stream, self-dependent PRIORITY on a used stream and DATA on a
closed stream, and asserts GOAWAY(ENHANCE_YOUR_CALM) plus a bounded
RST_STREAM count where the frame draws a reply. A self-dependent PRIORITY
on an idle stream id asserts the RFC 9113 5.1 rule the issue also
flagged: GOAWAY(PROTOCOL_ERROR) and no RST_STREAM for an id that was
never opened. One SETTINGS frame packed with 40 INITIAL_WINDOW_SIZE
entries covers the per-entry charge, and an interleave of one GET per
burst of ACKs covers the counter that used to be zeroed by every accepted
request. Two further tests hold the converse, that a few control frames
or window updates per request are answered normally, because over-
charging is the failure mode #335 had to repair.

The partial-write-safe sendAll the teardown suite kept to itself moves to
tests/h2client.nim, where the new suite uses it too.

Fixes #234
… streaming request bodies

PR #242 gave a streaming (onBody) route the bodyReceived tally the
buffered path gets from st.body.len, and reconciled it against the
declared content-length at END_STREAM on DATA and at a trailer section.
Auditing those two sites against the issue's failure scenario leaves two
holes.

First, END_STREAM on the request HEADERS themselves. No DATA frame and
no trailer section ever arrives, so neither of the two checks runs, and
the dispatch tail's check sits in the branch after the streaming one
(reqStreaming wins the elif chain), so it never runs either. A
`content-length: 10` with END_STREAM on the HEADERS therefore dispatched
the handler, and h2SetOnBody flushed it last=true immediately for the
already-half-closed stream: a clean, complete, empty body for a request
that declared ten bytes. finishHeaders now reconciles before dispatching
a streaming route, so the stream is reset before the handler exists.

Second, the point of the check. Reconciling only at the end of the
message is enough for a buffered route, which never sees a partial body,
but a streaming route is given each chunk as it arrives and that is
exactly what the issue's scenario forwards upstream. With
`content-length: 10` and a 1000-byte DATA frame the sink received all
1000 bytes; a route relaying them to an h1 backend under the declared
Content-Length had already desynchronized that connection by the time
the terminating frame revealed the mismatch, and if the client simply
never sent a terminating frame there was no reset at all. handleData now
fails the stream as soon as bodyReceived passes contentLength, before the
bytes reach the sink. RFC 9113 8.1.1 constrains the whole message and 8.1
lets us reset as soon as we know, so the earlier rejection is the
conformant one as well as the safe one. teardownStream returns the
frame's connDeferred bytes, so the connection window is not leaked on
this path (#231).

The failure is protocol-agnostic, so the h3 backend takes the same early
check in cbBody (its end-of-stream reconciliation landed with #257, and
cbStreamEnd already covers the empty-body case because nghttp3 reports
end_stream for a HEADERS-with-fin request). The reset there is
H3_MESSAGE_ERROR and cbStreamClose's creditRemainder returns the
uncredited bytes, matching the existing oversize paths.

Buffered routes are deliberately left alone: they are dispatched only
once the body is complete, so there is no window in which a partial body
escapes, and maxBodySize already bounds the excess.

Tests: tests/test_http2_request_body.nim drives a frame-level client
against a live server with a streaming route and a buffered control
route. It covers DATA past the declared length with and without
END_STREAM, a short body, a mismatch revealed by the trailer section,
END_STREAM on the HEADERS, `content-length: 0` followed by DATA, the
matching cases that must still succeed (including a body ended by a
trailer section, whose trailers reach the sink), and that a rejected
stream still terminates the sink so a handler suspended in await
req.read() cannot leak (#232). Each rejection asserts both
RST_STREAM(PROTOCOL_ERROR) and the absence of a response: the handler
answers from its EOF callback, so a response on the wire is the proof
that the sink was told "complete" while the stream was still live. All
four reconciliation sites were verified live by disabling each in turn.

Fixes #237
…in the whole trailer rule set

PR #242 routed decoded trailers through fieldrules.validTrailerField, so
an h2 or h3 trailer section already takes the same byte rules as the
request head: a lowercase-token name, no CR/LF/NUL and no edge
whitespace in the value, no pseudo-header, and none of the
connection-specific fields. Auditing that set against RFC 9110 6.5.1
leaves one field through: content-length.

validTrailerField builds on isForbiddenResponseField, which excludes
content-length deliberately and says so, because on the response side h1
generates Content-Length itself and the h1 codec forbids it separately
as a framing concern. A trailer section is not the response side. There
content-length is the framing field 6.5.1 names first, and it is the one
the h1 parser already drops (tests/test_trailers.nim covers that), so
h2/h3 were the lenient pair: a handler that logged or relayed
req.trailers could emit a second Content-Length for the same message,
and a client could pick which of the two a relay believed.
validTrailerField now rejects it by exact name (the name is known
lowercase by then, so no allocation), which resets the stream on h2 and
h3 alike.

The wider 6.5.1 superset the h1 parser drops (host, cache-control,
authorization, set-cookie and the rest) is deliberately left out: those
are routing, auth and response-control fields, not framing, h1 only
drops them rather than failing the message, and resetting a stream over
one would be stricter than any widely deployed server. Rejecting is the
right verb for h2/h3 only where a mismatch is a smuggling primitive.

Tests: the #238 suite in tests/test_http2_request_body.nim drives a
trailing HEADERS block from a frame-level client, over a buffered and a
streaming route. It pins every rule, not just the new one: CR/LF, a bare
CR, a bare LF, NUL, leading SP and trailing HTAB values; uppercase,
non-token (SP, separator, control byte) and empty names;
:status/:method; connection, proxy-connection, keep-alive,
transfer-encoding, upgrade and te (forbidden in a trailer section even
with the "trailers" value that is legal in the head); content-length;
and that one bad field poisons the whole block in either order, so the
handler never runs and no leading valid field surfaces through
req.trailers. The positive cases assert the trailer really is delivered,
and one test pins the maxHeaderSize bound on the trailer block
(COMPRESSION_ERROR over the decoded field-list cap). Proved live by
replacing the validTrailerField call with the pre-#242 pseudo-header-only
check: 10 of the 16 trailer tests fail, including the issue's
`a\r\nSet-Cookie: pwn=1`.

Fixes #238
sseSanitize dropped CR and LF, the two characters that could inject a
second field or end the event early, and nothing else. NUL is the third
character the wire format cannot carry, and it is the one with a
behavioural consequence instead of a structural one: the WHATWG
EventSource rules say that if an `id` field value contains U+0000 the
client ignores the field rather than rejecting the byte, so the client's
last event ID buffer keeps whatever the previous event put there. The
server believed it had set a resume point, the client resumed from an
older one, and the next reconnect's Last-Event-ID replayed or skipped
events with no diagnostic on either side.

The same sanitizer feeds `event` and a comment's text, so NUL is now
stripped from all three.

The second half of the same gap is an id that survives the `id.len > 0`
guard and then sanitizes to nothing: `send("x", id = "\r\n")` emitted
`id: ` with an empty value. That is not "no id" on the client, it is the
field that resets Last-Event-ID to empty, so an id made only of
characters the wire format drops silently cleared the resume point. The
id field is now emitted only when the sanitized value is non-empty, and
an `event` name that sanitizes away is dropped the same way (an empty
event-type buffer dispatches as the default "message", so the field
carried nothing).

A caller cannot therefore ask for an explicit reset, because `id = ""`
is already the "no id field" default and nothing in the API sends a bare
`id:`. That is left as is rather than grown a flag for: a reset is a
rare, client-side concern, and an API where the empty string means two
opposite things would be worse than not expressing one of them. The
docstring, the README SSE section and the changelog all say so.

Tests: tests/test_sse_streaming.nim gains a /ids route checked against
the exact wire bytes: a normal id still emits `id: abc`, "x\0y" emits
`id: xy`, "\r\n" and "\0" emit no id line, a NUL in an event name is
stripped, a comment is sanitized too, and the whole dechunked stream is
asserted to contain no NUL byte. Verified the case fails against the
unfixed sources (NUL went out verbatim, `event: ` and `id: ` were
emitted empty).

Fixes #265
handleAccept could not grow the connection table while any slot was
pinned. The table was one flat seq[Connection] per loop thread, so
growing it reallocated the payload and moved every Connection, and a
running `blocking:` worker holds `addr core.conns[fd]` for the whole of
its handler: growing under a pin would have left that pointer aimed at
freed memory. The accept path therefore scanned for a pinned slot and, on
finding one, accepted the connection and closed it again. #349 gave that
drop a counter and a rate-limited log line, which made it visible but
left it a drop: the client still saw an empty connect error,
indistinguishable from a network fault or the loop stalls d2ee202 fixed,
and it is the client that pays for an implementation detail of the
server's memory layout.

The table is now segmented. A ConnTable holds blocks of 1024 Connections
(712 B each, so ~712 KiB a block); a block is sized once at creation and
is never resized, moved or freed for the loop's lifetime, and only the
outer block list grows. A Nim seq is a (length, payload pointer) header
whose elements live in a separately allocated payload, so growing a
seq[seq[Connection]] reallocates just the outer buffer: the inner headers
are byte-copied to a new address and every payload pointer they carry
survives. A `ptr Connection` into a block therefore stays valid across
any number of later growths, which is exactly what the `blocking:` pool,
the file-chunk workers and the await paths need. The reasoning is spelled
out on the type, and the structural test asserts it directly rather than
leaving it to the reader.

So handleAccept grows unconditionally, and the pinned scan, the refusal,
pinnedGrowDrops and pinnedGrowLogSec are gone. No new ceiling replaces
them: the index is a file descriptor, so the fd rlimit already bounds the
table, the accept path still backs off on EMFILE/ENFILE, and
maxConnections is still checked first and is unchanged in meaning. The
initial allocation is one block, i.e. the 1024 slots the flat seq
preallocated, so startup cost and the no-growth common case are as
before. Growth now adds a whole block rather than `setLen(fd + 64)`,
which is coarser but allocates once per 1024 fds instead of once per 64.

The slot table is reached only through initConnTable / len / at / grow /
slots now, so no caller can take a pointer into a buffer that is about to
be resized, and `=copy` on a ConnTable is a compile-time error: copying
one would duplicate the payloads and leave every pointer a worker holds
aimed at the original. The debug audit in checkBodyPause and the drain,
timeout and shutdown scans all moved to the `slots` iterator; the
fd-indexed semantics are untouched everywhere else.

Tests: tests/test_conn_table.nim, built with -d:vortexConnBlock=8 from
its sibling tests/test_conn_table.nims so the growth path is reachable
with a couple of dozen connections instead of an fd rlimit above 1024
(the define is internal; nothing but this suite shrinks it, and Nim
applies a file-specific config script on top of tests/config.nims, so the
suite still gets orc/threads/ssl and the NIM_MM, NIM_SANITIZE and
NIM_COMPRESS knobs). The structural half asserts the invariant: a slot's
address and its GC'd field survive growth to 104 and then 1000 slots, the
index split addresses every slot distinctly, and `slots` yields each one
in fd order. The live half pins a connection on a worker that sleeps 5 s,
opens 24 more while it is held, and requires all 24 to answer 200 inside
that window, then requires the pinned request to complete on its own
connection. Verified the live case fails with the refusal reinstated (3
of 24 served) and passes clean under NIM_SANITIZE=1 (ASan + UBSan), which
is what would catch a dangling slot pointer.

.gitignore gains `!tests/t*.nims` beside the existing `!tests/t*.nim`:
`tests/t*` exists to keep compiled suite binaries out, and it was
swallowing the per-suite config script, which is source.

Fixes #343
PR #242 added the cap but cited its issues as a range, so #235 stayed open
and nothing in tests/ exercised it. This adds
tests/test_http2_backpressure.nim, a frame-level client that tracks the
receive credit the server grants and never overruns it, so a reset in these
tests is always the memory cap talking and never a FLOW_CONTROL_ERROR.

An audit of every claim in the issue against the current code found no
residual behaviour gap. A buffered body's flow-control bytes are still
credited eagerly on both windows, which is required rather than incidental:
maxBodySize may exceed h2ConnWindow, so deferring the connection credit to
dispatch (the issue's first suggestion) would deadlock every upload larger
than the connection window. The aggregate instead bounds un-dispatched
buffered bytes at max(connRecvWindow, maxBody), so it can never refuse an
upload the configuration allows, and the stream that crosses it is reset
with REFUSED_STREAM, which RFC 9113 8.7 defines as retryable: the body was
never dispatched, so that guarantee holds. ENHANCE_YOUR_CALM would be a
connection-level verdict on what is one stream's fault. The reservation is
released on dispatch, on the trailers path, and in teardownStream, which
every abnormal end funnels through (peer RST_STREAM, stream error, an early
response, connection close).

Three cases. 32 POST streams trickled round-robin past the cap, each refused
stream immediately re-opened so the connection keeps 32 bodies in flight for
the whole run, assert the pinned total never exceeds the cap plus one frame.
A cancelled stream's reservation comes back, so a full-size upload that
would not otherwise fit still succeeds. And a single upload of exactly
maxBodySize completes across a connection window half that size, which only
works while the credit stays eager.

Both guards were verified live by removing them: with the aggregate check
gone one connection pinned 3 MiB, six times the cap, and with
teardownStream's release gone the upload after the cancel was answered with
REFUSED_STREAM.

h2CheckCounters, the debug-only audit the loop already runs on every input
event, now re-derives the aggregate from a full stream scan as well, so a
teardown path added later that forgets to release a reservation fails the
test suite instead of silently and permanently shrinking what the connection
will accept.

Fixes #235
PR #242 closed the main hole in #236: "a stream owes response bytes it cannot
send because a peer send window is shut" now arms the body deadline, which is
the only deadline that fits. The client owes nothing, so no read-side timeout
applies, and the owed bytes sit in pendingBody rather than the write buffer,
so writeTimeout does not apply either (it arms only while the socket itself is
unwritable). An audit of the rest of the issue's checklist found the predicate
complete: h2BlockedOnPeerWindow covers a stream blocked on its own window and
a connection whose aggregate window is shut with a backlog behind it, which
between them cover a buffered response, a streamed res.write producer and an
h2 WebSocket stream (a ws stream never sees END_STREAM, so h2AwaitingClient
arms it anyway), sweepTimeouts acts on the expiry, and the resulting close
goes out as GOAWAY(NO_ERROR) so the peer can tell a server-side timeout from a
network fault (#342).

What was still missing is where the classification runs. h2Deadline has one
call site, the deadline tail of h2Input, so it only ever runs on an inbound
event. A streamed sendFile parks its bytes from somewhere else entirely: a
chunk read holds a file-chunk pin, which deliberately does not pause input, and
releasePin re-processes input only when bytes are already buffered, which a
silent client never has, while resumeAfterRespond runs only for the final
chunk. So the first chunk of a download to a client that advertised a small
initial window, absorbed it and then stopped sending WINDOW_UPDATEs landed in
pendingBody on a connection that had been left with deadline 0 by the
request's own input pass, and nothing ever looked again: the fd, the
connection slot and the parked chunks were pinned for the life of the process.
A static-file route is the most likely target for exactly that read pattern.

sweepTimeouts now arms the deadline itself when it finds an h2 connection with
nothing timed and bytes blocked on a peer window. It is the same predicate
h2Deadline uses, so it can arm nothing an inbound frame would not have armed,
it just no longer needs one; the cost is one O(1) counter read per connection
per second. The gate is inputPausePins rather than totalPins, matching
h2Input's own gate: a file-chunk worker never touches the h2 stream table, so
reading its counters here cannot race one, and gating on totalPins instead
would have skipped precisely the sendFile case this fixes.

Deliberately unchanged: the deadline stays idle-based, so a peer that returns
a token of credit every few seconds keeps the connection alive at a trickle.
That is the same contract bodyTimeout and writeTimeout already document (both
re-arm on progress, because a total-duration cap kills slow links), and the
memory is bounded either way; maxConnections bounds the slots.

Tests: tests/test_http2_backpressure.nim gains three cases. Three GETs for
large buffered responses with a 16 KiB initial window, read down to the window
and then silent, must draw GOAWAY(NO_ERROR) and a FIN inside bodyTimeout; the
same against a streamed sendFile; and a client that keeps granting credit
every 400 ms must finish a download that takes several times bodyTimeout with
no GOAWAY, so the arm cannot false-positive on a slow but honest reader. The
sendFile case fails on the unfixed sources (no GOAWAY, no close), and with
h2BlockedOnPeerWindow stubbed to false both stall cases fail, which pins the
coverage to this guard and not to another timeout.

Fixes #236
…hten the docs

Review follow-ups to the #343 and #265 commits.

The segmented table made growth safe for a worker's ptr Connection, but
it also removed a backstop nobody had written down: the old flat table
could not move while any slot was pinned, so an off-thread read of it was
merely stale. Now the outer block list reallocates whenever the loop
grows the table, so an off-thread len or index would race a free. Every
off-thread entry point already checks the thread or reads a snapshot;
conn() is the one chokepoint they all reach, so it asserts the rule in
debug builds and the ConnTable docstring says why the rule is stricter
than "never copy". The -d:vortexConnBlock knob gets a compile-time floor
of 1 instead of silently degenerating to one slot per block.

The SSE wording claimed the wire format cannot carry NUL at all; data is
not stripped and a client appends a NUL in data to the payload. The
docstrings and README now scope the stripping to id, event and comments,
and say that an id therefore does not round-trip byte for byte. The #236
changelog entry no longer promises GOAWAY unconditionally: a deadline
that lands while a file-chunk read is in flight defers the close, and the
deferred close is a bare FIN. The maxConnections row states the
connection table's worst-case footprint, which the fd rlimit bounds
rather than the cap.

Refs #343, #265, #236
… TSan

Review follow-ups to the test side of the branch.

test_conn_table waited a fixed 300 ms for the blocking: handler to reach
its worker before growing the table, so on a slow enough host the growth
could land before the pin and the case would pass against the old
refusing code too. The worker now bumps an atomic as soon as it holds the
pin and the test polls for it, so the overlap is asserted rather than
assumed. The elapsed budget keeps the whole pin window and says why, so
nobody tightens an overlap proof into a performance bound. The suite
joins the ThreadSanitizer job: it is the only one that grows the table
while another thread holds a slot, which is the cross-thread half of the
#343 claim, and it runs in seconds.

test_http2_teardown lost its only posix user when sendAll moved to the
shared client, so the import goes. The buffered-body cap is documented as
never below the 64 KiB protocol default receive window, which is what the
code computes (connRecvWindow is clamped up at construction), and the SSE
suite's row records the #265 coverage like every other issue-numbered
case in the table.

Refs #343, #235, #265
The helper passed flags 0, which is only SIGPIPE-safe on Linux because
the server under test ignores SIGPIPE process-wide before the first send.
That dependency was undocumented and would bite a suite that sends before
starting a server. std/net passes MSG_NOSIGNAL on Linux for this reason;
so does sendAll now, with the macOS side unchanged (SO_NOSIGPIPE).
…A frame

An adversarial review of the #234 round found the credit gate wrong in both
directions, and the two defects are the same mistake seen from either side: it
decided what a WINDOW_UPDATE costs from the bytes we had sent, when what the
peer's reply rate actually follows is the frames we chose to send them in.

The issue's headline vector was still free. The new gate charges a stream-level
WINDOW_UPDATE only when the stream is not sendable, and an increment of 1 always
makes a stream with a backlog sendable, so after SETTINGS_INITIAL_WINDOW_SIZE=0
a flood of WINDOW_UPDATE(sid, 1) against a parked response still bought one
1-byte DATA frame and one full scheduler pass per 13-byte frame, forever. Each
forced frame even banked credit on the way out. An update that dribbles is now
charged straight to the control-frame budget: an increment below
windowCreditBytes that leaves the window it credits below windowCreditBytes
while bytes are waiting on that window, at the connection level as well as the
stream level. A correct client never asks for more data in pieces that small
while holding the window under 256 bytes; it has either a real window to offer
or none at all. That is the CVE-2019-9511 shape. The charge goes to the budget
and not to the credit pool precisely because the 1-byte frames the dribble
forces would otherwise earn the credit that pays for it, and it runs before the
scheduler pass, so the frame that trips the budget emits nothing after the
GOAWAY.

In the other direction a correct client was being torn down. Credit was earned
per 256 bytes of DATA but spent per WINDOW_UPDATE received, and the server picks
the frame size: an SSE handler writing 1500 16-byte events to a client that
returns one stream-level and one connection-level update per frame it consumed
ran the pool dry and tripped GOAWAY(ENHANCE_YOUR_CALM) after about 1070 events
at the default budget. Those 256-byte chunks acked on both levels survived only
by the secondary decay. Every DATA frame now earns a floor of two credits, one
per level, which is the finest acknowledgement granularity a correct client can
have, on top of the existing one per 256 bytes and still under the same cap.
noteDataProgress takes the frame count for it, since h2WriteDirect emits a whole
run of frames in one call and reported only their total size.

Two smaller review notes ride along. The comment at the stream-level h2Enqueue
claimed the stream was queued regardless, which h2Enqueue contradicts: it gates
on h2Sendable, and what makes a stream blocked only on the connection window
findable by a later connection WINDOW_UPDATE is that h2Sendable ignores that
window. The code is right, the comment now says why. And DATA on a closed stream
charged the budget and then reset the stream unconditionally, so the frame that
tripped ENHANCE_YOUR_CALM emitted a RST_STREAM after the GOAWAY; it now takes
the csClosing guard the other charged replies in that commit took, which lets
the suite's RST_STREAM bound drop from budget + 1 to budget.

tests/test_http2_budget.nim grows the four cases: the stream-level and
connection-level dribble floods (bounded DATA frames and ENHANCE_YOUR_CALM), the
small-frame producer acked per frame on both levels, and a 200 KiB download
acked in 4096-byte increments at the low budget (the Go net/http2
inflowMinRefresh shape, where every update unblocks a send and none may be
charged). Each was confirmed to fail with its fix reverted.

Refs #234
…ply too

The maxControlFrames row, the threat-model floods row and the
noteControlFrame docstring all read as though every RST_STREAM sent in
answer to a flood cost a second unit. The inbound frame is charged once,
before the reply; only the CONTINUATION refusal charges its own reply.
A maintainer sizing the budget from those rows would have double-counted.
The budget suite's header also counted the audited vectors as eight
while listing ten.
…s content-length

The h3 backend queues a streaming route for dispatch from cbEndHeaders, by
pushing the stream onto the ready list, and the event loop runs the handler only
after the whole engine pump: ngReceive parses everything the read batch
contains, then ngTakeReady is drained. Both content-length reconciliation sites
reset the stream but deliberately leave it in h3c.streams, so cbStreamClose can
still return its flow-control credit, and neither removes the entry the ready
list already carries. For a request whose head, body and FIN are parsed in one
batch the reset therefore happens while the handler does not exist yet, and the
handler runs afterwards anyway, with every side effect (an upstream request, a
database write) the reset was meant to prevent. h2 has no such window:
finishHeaders reconciles and returns before it adds the stream to its ready
list.

The ready-list consumer now resolves the connection first and skips the stream
when it is gone from the table or flagged rejected, mirroring the h3StreamAlive
check the post-accept WebSocket pump a few lines below already makes. The flag
is set by the two reset-but-keep paths (cbBody's early over-length check and
cbStreamEnd's end-of-message reconciliation), and the alive check also covers
the third resetter that drops the stream outright, the oversize pre-accept
WebSocket handshake in cbBody, which had the same queued entry behind it.
Skipping costs the stream nothing it needs: cbStreamClose still releases the
buffered-body reservation, still fires onBodyCb(last = true) so a suspended
handler cannot leak, and creditRemainder still returns the uncredited bytes to
the connection window. The ready list is taken and cleared wholesale each pass,
so a skipped entry is dropped rather than left to leak.

A rejected stream also stops collecting body bytes. The append used to run
before the over-length check and the early return skipped deliverBody, so a
stream whose handler had not run yet (the case above, where there is no sink to
drain st.body) kept buffering every further DATA frame until cbStreamClose. The
reject path clears st.body, and further DATA on a rejected stream is counted as
uncredited, so the credit is still returned at close, and discarded.

How reachable the window is depends on the client. curl sends the request head
in its own packet, so on loopback the handler is dispatched from the batch that
carries the HEADERS, before the body it will disagree with has arrived, and
nghttp3 fails an over-length DATA frame itself before cbBody sees it. Out-of-
order delivery is what closes the gap: a DATA frame that arrives before the
HEADERS is buffered by ngtcp2 and flushed into nghttp3 together with them, which
puts the whole request, reconciliation included, in the single read batch that
precedes the dispatch. Verified with a UDP relay that holds one 1-RTT packet
back: cbHeaders and cbBody then run in one batch with no dispatch in between.

Tests: tests/test_http3.nim pins the wire-visible property for the three
mismatched shapes (10 bytes under content-length 5, 10 under 64, and no body at
all under 5): none of them is ever answered, and the server still serves the
next request. They deliberately do not assert on the dispatch counter, because
for a client that cannot reorder packets the dispatch is legitimate at that
point and asserting otherwise would pin curl's packetization rather than the
server's behaviour. The positive case (content-length 10 with exactly 10 bytes)
asserts the guard costs the normal path neither its single dispatch nor its
clean EOF.

Refs #237
…very protocol

PR #242 and the commit before this one closed the inbound half of the trailer
rules: a request trailer section takes the request head's byte rules plus the
forbidden set, content-length included. The outbound half had three
implementations and they disagreed. h1's appendLastChunk filtered with
connSpecificField, which drops content-length but not te. h2's emitTrailers
routed the trailer fields through encodeExtraHeader, the response-header filter,
which is called with trailer = false and so emitted BOTH content-length and te
in the trailing HEADERS. h3's h3StreamFinish passed trailer = true, so it
dropped te but still submitted content-length, and it checked the value bytes
without checking that the name was a token at all.

A handler that relays an upstream's trailers verbatim is the realistic way in:
res.trailers["Content-Length"] = "999" after a streamed body put a second,
later Content-Length on the wire for a message the client had already framed,
which is the same smuggling primitive as the inbound one with the direction
reversed, and an intermediary that believes the trailer disagrees with the one
that believes the head. RFC 9110 6.5.1 forbids generating Content-Length in a
trailer section for exactly that reason, and RFC 9113 8.2.2 / RFC 9114 4.2
forbid te on a response at all.

fieldrules gains one predicate, forbiddenResponseTrailerField, with the
rationale for why content-length belongs in it here and deliberately not in
isForbiddenResponseField. All three writers now use it, so the set cannot drift
again, and validTrailerField (the inbound rule) is expressed in terms of it
too, since it had grown its own copy of the same two additions. h1 keeps every
field it dropped before, since the new set is a superset of connSpecificField,
and keeps its h1 field-name case. h2 trailers now also drop a pseudo-header or
non-token name and a CR/LF/NUL or edge-whitespace value, matching what h3 had
already done for values (#257): HPACK carries those bytes without complaint, so
the splitting happens in a relay re-serializing the section to h1. Both h2 and
h3 lowercase the name, as HPACK and QPACK require.

Tests: tests/test_trailers.nim grows a streamed route whose trailer section
carries Content-Length, TE, Transfer-Encoding and X-Checksum, and asserts that
only x-checksum arrives, over h1 (read out of the raw chunked trailer section)
and over h2 (the trailing HEADERS block decoded field by field, so the
assertion is the exact field list). tests/test_http3.nim takes the same route
over curl's h3. Proved live by reverting each writer in turn: the h2 case fails
on the leaked content-length and te, the h1 case on te. The h3 case does not
distinguish, because curl's own h3 stack drops a content-length trailer before
it reaches the dump; what it pins there is that the stricter shared predicate
still delivers the legitimate trailer.

Refs #238
…dowing sendAll copies

test_http2_request_body spent eight of its twelve seconds sleeping out a
300 ms quiet period per connection to discard the server's SETTINGS. It
now reads until that frame arrives, which is the deterministic form the
rest of the suite already uses, and the suite finishes in two seconds.
It also closes its server before exit like every other h2 suite, so
process teardown does not race a live loop thread.

Both new h2 suites re-declared a private sendAll identical to the one the
shared client exports, and the local symbol shadowed the import; the
copies go, and with them a posix import the request-body suite no longer
needs. The TESTING row stops claiming the sink-termination check runs for
every rejected stream (one case pins it), and the threat-model row for
request trailers says the h3 path shares the rule but is reasoned rather
than driven, since no client here can send a malformed h3 trailer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment