Skip to content

HTTP/1 step and encoder gaps, and Content-Encoding lists and step reasons in compression - #104

Merged
proggeramlug merged 9 commits into
mainfrom
fix/http1-and-compression
Sep 22, 2026
Merged

proggeramlug merged 9 commits into
mainfrom
fix/http1-and-compression

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Six turnloop-http issues in http1 and compression, one commit each, plus one commit for a zstd bug that the #54 change made easier to reach. Every behaviour change has a test that fails on the old code.

#46: Event::Upgrade on the request side

Of the two fixes the issue offers, this takes the first. In Mode::Request, an HTTP/1.1 request with an Upgrade header and the upgrade token in Connection, or any CONNECT, now ends with Event::Upgrade in place of End. It comes after the request body, reads no input, and leaves the bytes that follow in the host's buffer. The caller still decides. The decoder stays reusable just as it would after End, so a server that declines can answer normally and reset. RFC 9110 7.8 says an Upgrade on HTTP/1.0 is ignored, so it is. Event::Upgrade and Mode now document both modes. The in-crate drivers follow this:

  • asynchronous::Http1::reusable and reset now account for a request-side upgrade.
  • server::http1 ends the request on Upgrade. If the service answers 101, or 2xx to a CONNECT, the connection is closed rather than read as HTTP/1.
  • turnloop-websocket's accept already accepted Upgrade | End.

Closes #46

#47: reason phrase, close-delimited body, body-forbidden responses

  • Encoder::start_with_reason(head, reason, body, out) writes a custom reason phrase. The phrase is checked against RFC 9112's grammar, so it cannot inject a header line, and it is refused on a request.
  • BodyLength::CloseDelimited is for responses only. It writes no framing header, and the body ends when the host closes the connection.
  • BodyLength::Omitted is for responses only. The head's own Content-Length or Transfer-Encoding goes out as given and no body follows: a HEAD response or a 304. A 204 or 1xx that advertises a length is still refused (RFC 9110 8.6).

Closes #47

#50: Event::End from a zero-byte step

  • New Decoder::wants_step(). It is true exactly while a zero-input End or Upgrade is pending, so while !input.is_empty() || decoder.wants_step() is a correct loop.
  • http1::Step's docs now include a runnable loop in the same style as http2::Step (A terminated HTTP/2 stream no longer takes the connection with it #85), plus a paragraph on the trap.
  • A new test runs the loop P6 and P11 wrote over Content-Length, chunked and 204 responses. That loop consumes every byte, never sees End, and leaves the decoder unreusable. The same loop keyed on wants_step completes all three.

Closes #50

#80: asking whether the decoder is mid-message

Two new read-only queries:

  • Decoder::is_mid_message(): a final head has been decoded and the end of the message has not been reached.
  • Decoder::eof_is_clean(): true exactly where eof() would succeed. eof() is now written in terms of it, so the two cannot drift.

Together they separate three cases: not in a message, truncated if EOF comes now, and inside a close-delimited body where EOF is the legal end.

Closes #80

#54: needs input vs output full

process now keeps running engine steps until the body is finished, the decoder needs input, or the output is full. DecodeStep gains needs_input, which together with finished tells the three apart. So there is one call per transport read and one per full buffer, with no final no-progress call. Existing "loop until nothing happens" hosts still work.

A follow-up commit fixes the native zstd engine. It lost its frame boundary on an empty step, so an end declared after the last byte failed a complete body. That is the normal HTTP/1 shape, and the new loop makes such an empty step on its own. It has its own regression test.

Closes #54

#79: Content-Encoding lists

  • StreamingDecoder::new accepts a list such as gzip, br.
  • New StreamingDecoder::from_codings(values, limit) takes every header line's value and merges repeated lines.
  • New Head::values(name) yields every line of a header.

Codings are decoded innermost-last: the last listed is decoded first. Names are case-insensitive, x-gzip is accepted, and identity and empty list elements are skipped. A chain longer than MAX_CODINGS (5, the same cap undici uses) is refused.

A single coding behaves as before. Each extra coding stages its output in a fixed 16 KiB buffer that is allocated at construction and kept across reset. The allocation test adds a gzip, br case and still measures zero allocations. limit now bounds every coding's output, not only the final one.

