Skip to content

HTTP/2 SETTINGS as a live negotiation; websocket Received step contract (#87, #86) - #100

Merged
proggeramlug merged 3 commits into
mainfrom
fix/http2-settings-and-websocket-step
Sep 22, 2026
Merged

proggeramlug merged 3 commits into
mainfrom
fix/http2-settings-and-websocket-step

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

#87: SETTINGS is a negotiation, not a constructor argument

All six items, in protocols/turnloop-http/src/http2.rs:

  1. Unacknowledged SETTINGS frames are an ordered queue (RFC 9113 §6.5), so a second outstanding frame no longer fails the connection. An ack with nothing queued is still PROTOCOL_ERROR.
  2. Event::SettingsAck(Settings) fires when the peer acknowledges. Acks come back in send order, so a host can timestamp each send and compute the round trip. The crate still reads no clock.
  3. Event::Settings(SettingsFrame<'a>) carries the peer's frame as sent. remote_settings() and local_settings() report what is in force on each side.
  4. Event::Goaway gains debug: Option<&[u8]>, which is None when the frame had no opaque data (Node's undefined).
  5. Connection::with_settings(role, limits, &Settings) sends exactly the host's initial frame, including an empty one, in ascending id order.
  6. Connection::settings(&Settings) changes settings on a live connection. A change that loosens a limit applies immediately; one that tightens waits for the ack. INITIAL_WINDOW_SIZE shifts every open stream's receive window. limits() reports what is currently enforced.

h2spec: 147/147.

#86: turnloop_websocket::Received

Measured behaviour differs from what the issue predicted. tungstenite buffers input, so consumed == 0 with a message is normal. A host that loops while consumed > 0 loses that message rather than spinning, and a host that reads the transport whenever its input is empty stalls. Looping while "a message came back" is correct for this type. All of this is documented on Received with a table and a doctest, and pinned by three tests. The Node interop test's own server loop had the read-when-empty stall and is fixed. receive is not idempotent: feeding the same input again delivers a complete message twice, now documented.

Breaking changes

  • Event::Settings is now a tuple variant, Event::Goaway has a third field, and Event::SettingsAck is new.
  • A SETTINGS ack is now reported as an event; the crate-root contract doc and shape test are updated to match.

Checks

Local: fmt, cargo test -p turnloop-http --all-features, cargo test -p turnloop-websocket --all-features, and h2spec 147/147 pass. The workspace clippy, doc and MSRV checks were cut short when the disk filled up; CI covers them.

Closes #87
Closes #86

`Connection` owned SETTINGS state that only the host knows the policy for, and
five of the six gaps Perry's node:http2 lane hit forced a host to interfere
with it.

Outstanding SETTINGS are now a queue. RFC 9113 section 6.5.3 acknowledges
frames in order, and a single `settings_awaiting_ack` flag turned the second
ack of two outstanding frames into "unsolicited SETTINGS ack" and failed the
connection - so `session.settings()` was unimplementable. An ack with nothing
queued is still PROTOCOL_ERROR.

Each ack is now `Event::SettingsAck(Settings)`, carrying the parameters it
acknowledged. A host that timestamps each send pairs them in FIFO order and
has its round trip; no clock is read here. The ack clears the host's SETTINGS
deadline, since that deadline was for the frame just acknowledged.

`Event::Settings` carries a `SettingsFrame` borrowed from the input: the
peer's parameters in wire order, duplicates and unknown identifiers included,
with `get` returning the last (in-force) value. `remote_settings()` and
`local_settings()` accumulate what each side has put in force.

`Event::Goaway` carries `debug: Option<&[u8]>`: `None` when the frame had no
opaque data, which Node reports as `undefined`. The wire cannot express
"present but empty", so it is never `Some(&[])`.

`Connection::with_settings(role, limits, &Settings)` sends exactly the
host's initial SETTINGS - `Settings::new()` gives the empty frame Node's
server sends - serialised in ascending identifier order, as Node does.
`Connection::new` is `with_settings` of `Settings::from_limits`, so a server's
bytes are unchanged and a client's differ only in ENABLE_PUSH moving to the
front. A client that names no ENABLE_PUSH still sends ENABLE_PUSH=0: push
defaults to on and PUSH_PROMISE is a connection error here.

`Connection::settings(&Settings)` sends a change to a live connection and
applies it. A loosening (larger MAX_FRAME_SIZE, INITIAL_WINDOW_SIZE,
MAX_HEADER_LIST_SIZE, MAX_CONCURRENT_STREAMS) takes effect at once, because
the peer may act on it before its ack reaches us; a tightening takes effect
on the ack, so nothing the peer sent under the old value is treated as an
error. INITIAL_WINDOW_SIZE moves every open stream's receive window by the
difference (section 6.9.2), and new streams start at the new size - the
per-stream receive window was a hard-coded 65535 before. `limits()` reports
what is enforced. This is a method rather than a `Limits` setter because the
wire-visible limits cannot change without telling the peer; values this
endpoint cannot honour (ENABLE_PUSH=1, HEADER_TABLE_SIZE above the decoder's
fixed 4096, out-of-range values) are refused before anything is sent.

Breaking: `Event::Settings` is a tuple variant, `Event::Goaway` has a third
field, and `Event::SettingsAck` is new; the SETTINGS ack is no longer a
no-event step, so the step-contract docs and the shape test say so.
`asynchronous::client`'s two GOAWAY patterns gain `..`.

Tests in tests/http2_settings.rs cover each item: three outstanding frames
acked in order, the ack event and deadline, the peer frame's contents, GOAWAY
debug data present and absent, empty and ordered initial frames, refused
values, and live frame-size, stream-limit and initial-window changes applied
before and after the ack. h2spec strict: 147 tests, 147 passed.

Closes #87
`Received` had the same undocumented independence of `consumed` and
`message` as turnloop-http's `Step`, but measuring it showed a different
shape from the one the issue predicted, so the contract is written from the
measurement.

`receive` hands input to tungstenite, which reads it into its own buffer
and parses from that buffer before reading again. So:

- `consumed == 0` with a message is normal: a second frame that arrived in
  the same read completes on the next call, from bytes already consumed.
  "consumed == 0 means wait for input" is therefore wrong, and a host that
  loops while `consumed > 0` drops that message outright - it never looks at
  the step. This is the HTTP/1 `Event::End` shape (#50), not HTTP/2's.
- `consumed > 0` without a message is a partial frame or non-final
  fragment, and all of the input has been taken in. Calling again returns
  the stop shape.
- A host that reads the transport whenever its input is empty stalls on the
  first case: nothing is left to hand in, yet a message is waiting.
- Looping while a message came back does *not* stall for this type, unlike
  `http2::Step` at the preface: a step with no message always took all of
  its input, so nothing is stranded. The test pins that, since it rests on
  how tungstenite reads.

The documented loop condition is the workspace one, `consumed > 0 ||
message.is_some()`, now on `Received` with a table and a doctest. Tests:
the measured shapes (23/Some, 0/Some, 0/None; 6/Some; 5/None, 0/None), the
loop-on-consumed host losing "two", the read-when-empty host finding a
message waiting after it would have blocked, and the loop-on-message host
seeing all three.

Idempotence, as the issue asked: `receive` is not idempotent, and the
failure is worse than HTTP/2's. Re-feeding undrained input does not fail
the connection; a complete message in it is silently delivered twice.
Documented and tested.

The Node interop test's own server loop read the socket whenever its input
was empty - the stall above - and now reads only when a step made no
progress. `asynchronous::WebSocketStream::receive` already calls `receive`
before every transport read, so it was correct.

Closes #86
@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 11 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: c1caddc7-0619-454e-af49-d708198c2a62

📥 Commits

Reviewing files that changed from the base of the PR and between 14bc89b and 65937fc.

📒 Files selected for processing (7)
  • protocols/turnloop-http/src/asynchronous/client.rs
  • protocols/turnloop-http/src/http2.rs
  • protocols/turnloop-http/src/lib.rs
  • protocols/turnloop-http/tests/codecs.rs
  • protocols/turnloop-http/tests/http2_settings.rs
  • protocols/turnloop-websocket/src/lib.rs
  • protocols/turnloop-websocket/tests/websocket.rs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug
proggeramlug merged commit d918664 into main Sep 22, 2026
39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant