http: HTTP/2 connection core, streams, flow control and the SETTINGS handshake (step 3 of #72) #699
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Goal
Step 3 of #72 (HTTP/2): the connection core of RFC 9113, as a state machine of its own in
std/httpthat is fed octets, answers events and queues octets to send, with no socket, no task and no channel (they are step 3b, below) and no change to what aClientor aServerdoes. It reads and writes frames through the codec of step 2 (#685) and header blocks through HPACK (step 1, #563). Steps 4 and 5 (server, client) and 3b drive it.Rules
A new module
std/http/src/h2.trb(internal to the package, imported by relative path likeh2frame.trb, not exported fromlib.trb), in the form ofHpackDecoder(a type withvar fnmethods; the implementer followshpack.trb):H2Connectionwithclient(limits)andserver(limits).feed(var buffer: Bytes): Result<List<H2Event>, H2Error>takes what has arrived (the server first strips the 24-octet preface withisPreface; anything else is a connection errorPROTOCOL_ERROR, and a partial preface waits), decodes every complete frame and answers the events.drain(): Bytestakes the octets queued to send (a client's start is the preface plus its SETTINGS; a server's is its SETTINGS). Both ends must open with SETTINGS (section 3.4): any other first frame isPROTOCOL_ERROR.H2Event, one variant):Headers(streamId, fields, endStream, trailers)(the block is joined from HEADERS and CONTINUATION and decoded by the connection's one HPACK decoder, so the table stays in step even for a stream that is refused),Data(streamId, data, endStream),Reset(streamId, code),WindowOpened(streamId)(0 for the connection; a blocked sender may resume),SettingsApplied,Pong(opaque),GoAway(lastStreamId, code, debug),StreamRefused(streamId)(a stream above the peer's GOAWAYlastStreamId: nothing of it was processed, so it can be retried). Unknown frame types are ignored.PROTOCOL_ERROR) and have the right parity (client odd, server even; a server never opens one because push is off); DATA or HEADERS on a half-closed (remote) stream is a stream errorSTREAM_CLOSED, on a closed stream a stream errorSTREAM_CLOSEDtoo, except for a stream this end reset, whose frames are dropped (their DATA still counts against the connection window); DATA on an idle stream, RST_STREAM on an idle stream, WINDOW_UPDATE on an idle stream arePROTOCOL_ERRORof the connection; PRIORITY is accepted in every state and ignored; a second HEADERS on an open stream is the trailers and needsEND_STREAM(otherwise a stream errorPROTOCOL_ERROR).END_HEADERSonly a CONTINUATION of the same stream may follow, any other frame isPROTOCOL_ERROR; a CONTINUATION without an open block is too. The octets buffered for a block are limited tomaxHeaderListSize(a valid block never exceeds the weight of its list, whose every field countsname + value + 32); more isENHANCE_YOUR_CALMof the connection. A block that HPACK refuses (a compression error, which includes a list overmaxHeaderListSize) is a connection errorCOMPRESSION_ERROR, because the two tables can no longer be kept in step; a stream-level431for an oversized list is a later refinement. APUSH_PROMISEis a connection errorPROTOCOL_ERROR, as this end sentENABLE_PUSH0 (section 8.4).SettingsApplied), and only then does the decoder'smaxFrameSizechange. An ACK that was not asked for isPROTOCOL_ERROR. Applied from the peer:HEADER_TABLE_SIZEto the HPACK encoder,MAX_FRAME_SIZEto the size of the frames this end writes,MAX_CONCURRENT_STREAMStoopenStream,INITIAL_WINDOW_SIZEto the send window of every open stream by the difference (may go negative, section 6.9.2; above 2^31-1 isFLOW_CONTROL_ERRORof the connection). An unknown identifier is ignored.sendData(streamId, data, endStream)writes DATA frames up to the smaller of the two send windows and the peer's frame size, and answers how many octets it took (the caller keeps the rest and retries onWindowOpened);END_STREAMgoes on the frame that carries the last octet, an empty final DATA is allowed at a zero window. A DATA frame that overruns the receive window isFLOW_CONTROL_ERROR(connection for the connection window, stream error for a stream window). The receive side hands credit back only when the application says it consumed the data:consumed(streamId, count)queues a WINDOW_UPDATE once the unreturned credit reaches half of the window (and at once for the connection window's share, so no stream's slow reader stalls the others). A WINDOW_UPDATE that takes a send window above 2^31-1 isFLOW_CONTROL_ERROR(stream error on a stream, connection error on stream 0).openStream(fields, endStream): Result<Int, H2Error>encodes the HEADERS throughsplitHeaderBlock; a server answers withsendHeaders(streamId, fields, endStream)): refused with anH2Errorthat is not a protocol error when the peer'sMAX_CONCURRENT_STREAMSis reached or a GOAWAY has been received (the waiting is 3b's).reset(streamId, code)sends RST_STREAM and closes the stream;release(streamId)is the application saying it has finished with the stream.maxConcurrentStreamsstreams the peer opened and the application has not released; a HEADERS beyond is a stream errorREFUSED_STREAM. At most 10000 queued control frames (SETTINGS ACK, PING ACK, RST_STREAM) the caller has not drained; more isENHANCE_YOUR_CALM.Pongevent;ping(opaque)sends one.goAway(code, debug)queues GOAWAY with the highest peer stream identifier processed and from then on refuses new streams of the peer (silently ignored above that identifier); a received GOAWAY endsopenStreamand reportsGoAway. A connection error found byfeedqueues the GOAWAY with its code itself and returnsH2Error; the connection then accepts no further input.content-length, connection-specific fields) are the translation toRequestandResponseof steps 4 and 5; the socket, the task, the channels and the timeouts (SETTINGS ACK, PING) are step 3b; graceful two-phase shutdown is step 4.Decided by the loop
--native, with both ends in one program wired bydrainintofeed, and so the task of 3b (one per connection) owns it without a lock. The alternative, a task with channels at once, is the shape of 3b; splitting it off keeps the PR at the size of step 2 (the state rules and the windows are the part with the many tests).feed, and events carry fields, not blocks. (Applies to the encoder the same way:openStreamandsendHeadersencode under one encoder, in call order.)H2Limits, following Go'snet/http2, epic "Limits"):maxConcurrentStreams100, per-stream initial window 65535 (not announced, it is the default), connection receive window raised to 1 MiB by a WINDOW_UPDATE right after SETTINGS,maxFrameSize16384,maxHeaderListSize65536,headerTableSize4096,ENABLE_PUSH0 announced by a client; a server announces nothing it does not change from the default exceptMAX_CONCURRENT_STREAMSandMAX_HEADER_LIST_SIZE. A program cannot change them yet;H2Limitsis a record so a later issue can expose them.release, not until RST_STREAM arrives, as Go and nghttp2 did after the attack. A peer that opens and cancels streams quickly therefore runs intoREFUSED_STREAMinstead of creating unbounded work.trailers: true); dropping them is the layer above, as the epic decided.consumed), not automatic on receipt, because a stream'sBodyis pulled (NETWORK.md section 1): credit follows the reader, which is the backpressure HTTP/2 exists to give.Acceptance criteria
std/http/tests/h2.test.trb(VM and--native, as the otherstd/http/tests), all with a client and a serverH2Connectionin one program, their output fed into each other:SettingsAppliedarrives on both, and a server that gets a first frame that is not SETTINGS, or a partial preface, answers as above (PROTOCOL_ERRORwith GOAWAY, and need-more).MAX_FRAME_SIZE), DATA both ways, trailers,END_STREAMon each side; the stream walks idle, open, half-closed, closed and the events arrive in order with the right fields; the HPACK tables stay in step over ten requests with repeated fields.consumed;sendDataanswers the short count andWindowOpenedresumes it; a DATA frame beyond the receive window, a WINDOW_UPDATE past 2^31-1, and anINITIAL_WINDOW_SIZEchange that takes an open stream's window negative and back are each a test, with the level (stream or connection) and the code named.drain.resetand the peer's RST_STREAM: the event, the closed state, the frames dropped for a stream this end reset (their DATA still debits the connection window), and the slot held untilrelease(a loop of 1000 open-and-reset requests from the client getsREFUSED_STREAMonce 100 are unreleased).goAwayand a received GOAWAY (newopenStreamrefused,StreamRefusedfor a stream abovelastStreamId), and a connection error that queues its own GOAWAY and rejects later input.tools/gates.sh aandbgreen in forge CI. NETWORK.md section 12 gets "As built, step 3: the connection core" and slice 12's row and FIRST-PARTY.md gap 25 get the note (the gap stays open);skills/regenerated if the docs change reaches them.Design record / seed
Implements NETWORK.md slice 12 (HTTP/2), RFC 9113 sections 3.4, 5, 6.5, 6.9 and 6.10. Seed: no (std only, no syntax, no native, no string lookup by the compiler).
Size
One PR, two Conventional Commits:
feat(http): HTTP/2 connection core, streams and handshake, thenfeat(http): HTTP/2 flow control, limits and the rapid-reset guard(with the docs).Dependencies
#685 (frames, landed) and #563 (HPACK, landed). Part of #72. Step 3b (the connection task over a
Transportwith a channel per stream, the SETTINGS and PING timeouts, tested over loopback with--native) is filed when this one has landed; steps 4 to 6 follow after it.[loop:torbscript] decided by the loop: step 3 is the connection core as a sans-IO state machine (octets in, events and octets out), not the task with channels at once. Why: the stream-state rules, the flow-control windows and the limits are the part with the many tests, and a state machine needs no network to test (both ends in one program, VM and
--native); the Transport task and the channel per stream become step 3b, filed when this lands. It keeps the PR at the size of step 2 (two commits). HPACK is owned by the core because header blocks must be decoded in arrival order, also for refused streams. Window credit is explicit (consumed) so a pulledBodygives the backpressure. A peer's stream slot is held until the application releases it, which is the guard against rapid reset (CVE-2023-44487). Defaults follow Go'snet/http2as the epic says. Seed impact: none (std only).next: ready
[loop:torbscript] claim implementation by session:loop-torbscript-20261030 @anthropic:tkoehn until 2026-10-31T01:31Z
[loop:torbscript] release implementation by session:loop-torbscript-20261030 @anthropic:tkoehn: the worker run ended without a result (the worker runtime refused its oversized output log); no branch, commit or PR was pushed, so nothing is left to pick up. The item is still
ready; the next worker starts from main and keeps its command output small.next: implementation
[loop:torbscript] claim implementation by session:loop-torbscript-20261030 @anthropic:tkoehn until 2026-10-31T04:27Z
[loop:torbscript] release implementation by session:loop-torbscript-20261030 @anthropic:tkoehn: the worker run was cancelled without a result; no branch, no PR. Release of the claim.
next: implementation