Closes #79

Breaking changes (pre-1.0)

  • http1::Mode::Request: upgrade and CONNECT requests now end with Event::Upgrade instead of Event::End. A request-side host that matched only End needs an Upgrade arm. server::http1 services see Upgrade for these requests too.
  • http1::BodyLength has two new variants, CloseDelimited and Omitted, so an exhaustive match needs two more arms.
  • compression::DecodeStep has a new public field, needs_input.
  • compression::StreamingDecoder::process may now do more work per call: it returns only at one of the three stop reasons.

Not changed here

client.rs and asynchronous/client.rs belong to a parallel change and are untouched. asynchronous::client::Decoders::start still reads only the first Content-Encoding line through head.get. With this PR, a list on that single line now decodes. Merging repeated lines there is a one-line change: from_codings(head.values("content-encoding"), ..), which that module's owner can make.

Checks

  • cargo +nightly-2026-08-20 fmt --all --check
  • cargo +nightly-2026-08-20 clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks
  • RUSTDOCFLAGS='-D warnings' cargo +nightly-2026-08-20 doc --locked --workspace --all-features --no-deps
  • cargo +nightly-2026-08-20 test --locked -p turnloop-http --all-features, plus --test codecs --test allocations with default features for the native zstd backend
  • cargo +nightly-2026-08-20 test --locked -p turnloop-websocket --all-features
  • python3 scripts/test-servers.py --services http run -- cargo +nightly-2026-08-20 test --locked -p turnloop-http --all-features --test interop --test asynchronous -- --include-ignored --test-threads=1 (30 passed, including the Node/curl fixtures and the CONNECT proxy)
  • cargo +1.97.1 check --locked --workspace --all-targets --all-features

`State::Upgrade` was reachable only in `Mode::Response`. A server decoding
`GET / HTTP/1.1` with `Connection: upgrade` saw an ordinary head and `End`, so
a host that matched on the public `Event::Upgrade` - the obvious thing to match
on - served every WebSocket handshake as a normal request. Perry's P5 lane
found the asymmetry only by reading the decoder.

Of the two fixes the issue offers, this takes the first: the enum now means one
thing in both modes. Documenting the asymmetry would have kept a public variant
that is unreachable in the mode most servers run in, and every server would
still have to re-derive the same RFC 9110 7.8 test from the headers.

In `Mode::Request` a request ends with `Event::Upgrade` in place of `End` when
it is HTTP/1.1 with an `Upgrade` header and the `upgrade` token in `Connection`,
or when it is a CONNECT - the request-side mirror of the 101 and CONNECT-2xx
cases the response side already had. It is raised after the request body, if
any, and like `End` it reads no input, so the next protocol's bytes stay in the
host's buffer. An `Upgrade` on an HTTP/1.0 request is ignored, as 7.8 requires.

The decision stays with the caller. Unlike the response side, the decoder does
not give up the connection: after a request-side `Upgrade` it is `reusable` on
the same terms as after `End`, so a server that declines answers normally and
calls `reset`. `Event::Upgrade` and `Mode` now say all of this.

The in-crate drivers follow. `asynchronous::Http1` counts a request-side
upgrade as the end of the message for `reusable`, and `reset` clears it.
`server::http1` treats `Upgrade` as the end of the request; a service that
declines keeps the connection, and one that answers 101 (or 2xx to CONNECT)
now closes it, because that driver cannot hand the transport off and would
otherwise parse the next protocol's bytes as a request. turnloop-websocket's
`accept` already accepted `Upgrade | End`.

Behaviour change: a request-mode host that matched only `End` must now also
handle `Upgrade` for upgrade and CONNECT requests.

Tests: `http1_request_side_raises_upgrade` (codecs) checks the event, its
zero consumption, the untouched trailing bytes, body-then-upgrade, CONNECT,
reuse after a decline, and the three non-upgrade shapes; before the fix every
upgrade case ended in `End`. `http1_server_upgrade_request_is_the_services_decision`
(asynchronous) drives `server::http1` over TCP: a declined upgrade serves the
next request on the same connection, and an accepted one closes without
parsing the bytes that follow - on the old code the service saw `End` and the
driver failed parsing "not HTTP at all" as a request. The interop CONNECT
proxy fixture now stops on `Upgrade` as well as `End`.

