Skip to content

HTTP client pool deadlines and stale ids, multipart encoder, host-built rustls configs and provider choice - #101

Merged
proggeramlug merged 4 commits into
mainfrom
fix/http-client-and-tls-config
Sep 22, 2026
Merged

proggeramlug merged 4 commits into
mainfrom
fix/http-client-and-tls-config

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Resolves five issues in the HTTP client (protocols/turnloop-http/src/client.rs, new multipart module) and turnloop-tls.

#51: check or drop a stale ConnectionId

Pool::contains(id) reports whether an id still names a live connection. Pool::forget(id) drops it whatever its state, including outstanding acquisitions, so the per-host place is free again. It clears the idle deadline and returns whether the id was live. A stale id is reported, not an error, so a host whose socket table disagrees with the pool no longer has to make up a release-then-close sequence. ConnectionId now documents that ids are never reused.

Closes #51

#52: one client-wide next_timeout

New client::Deadlines<K>, a keyed deadline heap: next_timeout is O(1), and set/pop_expired are O(log n). Replaced entries are dropped lazily and the heap is rebuilt when they pile up, so its size stays within 2n+16. Pool owns two of these. One holds the idle deadlines, so next_timeout and handle_timeout no longer scan slots. The other holds request deadlines the host registers with set_request_deadline(RequestId, lifecycle.next_timeout()) and drains with handle_request_timeout. Pool::next_timeout covers both, so the host arms one timer per turn for every connection and in-flight request, and per-phase connect/headers/body deadlines no longer cost a rescan.

Closes #52

#78: multipart/form-data builder

turnloop_http::multipart::{Form, Part, Encoded} builds text and file parts in memory. It escapes names and filenames the way browsers do and rejects a content type that contains control characters. Encoded::apply puts the body and content type on a client::Request. The boundary comes from 16 bytes of entropy that the host supplies (the crate stays sans-I/O and adds no new dependency). Each candidate boundary is checked against every part's bytes, name, filename and content type, and a new one is generated on collision. Candidates are pairwise distinct, so the search always ends. A test puts the first two candidates inside a base64-like field and a filename. It asserts that the chosen delimiter appears exactly once per part plus once as the closing delimiter.

Closes #78

#48: from_rustls on both TLS configs

ClientConfig::from_rustls and ServerConfig::from_rustls wrap an Arc<rustls::…Config> that the host built itself, which covers client certificates, SNI resolvers, custom verifiers and ticketers. Both configs also get rustls_config(). The host-supplied clock is now the public HostTime. Pass host_time.time_provider() to builder_with_details and certificate checks keep following the time given to process. Tested with mutual TLS: the server sees exactly the client's certificate, and a client without a certificate is refused.

Closes #48

#53: host-chosen crypto provider, ring optional

ClientOptions::provider and ServerConfig::with_provider accept any rustls CryptoProvider. With None, the client keeps using explicit ring, so behaviour is unchanged. ring is now a default feature of turnloop-tls (dep:ring + rustls/ring). turnloop-http forwards it as its own default ring feature and depends on turnloop-tls with default features off, because it never builds a TLS config itself. turnloop-tls now declares rustls directly instead of inheriting the workspace entry, which turns on ring for every member. The resolved versions and Cargo.lock do not change. With --no-default-features, cargo tree -e normal -i ring prints nothing for either crate. In that mode new uses the provider the host passes or the rustls process default, and returns an error if there is neither. tls_server_end_point, which returns a ring Digest, is compiled out, and the new provider-neutral tls_server_end_point_hash tells the host which hash to compute. A test gives each side a different single-suite provider and checks that the handshake fails with NoCipherSuitesInCommon. The same test passes when both sides share the suite. deny.toml is unchanged, and feature_modes.py and check-paths.py pass.

Closes #53

Breaking changes

  • turnloop_tls::ClientOptions has a new public field, provider. Struct literals need ..Default::default(). Every literal in the workspace already uses it.
  • With default-features = false, turnloop-tls has no tls_server_end_point and links no crypto provider. Default builds are unaffected.
  • turnloop-http depends on turnloop-tls through its own default ring feature, not directly through the workspace entry.

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-tls --all-features
  • cargo +nightly-2026-08-20 test --locked -p turnloop-http --all-features
  • cargo +1.97.1 check --locked --workspace --all-targets --all-features
  • python3 scripts/ci/check-paths.py, python3 scripts/ci/feature_modes.py
  • cargo +nightly-2026-08-20 check --locked -p turnloop-tls --no-default-features and -p turnloop-http --no-default-features --all-targets

turnloop_tls::ClientConfig and ServerConfig could only be built from roots or
a chain, a key and ALPN, with rustls::crypto::ring hardwired as the provider.
Everything else rustls can decide at config level (client certificates, SNI
resolvers, custom verifiers, ticketers, protocol versions) was unreachable, so
Perry's P5 lane bypassed the wrappers and P11 could not attach a client
certificate to outbound requests. And a host that installs aws-lc-rs had no
way to keep ring out of its binary.

Wrapping an existing rustls config (#48): both configs gain
`from_rustls(Arc<rustls::…Config>, HostTime)` and `rustls_config()`. The
wrappers' only state besides the rustls config is the host-supplied wall
clock, so that clock becomes the public `HostTime`: the host passes
`host_time.time_provider()` to `builder_with_details`, and certificate
validity keeps following the `unix_seconds` handed to `process`, exactly as
with `new`. `new` is now built on `from_rustls`. A constructor over public
fields was preferred because it keeps the crate's connection handling and
time plumbing in one place.

Choosing the provider (#53): `ClientOptions::provider` and
`ServerConfig::with_provider` take any `Arc<CryptoProvider>`; `None` keeps
the explicit ring provider, so current behaviour is unchanged. Selecting a
provider alone would still link ring, so `ring` is now a default feature
(`dep:ring` + `rustls/ring`). turnloop-tls declares rustls itself rather
than inheriting the workspace entry, which enables ring for every member;
the resolved rustls version and Cargo.lock are unchanged. Without `ring`,
`new` uses the given provider or the rustls process default and returns an
error if there is neither, and `tls_server_end_point` (which returns a ring
Digest) is compiled out; the new provider-neutral
`tls_server_end_point_hash` names the RFC 5929 hash for the host to compute.
turnloop-http forwards the feature (`ring`, default on) and depends on
turnloop-tls with default features off, since it never builds a config
itself. `cargo tree -e normal -i ring` for turnloop-tls and turnloop-http with
`--no-default-features` now prints nothing.

Evidence (tests/tls.rs, tests/channel_binding.rs):
- a client whose provider has only AES-128-GCM and a server whose provider has
  only ChaCha20 fail with NoCipherSuitesInCommon, and succeed when both carry
  ChaCha20; with ring hardwired they would have agreed on a suite;
- a server built with from_rustls and a WebPkiClientVerifier receives exactly
  the client certificate a from_rustls client presents, and refuses a plain
  client with "peer sent no certificates"; the client's HostTime, created at
  0, reads the time `process` supplied;
- tls_server_end_point_hash agrees with the ring digest length for all 14
  certificate fixtures (6 SHA-256, 4 SHA-384, 3 SHA-512, Ed25519 None).

Breaking: ClientOptions has a new public field (struct literals need
`..Default::default()`); turnloop-tls without default features no longer has
`tls_server_end_point`.

Closes #48
Closes #53
Two gaps in client::Pool left every consumer improvising.

A ConnectionId from Acquire::Reuse could not be checked or retired (#51). When
the host's socket table and the pool disagreed, the only recovery was a
fabricated release-then-close sequence, which errors for an id the pool
already dropped. The pool now has `contains(id)` and `forget(id)`: forget
drops the connection whatever its state (outstanding acquisitions included,
so the per-host place is free for the next acquire), clears its idle
deadline and returns whether the id was live; a stale id is reported, not an
error. Both were added rather than one: contains answers the question the
host asks first, forget is the well-defined recovery. ConnectionId now
documents that ids are never reused, so a stale id cannot alias a newer
connection.

There was no client-wide next_timeout (#52). Pool::next_timeout rescanned
every slot, and request lifecycles were not covered at all, so a host with N
requests in flight had to min() N structures per turn; Perry's P6 client
skipped per-phase deadlines (connect/headers/body) for exactly that reason.
The new `client::Deadlines<K>` is a keyed deadline heap: next_timeout is a
peek, set and pop_expired are O(log n). Replaced deadlines are discarded
lazily when they surface and the heap is rebuilt when they outnumber live
entries, so its size stays within 2n+16 even for a body deadline refreshed on
every chunk. The pool owns two of them: its idle deadlines (next_timeout and
handle_timeout no longer scan slots) and host-registered request deadlines
(`set_request_deadline(RequestId, lifecycle.next_timeout())`, drained with
`handle_request_timeout`). Pool::next_timeout is the minimum of both, so one
call arms the host's timer for every connection and request. The existing
handle_timeout signature and behaviour are unchanged; a heap in the pool was
chosen over a timer wheel because deadlines are arbitrary Instants the host
supplies and no tick granularity is imposed.

Evidence (tests/client.rs, client.rs unit test):
- a Reuse id the host forgets is no longer contained, a second forget and a
  release report it stale, the per-host place goes to a new id, and a
  forgotten idle connection's deadline never fires;
- with 50 idle connections and 100 registered request lifecycles one
  next_timeout reports the earliest deadline, follows a request moving to 10s
  and back out when its phase drops the deadline, expires the 50 idle
  connections and then exactly the two requests due, each yielding
  UND_ERR_HEADERS_TIMEOUT from its Lifecycle;
- an Http1Connection's 100-continue and headers deadlines feed the pool;
- 100 000 refreshes of one key keep the heap within bound and the refreshed
  key still fires last.

Closes #51
Closes #52
turnloop_http had no multipart builder, which blocked Perry's CLI migration
off reqwest: `perry publish`, `audit`, `verify` and `run --remote` all send
multipart bodies, and P11 had to carry its own 280-line builder.

`multipart::Form` holds ordered text and file parts (`Part::text`,
`Part::file`, optional `content_type`; files default to
application/octet-stream) and `encode(entropy)` returns the body plus its
boundary; `Encoded::content_type()` gives the header value and `apply`
installs both on a client::Request. Names and filenames are escaped as
browsers do (`"`, CR, LF percent-encoded) and a content type with a control
character is rejected, so no field can inject a header line.

The boundary is verified, not just random. A base64 field can hold a
boundary-shaped run, and a collision silently truncates the upload, so every
candidate is checked against each part's bytes, name, filename and content
type, and the next candidate is tried until none contains it. Entropy comes
from the host as 16 bytes: the crate reads no randomness source (sans-I/O),
and its dependencies offer none once ring is optional. Candidates are
SplitMix64 outputs over distinct inputs and therefore pairwise distinct, so
the search ends within (total part bytes + 1) tries. The format is
"turnloop-" plus 32 hex digits, 41 characters with nothing to quote. In-memory
encoding matches every current Perry caller (only Part::text is used; no
streamed parts).

Evidence (tests/multipart.rs): a four-part form (escaped name, CRLF in a
value, binary file, typed file) encodes byte-for-byte to the expected body,
deterministically per entropy; planting the first two candidates in a
base64-looking field and a filename makes the encoder pick a third boundary
whose delimiter occurs exactly three times, two openings and one closing;
`apply` leaves exactly one content-type header; a CRLF content type is
refused with UND_ERR_INVALID_ARG.

Closes #78
@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 41 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: 5136770d-de3d-4282-afef-5b856eb4fc0e

📥 Commits

Reviewing files that changed from the base of the PR and between cfc9b87 and e771342.

📒 Files selected for processing (13)
  • protocols/turnloop-http/Cargo.toml
  • protocols/turnloop-http/README.md
  • protocols/turnloop-http/src/client.rs
  • protocols/turnloop-http/src/lib.rs
  • protocols/turnloop-http/src/multipart.rs
  • protocols/turnloop-http/tests/client.rs
  • protocols/turnloop-http/tests/multipart.rs
  • protocols/turnloop-tls/Cargo.toml
  • protocols/turnloop-tls/README.md
  • protocols/turnloop-tls/src/channel_binding.rs
  • protocols/turnloop-tls/src/lib.rs
  • protocols/turnloop-tls/tests/channel_binding.rs
  • protocols/turnloop-tls/tests/tls.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 f692519 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