Closes #46
Three response shapes Node puts on the wire could not go through the encoder,
and Perry's P5 lane wrote the head by hand for each:

- `Encoder::start` always wrote the canonical reason phrase, so
  `res.writeHead(404, "Nope")` meant patching the status line afterwards by
  re-finding the CRLF in the encoder's own output.
- `BodyLength` had no close-delimited framing.
- A HEAD response advertises the `Content-Length` it would have sent and sends
  no body. `Known(0)` rejected that head as a conflict with the header, and
  `Known(n)` refused to `finish` without n bytes.

Now:

- `Encoder::start_with_reason(head, reason, body, out)` writes the given
  phrase. It is validated against RFC 9112's reason-phrase grammar (tab, space,
  visible ASCII, obs-text) before anything is written, so a phrase cannot inject
  a header line, and it is refused on a request. `start` is unchanged. A
  separate constructor rather than a field on `Head` keeps every existing
  `Head { .. }` literal compiling, and a decoded head has no reason to carry.
- `BodyLength::CloseDelimited`: responses only, no framing header, body bytes
  written raw, `finish` writes nothing and the host's close ends the body. A
  head with `Content-Length` or `Transfer-Encoding`, a request, and a
  1xx/204/304 status are refused, as are trailers.
- `BodyLength::Omitted`: responses only; the head's framing headers go out
  verbatim, none is added, and no body follows. That is a response to HEAD
  (length or chunked coding advertised) and a 304. A 204 or 1xx may not
  advertise a length at all (RFC 9110 8.6), so a framing header there is still
  refused. Of the issue's two shapes for this - a variant or a `head_response`
  flag on `start` - the variant fits: body framing is exactly what `BodyLength`
  already chooses, and a flag would be a second argument that changes what the
  first means.

Breaking: `BodyLength` gains two variants, so an exhaustive `match` on it needs
two more arms. No in-tree match was exhaustive.

Tests: `http1_encoder_writes_a_custom_reason_phrase`,
`http1_encoder_writes_a_close_delimited_body` and
`http1_encoder_writes_a_body_forbidden_response` assert the exact wire bytes,
decode the close-delimited and HEAD responses back with `Decoder` (a body ended
by `eof`, and a HEAD response that ends at its head and stays reusable), and
check every refusal leaves the output untouched.

Closes #47
`Event::End` (and `Event::Upgrade`) come from a step that consumes nothing:
the step that consumes the last byte of a message only moves the decoder to
`End`, and the next call delivers it. A host that feeds the decoder "while
there is input" hands over every byte and stops, so the message sits finished
inside the decoder. Two lanes lost a debugging cycle to it: P6's client took
~5 s per fetch with no connection reuse, and P11's first real-server probe
timed out on every URL - the peer's idle timeout was the only thing ending the
exchange.

#85 stated the `Step` contract (loop while `consumed > 0 ||
event.is_some()`) in the crate root and on both `Step` types. The issue's
comment argues that two independent consumers hitting the same trap is the case
for a predicate over another note, and a host keyed on input rather than on
steps has no reason to read the loop condition anyway. So both:

- `Decoder::wants_step()` is true exactly when a zero-input event is pending
  (`State::End` or `State::Upgrade`): after the last body byte or final chunk,
  after the head of a bodiless message, and after `eof` ends a close-delimited
  body. `while !input.is_empty() || decoder.wants_step()` is a correct loop.
- `http1::Step`'s documentation now carries a runnable loop, as `http2::Step`'s
  does, and a paragraph on the zero-consumption trap naming `wants_step`. The
  crate-root contract names the "while it has input" loop beside the "while
  consumed" one.

Tests: `http1_end_needs_a_step_after_the_last_byte` runs the loop both lanes
wrote over a Content-Length, a chunked and a 204 response, and asserts it
consumes every byte yet never sees `End` and leaves the decoder unreusable -
the P6 symptom - while `wants_step` is true. The same loop keyed on
`wants_step` too reaches `End` from a zero-consumption step, leaves the
decoder reusable, and ends with `wants_step` false. It also covers `eof` on a
close-delimited body and a request-side upgrade. The existing
`step_contract_rejects_both_wrong_loop_conditions` still covers the
"stop when nothing was consumed" loop.

Closes #50
The only way to learn whether a zero-byte read was a legal end of body or a
truncated response was to call `eof()` and see whether it failed - and `eof()`
commits: on the wrong answer it has already failed the decoder. Perry's P11
client needs the answer on every connection, before choosing between retrying,
failing with its own diagnosis, or logging.

The issue offers a `wants_step()`-style predicate or an explicit query.
`wants_step` (#50) answers a different question - whether an event is owed -
so this adds the explicit pair, both read-only:

- `is_mid_message()`: a final head has been decoded and the end of the message
  has not been reached (fixed-length or close-delimited body, chunk framing,
  trailers). An informational response is not the message.
- `eof_is_clean()`: true exactly where `eof()` would return `Ok` - inside a
  close-delimited body and once the message is complete. `eof()` is now
  written in terms of it, so the two cannot drift.

Two predicates rather than one because the question P11 asks has three
answers: not in a message, in a message where EOF is truncation, and in a
close-delimited body where EOF is the end.

Test: `http1_mid_message_is_queryable_without_feeding_eof` walks a
Content-Length, a close-delimited and a chunked response through each state
and asserts both answers, that asking changes nothing (the body continues to
decode afterwards), that a 100 Continue is not mid-message, and that
`eof_is_clean() == false` is followed by `eof()` failing.

Closes #80
`StreamingDecoder::process` returned after a single engine call, which can stop
anywhere: after a gzip header, at a member boundary, when the output filled,
or when the input ran out. Every case looked the same - a step that consumed
and wrote something - so a host could only loop until a step did neither. Perry's
P6 client does exactly that, and so has no signal for "give me a bigger buffer"
versus "feed me more bytes".

`process` now runs engine steps until one of three reasons holds, and
`DecodeStep` gains `needs_input` to say which:

- `finished`: the body is complete.
- `needs_input`: every input byte the decoder can use is used; read more, or
  pass `end`.
- neither: `output` is full (`written == output.len()`); drain and call again.

The issue asks for a flag rather than an enum, and a flag is enough: with
`finished` it separates all three cases, and it keeps `DecodeStep` a plain
struct of counts. Running to a stop reason inside `process` is what makes the
flag true to its name - a single engine call can stop with input left and
space free, which is neither - and it means one call per transport read and
one per full buffer, with no no-progress call at the end. Existing loops keep
working: their extra call now returns `0, 0, needs_input`.

`decode` uses the flag for its truncation check instead of the both-zero test.

Breaking: `DecodeStep` has a new public field, so a struct literal of it outside
the crate needs `needs_input`.

Test: `streaming_decoder_says_whether_it_needs_input_or_output_space`, for
gzip, deflate, brotli and zstd (both zstd backends: default and
`--all-features`). With all input and a 4-byte buffer, every step but the last
writes exactly 4 bytes with `needs_input` false, in at most len/4 + 1 calls;
fed one byte at a time into a 1000-byte buffer, every step but the last reports
`needs_input`, and the last `finished`. On the old code the first half fails:
the gzip header step returns with nothing written and space free.

Closes #54
The native zstd engine set `boundary` from every `run_on_buffers` result. A
call that moves nothing - empty input after the last byte of a frame - reports
the header size of the *next* frame as `remaining`, so it cleared a boundary
that had just been reached. The following `end` then looked like a truncated
frame and failed a complete body.

A host hits this whenever it learns the body is over after feeding the last
byte, which is the normal HTTP/1 shape: the last body bytes arrive in one step
and `End` in the next, zero-byte one (#50). The previous commit's
run-to-a-stop-reason loop makes the empty call itself, so it made the bug
reachable from a single `process` per read; a host that looped until no
progress could already reach it.

Only a call that consumed or wrote something now moves the boundary.

Test: `streaming_decoder_finishes_when_end_follows_the_last_byte` feeds each
coding byte by byte without `end`, makes an idle call, then declares `end` with
no input. Native zstd failed it with UND_ERR_SOCKET before this change; the
pure-Rust zstd path, gzip, deflate and brotli pass either way.

Refs #54
`StreamingDecoder::new` matched its argument as one exact token, so a legal
`gzip, gzip` - or two `Content-Encoding` header lines - failed the whole
response with UND_ERR_NOT_SUPPORTED. Perry's P11 client is the first consumer
to send its own `Accept-Encoding`, and an independent review of it found the
gap; it now splits and chains on the caller side.

The issue offers an iterator of tokens or split-and-chain internally. This does
both, because they are the same thing at two call shapes:

- `StreamingDecoder::new(value, limit)` takes one header value, list included.
- `StreamingDecoder::from_codings(values, limit)` takes every header line's
  value in order and merges them as RFC 9110 5.3 merges any list-valued
  field. `Head::values(name)` yields those lines (`get` returns only the first).

Codings are decoded in reverse of the order listed: the last-listed is
outermost. Names are case-insensitive (RFC 9110 8.4.1), `x-gzip` is `gzip`,
and `identity` and empty list elements are skipped. An unknown coding still
fails with UND_ERR_NOT_SUPPORTED, and so does a chain longer than
`MAX_CODINGS` (5): the list is attacker-chosen and each coding costs state and
a buffer; undici stops at five for the same reason.

A single coding is unchanged: it decodes straight into the caller's output, and
the chain vector stays empty and unallocated. Each further coding decodes into a
fixed 16 KiB staging buffer - over the 8 KiB a gzip header may take, so the next
coding always sees a whole header - allocated at construction and kept across
`reset`, so steady-state reuse stays allocation-free. A chained step keeps the
#54 contract: it returns only when finished, when it needs input, or when the
output is full. `limit` bounds every coding's output, not only the last: an
outer coding could otherwise expand without bound into an inner one that
produces nothing (empty gzip members, for instance), costing CPU with no
decoded bytes to trip the limit.

Internally the per-coding state moves into a `Codec`, and `StreamingDecoder`
owns the innermost one plus the staged outer ones.

Tests: `streaming_decoder_applies_a_content_encoding_list_innermost_last`
decodes a 80 KB `gzip, br` body whole, byte-by-byte into 7-byte output, and in
other input/output splits, before and after `reset`; the same from two header
lines through `Head::values`; the reversed order fails; `gzip, gzip` under
case, `x-gzip`, identity and empty elements, through the convenience `decode`;
every coding under gzip, deflate and brotli (zstd included) in four
input/output splits; truncation; the decoded-size limit; unknown codings and a
six-coding chain refused, five accepted. `streaming_decompressors_reuse_scratch`
gains a `gzip, br` case and still measures zero allocations across 100 reused
bodies. All pass with the native and the pure-Rust zstd backends.

Closes #79
@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 21 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: 6fd436f9-61ae-4f74-a0d4-f66d26c3865a

📥 Commits

Reviewing files that changed from the base of the PR and between f692519 and 86f948d.

📒 Files selected for processing (11)
  • protocols/turnloop-http/README.md
  • protocols/turnloop-http/src/asynchronous/client.rs
  • protocols/turnloop-http/src/asynchronous/mod.rs
  • protocols/turnloop-http/src/asynchronous/server.rs
  • protocols/turnloop-http/src/compression.rs
  • protocols/turnloop-http/src/http1.rs
  • protocols/turnloop-http/src/lib.rs
  • protocols/turnloop-http/tests/allocations.rs
  • protocols/turnloop-http/tests/asynchronous.rs
  • protocols/turnloop-http/tests/codecs.rs
  • protocols/turnloop-http/tests/interop.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.

asynchronous::client::Decoders::start read only the first Content-Encoding
line, so a response carrying `gzip` on two separate lines was decoded once and
handed the caller still-gzipped bytes. RFC 9110 5.3 makes repeated lines one
list; StreamingDecoder already takes a list, so the lines are joined into the
decoder's cache key. The single-line case still borrows the header value.

The new unit test decodes a body gzipped twice, once with two header lines
and once with one `gzip, gzip` line, and fails without the change.

Refs #79
…sion

# Conflicts:
#	protocols/turnloop-http/README.md
@proggeramlug
proggeramlug merged commit 14bc89b 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