From f1fc09d5f34f49dd97f5db76828b512e2fd13519 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Mon, 24 Aug 2026 16:51:08 +0200 Subject: [PATCH 1/5] docs(fetch): define portable transport API Design a transport-erased HttpClientBuilder around portable library requirements, move backend mechanisms out of the public surface, and document named client credentials and TLS endpoint identity mapping. Add executable WinHTTP probes for independent routing/TLS/authority control and Nagle behavior to ground the design in measured backend capabilities. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c1d47b6-8039-4748-972d-20b238499d82 --- Cargo.lock | 166 +++++ Cargo.toml | 2 + crates/fetch/docs/design/README.md | 218 +++++++ crates/fetch/docs/design/capability-matrix.md | 149 +++++ .../docs/design/transport-configuration.md | 357 +++++++++++ crates/fetch_winhttp/Cargo.toml | 14 + crates/fetch_winhttp/docs/design.md | 19 + crates/fetch_winhttp/docs/implementation.md | 27 + .../docs/nagle-behavior-experiment.md | 73 +++ .../docs/resolution-hostname-experiment.md | 96 +++ .../fetch_winhttp/examples/nagle_behavior.rs | 163 +++++ .../fetch_winhttp/examples/nagle_receiver.py | 77 +++ .../examples/resolution_hostname.rs | 580 ++++++++++++++++++ 13 files changed, 1941 insertions(+) create mode 100644 crates/fetch/docs/design/README.md create mode 100644 crates/fetch/docs/design/capability-matrix.md create mode 100644 crates/fetch/docs/design/transport-configuration.md create mode 100644 crates/fetch_winhttp/docs/nagle-behavior-experiment.md create mode 100644 crates/fetch_winhttp/docs/resolution-hostname-experiment.md create mode 100644 crates/fetch_winhttp/examples/nagle_behavior.rs create mode 100644 crates/fetch_winhttp/examples/nagle_receiver.py create mode 100644 crates/fetch_winhttp/examples/resolution_hostname.rs diff --git a/Cargo.lock b/Cargo.lock index 0aeffa52e..23f3bbeb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,45 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5ade012bac4db278517a0132c8c10c6427025868dca16c801087c28d5a411f1" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -491,6 +530,15 @@ dependencies = [ "unty-next", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1078,6 +1126,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "data_privacy" version = "0.12.4" @@ -1191,6 +1245,20 @@ dependencies = [ "thiserror", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1486,6 +1554,18 @@ dependencies = [ [[package]] name = "fetch_winhttp" version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "rcgen", + "rustls", + "tokio", + "tokio-rustls", + "windows-sys 0.61.2", +] [[package]] name = "find-msvc-tools" @@ -2650,6 +2730,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.1" @@ -2788,6 +2874,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nonempty" version = "0.12.0" @@ -2803,6 +2899,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2902,6 +3008,15 @@ dependencies = [ "syn", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3572,6 +3687,19 @@ dependencies = [ "rustversion", ] +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "recoverable" version = "0.1.7" @@ -3823,6 +3951,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3844,6 +3981,7 @@ checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -5504,12 +5642,40 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + [[package]] name = "xxhash-rust" version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 3ba7457ac..43082f532 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -175,6 +175,7 @@ protox = { version = "0.9.1", default-features = false } ptr_meta = { version = "0.3.1", default-features = false } quote = { version = "1.0.42", default-features = false } rapidhash = { version = "4.1.1", default-features = false } +rcgen = { version = "0.14.5", default-features = false } recoverable = { path = "crates/recoverable", default-features = false, version = "0.1.7" } regex = { version = "1.12.2", default-features = false } rest_over_grpc = { path = "crates/rest_over_grpc", default-features = false, version = "0.2.0" } @@ -217,6 +218,7 @@ tick = { path = "crates/tick", default-features = false, version = "0.4.0" } time = { version = "0.3.47", default-features = false } tokio = { version = "1.48.0", default-features = false } tokio-native-tls = { version = "0.3.1", default-features = false } +tokio-rustls = { version = "0.26.4", default-features = false } tonic = { version = "0.14.2", default-features = false } tonic-prost = { version = "0.14.2", default-features = false } tonic-prost-build = { version = "0.14.2", default-features = false } diff --git a/crates/fetch/docs/design/README.md b/crates/fetch/docs/design/README.md new file mode 100644 index 000000000..4abf747bf --- /dev/null +++ b/crates/fetch/docs/design/README.md @@ -0,0 +1,218 @@ +# `fetch` design + +`fetch` provides a stable HTTP client and configuration surface over transports with different +capabilities. Applications choose a transport, while libraries can configure the portable +networking behavior they require without knowing which transport the application selected. + +The crate may use focused implementation crates for Hyper, TLS, and platform transports. Those +package boundaries are hidden by the supported `fetch` API. + +## Configuration model + +Configuration is classified by semantics and ownership: + +1. **Pipeline policy** is implemented above the transport and is available for every client. + Routing, resilience, telemetry, redaction, response policy, and custom middleware are in this + category. +2. **Portable transport requirements** describe observable networking behavior that a library or + application may require. Examples include connection lifetime, connection limits, protocol + constraints, certificate authentication, and portable trust policy. Every transport must honor + an explicit requirement or reject client construction. +3. **Transport-specific configuration** controls mechanisms that have no portable contract. + Applications set these options on a concrete transport builder before passing it into `fetch`. + Examples include rustls verifier callbacks, WinHTTP proxy discovery, and SChannel-specific + options. A backend mechanism does not automatically deserve a public transport option; routine + flow-control, socket-buffer, and congestion tuning remains transport-owned. + +The fact that a behavior is implemented by a transport does not make it transport-specific. +Connection lifetime is implemented differently by Hyper and WinHTTP, but its useful contract can +be expressed portably. Conversely, a rustls verifier callback is transport-specific because its +contract is the rustls callback API itself. + +Detailed requirement semantics and lowering rules are defined in +[transport configuration](transport-configuration.md). The current backend comparison and the +public-surface conclusion are in the [capability matrix](capability-matrix.md). + +## Composition across application and library boundaries + +`HttpClient` and `HttpClientBuilder` are concrete, transport-erased types. Every supported +transport implements the complete library-facing baseline, so retaining the transport type in the +builder would add generic complexity without preventing a demonstrated incompatibility. + +The application configures transport-specific behavior before handing the transport to `fetch`: + +```rust,ignore +let transport = fetch::transport::winhttp() + .proxy(proxy) + .integrated_authentication(true); + +let builder = fetch::HttpClient::builder(transport); +let client = service_library::build_client(builder)?; +``` + +The library accepts any builder whose transport implements the semantic capabilities it needs. It +does not name Hyper, rustls, native TLS, or WinHTTP: + +```rust,ignore +pub fn build_client( + builder: fetch::HttpClientBuilder, +) -> fetch::Result { + builder + .client_certificate(fetch::ClientCredentialId::new("tvs-client")) + .tls_server_name( + fetch::Origin::https("localhost", service_port), + fetch::ServerName::new("tvs.prod.example")?, + ) + .connection_lifetime(Duration::from_minutes(30)) + .standard_pipeline(configure_resilience) + .build() +} +``` + +This preserves the important ownership split: + +- the application chooses Hyper with rustls, Hyper with native TLS, WinHTTP, or a fake; +- the library declares the behavior its service requires; +- every accepted transport implements the full library-facing contract; +- `fetch` validates requested values, credential bindings, and host support during construction; +- neither party silently overrides or weakens the other's requirements. + +A library that requires no configuration accepts a built `HttpClient`. A library that owns +pipeline or portable transport policy accepts an `HttpClientBuilder` and builds the concrete +client after applying its requirements. + +## Transport contract + +A transport configuration implements the complete portable contract and materializes the request +handler at the bottom of the pipeline. During client construction it receives: + +- the resolved portable requirements; +- shared assembly services such as response-body infrastructure and telemetry; +- the runtime and threading context selected by its adapter. + +Materialization remains fallible because a particular duration, credential binding, operating +system version, or external resource may be invalid or unavailable. These are value, provisioning, +or environment failures rather than structural capability mismatches, and they are reported before +requests are sent. + +Runtime-selected and externally supplied transports use the same erased path. An external +transport is accepted only by implementing the full portable contract; partial transports do not +implement `Transport`. + +## Supported transports and runtimes + +The supported Hyper transport combines a connector, runtime services, a TLS backend, and +transport-specific tuning. Tokio is a supported runtime adapter in `fetch`. + +WinHTTP is a full-stack transport rather than a Hyper connector. It owns its sessions, connection +pool, SChannel integration, and asynchronous callback bridge. It participates in the same portable +requirements contract as Hyper while retaining its native configuration surface. + +Other runtime crates may supply connectors and execution services to supported transports without +creating a separate HTTP client API. + +## Libraries configure outcomes, not mechanisms + +Portable APIs describe guarantees with enough precision to validate them. They do not expose a +lowest-common-denominator options bag. + +For example, connection maximum lifetime means that a connection is not selected for a new request +after the configured age. Hyper can enforce that contract by retiring or poisoning pooled +connections. WinHTTP can enforce it with `WINHTTP_OPTION_EXPIRE_CONNECTION`. The mechanism differs; +the guarantee does not. + +When no faithful common contract exists, the option remains transport-specific. Coarse or partial +support is not silently treated as success. A weaker behavior requires a separately named portable +contract or an explicit preference API with observable resolution results. + +## Transport-owned performance policy + +The stable API exposes service requirements, not copies of socket and protocol-stack knobs. +Supported transports choose and validate defaults for HTTP flow control, kernel buffering, and +congestion behavior. + +Small writes are the exception because avoiding Nagle/delayed-ACK stalls is a general HTTP client +invariant rather than workload tuning. A transport that owns its sockets disables Nagle. An opaque +platform transport must demonstrate equivalent small-write behavior in an integration benchmark; +the WinHTTP probe does so for the tested HTTP/1.1 path. + +HTTP/2 receive windows remain transport-owned. Their useful value depends on bandwidth-delay +product, concurrent streams, response consumption, memory budget, and whether the implementation +uses adaptive flow control. Kernel send and receive buffers remain under operating-system +autotuning. Initial congestion behavior remains operating-system policy. None is configurable +through `HttpClientBuilder`. + +## TLS and credentials + +TLS backend selection and backend-native customization are transport-specific. Portable security +requirements remain on `HttpClientBuilder` because libraries may own them. + +Client-certificate authentication uses a logical credential identifier. Libraries name the +credential role they require; applications bind that name to transport-native certificate sources +when constructing the transport. A Windows application may bind the name to a certificate-store +selector, while a Linux application may bind it to provisioned key material. The library does not +observe either modality. + +A named binding may represent a set of rotating certificates. Rustls and WinHTTP can select a +certificate using issuer hints received during the handshake. The current native-TLS adapter must +resolve the binding to one identity when constructing the connector and therefore requires a client +rebuild to pick up rotation. + +Portable server validation is split into platform chain trust and endpoint identity. Platform +trust, hostname validation, and revocation are baseline security behavior. A library may map a +request origin to an exact TLS DNS name when the network endpoint and authenticated service name +differ. The request URI and wire authority remain unchanged; only connection establishment uses +the mapped TLS name. All supported transports can provide this contract without a custom +certificate-validation callback. + +An exact TLS-name mapping does not express arbitrary SAN patterns, subject distinguished-name +allowlists, or certificate/public-key pins. Those are separate policies and are not part of the +initial portable baseline. They should become semantic capabilities only if a service demonstrates +that a stable exact DNS identity cannot represent its requirement. + +A raw rustls verifier callback remains a rustls-specific mechanism. + +## Requirement composition + +Portable settings are accumulated as constraints rather than applied with unrestricted +last-write-wins semantics. + +- compatible bounds merge to the stricter result; +- an application may add or tighten a library requirement but may not weaken it; +- equivalent credential requirements are deduplicated; +- incompatible required credentials or policies fail construction with their sources identified. + +An explicitly configured portable option is required by default. The initial stable surface does +not silently downgrade performance requirements. Preferences may be added only with a resolution +report that lets the caller observe whether and how they were applied. + +## Telemetry + +The pipeline creates one telemetry scope for the client. The selected transport receives the +corresponding meter during materialization and records transport events within that scope. Runtime +and transport names are stable attributes supplied by their adapters. + +A transport does not require callers to provide a second telemetry sink or meter. + +## Features + +The core client and transport traits are available without selecting a runtime or TLS backend. +Features add supported runtime, transport, and TLS implementations. + +Feature selection never resolves ambiguity by order. When multiple TLS backends are enabled, the +application selects one on the concrete transport builder or accepts a documented preset. +Libraries do not enable a concrete backend merely to express portable requirements. + +## Public API boundary + +`HttpClientBuilder` contains pipeline policy and portable transport requirements. Its methods are +available regardless of the selected supported transport. The builder does not contain +transport-specific configuration or backend capability branches. + +Concrete transport builders contain backend selection and native tuning. The transport interface +receives resolved portable requirements rather than a Hyper-shaped options structure. + +Building returns a concrete `HttpClient` and may fail for invalid values, unresolved named +credentials, unavailable runtime resources, or an unsupported host version. Unsupported security +or networking configuration is never ignored, approximated without an explicit contract, or +discovered only after a request begins. diff --git a/crates/fetch/docs/design/capability-matrix.md b/crates/fetch/docs/design/capability-matrix.md new file mode 100644 index 000000000..07c039a9b --- /dev/null +++ b/crates/fetch/docs/design/capability-matrix.md @@ -0,0 +1,149 @@ +# Transport capability matrix + +This document compares the supported Hyper TLS combinations with the planned WinHTTP transport. It +separates differences that matter to libraries from backend mechanisms that should not expand the +portable `fetch` API. + +The WinHTTP column describes the design on `u/makolnek/winhttp`; implementation work must verify +the stated guarantees. + +## TLS and client authentication + +| Capability | Hyper + rustls | Hyper + native TLS | WinHTTP + SChannel | Public treatment | +| --- | --- | --- | --- | --- | +| Platform trust | Platform verifier | Native platform trust | Native Windows trust | Baseline/invariant | +| Named client credential | Catalog can bind key material or a signing resolver | Catalog binds a materialized native identity | Catalog can bind a store selector or imported material | Baseline | +| Exportable certificate and private key binding | Supports common key encodings | Requires PKCS#8 through the current adapter | Planned import into a temporary certificate store | Transport construction | +| Non-exportable Windows-store binding | Supported through a rustls signing resolver | No equivalent current API | Supported through `CERT_CONTEXT` | Transport construction | +| Arbitrary external signing service | Supported through rustls signing traits | Unsupported | Unsupported unless it provides a compatible Windows key handle | Keep rustls-specific until a non-Windows library use exists | +| Custom verifier callback | Supported | No equivalent current `fetch_tls` API | No userspace callback | Transport-specific | +| Exact TLS server-name override while preserving request authority | Connector dials the request endpoint and supplies the override to rustls | Connector dials the request endpoint and supplies the override to native TLS | `WinHttpConnect` uses the TLS name, resolution override uses the endpoint, and replaced `Host` preserves authority | Baseline | +| Custom SAN/subject server-identity policy | Enforced before application data by a verifier | No equivalent current adapter | Server certificate is queryable only after TLS negotiation; equivalent pre-disclosure enforcement is unproven | Add a semantic capability only if exact-name mapping cannot replace the TVS rules | +| Certificate or public-key pins | Implementable in a verifier | No equivalent current adapter | Certificate context is queryable after negotiation | Separate transport-specific feature until a safe portable contract exists | +| Custom trust roots | Expressible through custom rustls configuration | Not exposed by the current adapter | Uses OS trust unless additional validation is implemented | Transport-specific until a portable requirement is demonstrated | +| TLS backend and crypto-provider selection | rustls-specific | native-TLS-specific | SChannel is fixed | Transport-specific | +| Revocation | Required by the platform-verifier policy | Platform behavior | Must be enabled explicitly | Invariant, not a capability | + +Libraries select a stable logical client-credential identifier. Applications bind that identifier +to key material, a Windows-store selector, or another transport-native provider. This makes source +modality a transport-construction concern rather than a library-facing capability axis. + +## HTTP protocols + +| Capability | Hyper + either TLS backend | WinHTTP | Public treatment | +| --- | --- | --- | --- | +| HTTP/1.1 and HTTP/2 preference | Supported | Supported | Baseline | +| Strictly require HTTP/2 | Supported by Hyper's HTTP/2-only mode | Supported by enabling HTTP/2 and setting `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED` | Baseline; gRPC is a demonstrated consumer | +| Initial HTTP/2 stream receive window | Fixed or adaptive policy | OS default; a fixed window option exists | Transport-owned default, not public configuration | +| HTTP/3 | Unsupported | Supported on recent Windows | Transport-specific until a library requires HTTP/3 | +| Fine-grained HTTP/2 flow control | Supported | Different partial native controls | Internal transport policy | + +An ordered version preference and a protocol requirement are different APIs. A transport may honor +an HTTP/2 preference by falling back to HTTP/1.1, but that is not sufficient for a gRPC library +that requires HTTP/2. WinHTTP can prevent fallback by combining its HTTP/2 enable flag with +`WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED`; no user-space emulation is needed. On systems that lack +the option, transport construction fails. + +The receive window is not a protocol requirement. Its optimum depends on path bandwidth and RTT, +active stream count, response consumption, memory budget, and adaptive-window behavior. A numeric +cross-transport option would expose only part of that policy. Transports select and benchmark their +own defaults. + +## Connections + +| Capability | Hyper + either TLS backend | WinHTTP | Public treatment | +| --- | --- | --- | --- | +| Fixed maximum connection lifetime | Enforced by retiring aged connections | Planned through `WINHTTP_OPTION_EXPIRE_CONNECTION` | Baseline | +| Per-connection lifetime callback | Supported by current Hyper options | No portable callback model | Transport-specific; fixed lifetime covers the demonstrated library need | +| Maximum idle age | Arbitrary duration or unlimited | Shortening is supported; longer retention depends on protocol and native scavenging | Baseline with value/protocol validation | +| Total connections per origin | Not provided by the current Hyper option | Native per-server cap | Baseline requirement, but Hyper needs a real total-concurrency implementation | +| Maximum idle connections per host | Current Hyper `max_connections` behavior | No equivalent meaning; WinHTTP's cap is total connections | Rename and keep transport-specific | +| Coarse idle HTTP/2 health check | Supported | Supported with a native minimum interval | Avoid a trait unless a library requires this outcome independently of idle-age policy | +| Keep-alive interval, acknowledgement timeout, and active-only mode | Supported by Hyper | Not available at the same granularity | Transport-specific | +| Multiple dispatch pools | Implemented above the transport | Can use multiple transport instances | Pipeline policy, not a transport capability | +| Avoid Nagle/delayed-ACK stalls | Set `TCP_NODELAY` on owned sockets | No setter, but calibrated HTTP/1.1 measurements match `TCP_NODELAY` behavior | Transport invariant with backend regression coverage | +| Kernel receive/send buffers | Configurable on owned sockets | No corresponding WinHTTP option | Operating-system default/autotuning | +| Initial TCP congestion window | Windows custom connectors can call an undocumented `WSAIoctl`; no portable equivalent | No corresponding WinHTTP option | Operating-system/network policy | + +The current `ConnectionPoolOptions::max_connections` name is misleading: Hyper forwards it to +`pool_max_idle_per_host`, while downstream TVS configuration describes a maximum number of +concurrent connections per server. These are different guarantees and must become different +options rather than backend mappings of one field. + +Connection idle age is value-dependent rather than a useful type-level distinction. WinHTTP can +honor an upper bound by closing earlier, but cannot promise arbitrary long HTTP/1.1 retention. +Construction validates the requested value together with the protocol requirement. + +Nagle control is deliberately not configurable. General-purpose HTTP needs prompt semantic +boundaries and control frames; bulk transfers already fill segments, while protocol-aware +coalescing provides efficiency without ACK-dependent delay. The +[WinHTTP experiment](../../../fetch_winhttp/docs/nagle-behavior-experiment.md) verifies equivalent +behavior on the tested path but does not turn an undocumented implementation detail into a +platform guarantee. + +Fixed socket buffers and initial congestion windows are not portable outcomes. They can defeat +kernel adaptation, consume memory per connection, or tune one network at the expense of another. +Application buffers remain separate implementation details. + +## Timeouts, I/O, and platform services + +| Capability | Hyper + either TLS backend | WinHTTP | Public treatment | +| --- | --- | --- | --- | +| End-to-end and attempt deadlines | Pipeline-owned | Pipeline-owned | Pipeline policy | +| Connect deadline | Wraps connector establishment | Native resolve/connect controls with different phase boundaries | Baseline after defining one observable deadline | +| Separate resolve/send/receive timers | Not exposed by the supported Hyper path | Native controls | Transport-specific | +| Streaming request and response bodies | Supported | Planned | Required `Transport` invariant | +| Cancellation when the request future is dropped | Supported | Planned through handle closure | Required `Transport` invariant | +| Plain HTTP opt-in | Pipeline request validation plus transport support | Supported | Pipeline policy | +| Runtime/executor selection | Hyper requires an adapter | WinHTTP owns asynchronous I/O callbacks | Transport construction | +| Proxy discovery and integrated Windows authentication | Not in the standard Hyper connector | Native WinHTTP strengths | Transport-specific application configuration | + +## Demonstrated library requirements + +Current downstream code demonstrates a smaller set than the theoretical backend inventory: + +- gRPC requires HTTP/2 and configures a connect timeout; +- the TVS client configures exportable client key material, a server-certificate validator, + connection limits, idle age, and detailed HTTP/2 keep-alive values; +- fetch integration tests exercise fixed maximum connection lifetime. + +These uses do not automatically justify preserving every existing knob: + +- the TVS connection-limit setting currently maps to Hyper's idle-pool limit rather than the + configured concurrent-connection meaning and needs correction; +- detailed keep-alive timings are Hyper mechanisms unless the service can state a portable outcome + it requires; +- inherited C# settings for HTTP/2 windows, socket buffers, and initial congestion do not by + themselves demonstrate a library requirement; transport defaults remain until representative + benchmarks show a material deficit; +- the rustls `Validator` should first be reduced to platform trust plus an exact TLS server name. + Its SAN-pattern and subject-name modalities survive only if concrete TVS deployments cannot name + one DNS identity present in their certificates. + +## Public surface conclusion + +The current evidence does not justify capability traits or a transport-generic +`HttpClientBuilder`. Every demonstrated library requirement has a common observable contract, +and exact TLS server-name mapping covers the need to reach a local endpoint while authenticating a +service DNS name. The public builder can therefore be one concrete type with the same methods for +all supported transports. + +Custom server identity remains outside the proposed surface. It would cover service-defined SAN +patterns or known subject names only if TVS cannot migrate to an exact DNS identity. Hyper/rustls +supports that richer mechanism; the current native-TLS adapter does not, and WinHTTP has not +demonstrated equivalent enforcement before request secrets or body data may be sent. That +hypothetical need should not complicate the builder until it is demonstrated. + +Named client credentials, exact TLS server-name mapping, strict HTTP/2, fixed connection lifetime, +connect deadline, connection limits, streaming, cancellation, and ordinary HTTP/1.1/HTTP/2 +preferences belong to the baseline and do not need capability traits. + +Arbitrary signers, raw verifier callbacks, key encodings, detailed keep-alive controls, HTTP/3, +proxy/WPAD, integrated authentication, phase-specific timeouts, and pool internals stay on concrete +transport builders until a library demonstrates a portable semantic requirement. HTTP/2 +flow-control sizing, socket buffers, and initial congestion are transport-owned defaults rather +than public options on either builder. + +Construction remains fallible despite the uniform surface. Invalid values, missing named +credentials, unsupported operating-system versions, and unavailable resources are environmental +or provisioning failures; they are not evidence for a type-level transport capability. diff --git a/crates/fetch/docs/design/transport-configuration.md b/crates/fetch/docs/design/transport-configuration.md new file mode 100644 index 000000000..75df392fa --- /dev/null +++ b/crates/fetch/docs/design/transport-configuration.md @@ -0,0 +1,357 @@ +# Transport configuration + +This document defines how libraries configure networking requirements without choosing or +understanding the selected transport. + +## Portable requirements + +A portable requirement describes an outcome that can be implemented by different mechanisms. Its +contract is precise enough for a transport to decide whether a particular value is supported. + +Representative requirements include: + +- maximum connection age before retirement; +- maximum idle age before a connection is no longer reused; +- connection limits per destination; +- connect deadline; +- required and preferred HTTP protocol versions; +- client-certificate authentication; +- exact TLS server-name mapping; +- server-certificate trust and pinning policy, if a portable contract is later required; +- cancellation and streaming guarantees. + +These requirements are stored separately from pipeline configuration and from the concrete +transport configuration. + +```rust,ignore +pub struct TransportRequirements { + connection: ConnectionRequirements, + protocols: ProtocolRequirements, + security: SecurityRequirements, +} +``` + +The public types describe semantics, not backend controls. For example, a maximum connection +lifetime is an upper bound on reuse, not a Hyper pool-poisoning interval or a WinHTTP session +timeout. + +## Builder and transport mechanics + +`HttpClientBuilder` owns an erased transport configuration and an accumulated set of portable +requirements. Its setters merge constraints and retain enough provenance to diagnose conflicts. +`build` returns the concrete, transport-erased `HttpClient`. + +`Transport` defines the complete library-facing baseline: + +```rust,ignore +pub trait Transport: Send + Sync + 'static { + fn build( + self: Box, + requirements: TransportRequirements, + context: TransportContext, + ) -> Result; +} +``` + +The application passes any complete transport to the same constructor: + +```rust,ignore +let builder = HttpClient::builder(transport); +``` + +`build` performs value and environment validation because structural support does not imply that +every value is valid. For example, a transport can support connection lifetime while rejecting an +out-of-range duration, or require a named client credential that the application did not bind. + +The transport receives no generic TLS or connection-options bag. Each implementation translates +the semantic requirements directly into its own configuration. + +## Composition and erasure + +Transport erasure occurs when the application creates the builder. Libraries then configure one +stable type without understanding the underlying transport: + +```rust,ignore +pub fn configure(builder: HttpClientBuilder) -> Result { + builder + .connection_lifetime(LIFETIME) + .client_certificate(ClientCredentialId::new("service-client")) + .tls_server_name( + Origin::https("localhost", SERVICE_PORT), + ServerName::new("tvs.prod.example")?, + ) + .build() +} +``` + +The resulting client is likewise non-generic. Runtime-selected transports, application-selected +transports, and fakes all follow this path. A fake implements the full transport contract and can +record the received requirements. + +This design deliberately rejects partial transport capability profiles. A transport that cannot +implement a library-facing baseline requirement is not a `fetch` transport. Differences in +supported values or operating-system availability remain construction-time validation because +Rust types cannot prove those environmental facts. + +## Library-facing surface + +The demonstrated library requirements fit one coherent builder: + +| Concern | Portable contract | +| --- | --- | +| Connection lifetime | Do not select a connection for a new request after its maximum age | +| Idle lifetime | Do not reuse a connection after the configured idle age | +| Connection limit | Bound total concurrent connections per origin | +| Connect deadline | Bound establishment of a usable connection | +| HTTP versions | Express ordered preferences and strict protocol requirements | +| Client authentication | Select a logical credential role provisioned by the application | +| TLS endpoint identity | Authenticate an exact DNS name for a scoped request origin | +| Pipeline behavior | Compose routing, resilience, telemetry, redaction, and response policy | + +Streaming, cancellation, standard chain trust, hostname validation, and revocation are transport +invariants rather than optional builder settings. Backend tuning, proxy discovery, integrated +authentication, custom verifier callbacks, and credential source modalities stay on concrete +transport builders because applications own those mechanisms. + +Routine transport tuning is narrower still: a mechanism is not exposed merely because a backend +offers a setter. HTTP flow-control sizing, kernel socket buffers, and congestion startup remain +implementation policy unless a measured workload establishes a stable outcome that callers need +to control. + +## Requirement strength + +Explicit portable configuration is a requirement unless the API says otherwise. This keeps a +library's correctness, security, and resource assumptions from becoming best-effort behavior when +an application selects a different transport. + +Requirement types encode the guarantee: + +- `ConnectionLifetime::at_most(duration)` limits reuse by connection age; +- `ConnectionIdleAge::at_most(duration)` limits reuse after inactivity; +- a protocol requirement distinguishes an ordered preference from a strict minimum or prohibition; +- security policies are always required. + +An implementation either establishes the guarantee or returns an error. An implementation with +coarser behavior can satisfy a requirement only when the coarse behavior still implies the stated +guarantee. For example, retiring a connection earlier than a configured maximum lifetime is valid; +retiring it later is not. + +Portable preferences are a separate concept. If introduced, construction returns a resolution +report containing every unmet or coarsened preference. A required option never degrades through the +preference mechanism. + +## Constraint composition + +Multiple callers may contribute requirements to one builder. Setters merge constraints instead of +overwriting prior values. + +Monotonic constraints combine naturally: + +- maximum ages and connection counts take the lowest bound; +- minimum protocol or security constraints take the strongest compatible bound; +- allowed sets intersect; +- preferred orderings combine only when they do not violate requirements. + +Singleton resources require agreement. Two equivalent client-certificate sources are one +requirement; two distinct required sources conflict unless the policy explicitly scopes selection. +Errors identify the conflicting requirements and which configuration layer supplied them. + +There is no unrestricted "application wins" or "library wins" precedence. A later caller can +tighten a requirement. Weakening or replacing it requires an explicit API that proves the earlier +owner allowed replacement. + +## Client-certificate authentication + +The builder names one client credential per applicable destination scope: + +```rust,ignore +builder.client_certificate(ClientCredentialId::new("service-client")) +``` + +`ClientCredentialId` is a stable logical role, not a thumbprint, subject name, file path, or store +location. Those values identify a particular provisioning mechanism or certificate generation and +would force library code to understand deployment details. A logical identifier remains stable +through certificate rotation and across operating systems. + +The application supplies a certificate catalog when constructing the transport and binds logical +identifiers to transport-native sources: + +```rust,ignore +let transport = fetch::transport::hyper(runtime) + .rustls(rustls) + .client_certificates( + ClientCertificateCatalog::new() + .bind_windows_store("service-client", service_selector), + ); +``` + +Concrete transport builders expose the binding forms they can consume. Rustls can bind key material, +a Windows-store selector, or a signing provider. Native TLS can bind a materialized platform +identity. WinHTTP can bind a Windows-store selector or imported key material. These forms are not +part of the portable `HttpClientBuilder` API. + +Every supported transport implements named client-certificate authentication, so source modality +does not require a capability trait. A missing identifier or a binding that cannot be materialized +is a construction-time provisioning error, analogous to a missing named credential. + +Catalog entries may represent one certificate or an ordered set used for rotation. Rustls receives +signature schemes and acceptable issuer distinguished names during its handshake and can select a +compatible entry without exposing an identity unnecessarily. WinHTTP reports that a client +certificate is needed and exposes the server issuer list before the request is retried, enabling +the same selection. The current native-TLS API accepts one identity on the connector, so its +catalog must select during construction and rotation requires rebuilding the client. + +Certificate discovery is fallible and can expose sensitive metadata. Transport builders may list +registered logical identifiers and sanitized public-certificate descriptors for diagnostics. +Libraries select a known logical identifier rather than enumerating certificates and inventing +selection policy. + +Two different required identifiers for the same destination conflict. Rebinding an identifier is +an application composition operation and is not available to a library after the transport builder +has been handed off. + +## Connection lifetime + +Connection maximum lifetime is portable because the observable contract is portable even though +pool implementations differ. + +Hyper records connection age and prevents an over-age connection from serving a new request. +WinHTTP marks the connection serving a request for retirement with +`WINHTTP_OPTION_EXPIRE_CONNECTION` when its age reaches the configured bound. Both satisfy the +same upper-bound contract. + +Idle-age policy is also expressed as a bound, but supported values differ. Hyper can enforce the +configured bound in its pool. WinHTTP can shorten its native idle behavior and can retain HTTP/2 +connections with keep-alive PINGs, but cannot guarantee every longer HTTP/1.1 retention request. +The WinHTTP transport accepts values for which it can prove the portable contract and rejects the +rest. + +Fine-grained HTTP/2 PING interval, acknowledgement timeout, and pool-poisoning settings remain +Hyper-specific because they configure mechanisms rather than portable outcomes. + +## Data-path tuning policy + +The initial API does not expose the inherited socket and HTTP/2 tuning knobs. + +| Mechanism | Policy | +| --- | --- | +| Nagle algorithm | No caller setting. Socket-owning transports enable `TCP_NODELAY`; opaque transports must demonstrate equivalent small-write behavior. | +| HTTP/2 initial stream receive window | Transport-selected policy. Prefer a mature adaptive strategy where available; otherwise choose a validated fixed default. | +| Socket receive and send buffers | Leave to operating-system defaults and autotuning. | +| Initial TCP congestion window | Leave to the operating system and network policy. | + +The Nagle decision is an invariant because ACK-dependent delays harm request headers, small +streaming bodies, HTTP/2 control frames, and multiplexed RPC traffic. Protocol-aware write +coalescing remains desirable, but it occurs before TCP and does not replace `TCP_NODELAY`. +WinHTTP does not expose the socket setting, so its conformance is behavioral: a calibrated probe +shows its HTTP/1.1 upload path tracking a `TCP_NODELAY` control rather than a Nagle control. This is +retained as regression evidence, not treated as a documented WinHTTP guarantee. + +An HTTP/2 stream window is a receiver memory-and-throughput policy, not a service guarantee. A +small window can make a high-bandwidth, high-latency response RTT-bound; a large window grants more +outstanding data for every active stream. Hyper also offers adaptive flow control, while WinHTTP's +window-update strategy and default are OS-owned. A portable numeric setter would expose only one +piece of those policies and invite libraries to impose memory costs without knowing application +concurrency. + +`SO_RCVBUF` and `SO_SNDBUF` are kernel queue capacities, distinct from application buffers, TLS +records, TCP receive-window autotuning, and HTTP/2 flow control. Fixed values can constrain +autotuning and multiply memory consumption by connection count. Application-level buffering may +still be tuned internally to reduce I/O operation and allocation overhead. + +Initial congestion-window selection affects only connection startup, is path-dependent, and can +increase burst loss or unfairness. Pooling and HTTP/2 amortize its effect. The Windows per-socket +control is nonportable and poorly documented, and WinHTTP exposes no equivalent. + +These defaults require representative benchmarks rather than permanent configurability. A future +option needs evidence that the default causes a material problem, a precise observable contract, +and a coherent ownership model. Until then it is neither a portable builder method nor a supported +advanced transport option. + +## TLS policy + +TLS backend selection belongs to the concrete Hyper transport builder. WinHTTP always uses +SChannel. + +Portable security policy is configured through semantic requirements: + +- logical client-credential identifier; +- exact TLS server name for a request origin; +- trust anchors or platform trust; +- minimum TLS properties; +- mandatory revocation behavior. + +Each transport either enforces the policy or rejects construction. Security policy is never +approximated. + +Backend-native extension points stay on concrete builders. A raw rustls verifier, prebuilt rustls +configuration, native-TLS connector, or SChannel option is intentionally unavailable through the +portable builder. + +### Endpoint and TLS identity + +Requests ordinarily use one origin for three related purposes: + +- the network destination (`D`); +- the DNS name authenticated by TLS (`L`); +- the HTTP `Host` or `:authority` value (`H`). + +The baseline permits a library to replace only `L` for a scoped HTTPS origin: + +```rust,ignore +builder.tls_server_name( + Origin::https("localhost", service_port), + ServerName::new("tvs.prod.example")?, +) +``` + +The request remains addressed to `https://localhost:`. The transport connects to the +original host and port, authenticates `tvs.prod.example`, and sends `localhost:` as the HTTP +authority. Ports are part of the origin, routing, authority, and pool key, but not SNI or +certificate DNS-name matching. The API therefore scopes a mapping by the complete origin while +accepting only a DNS name as the replacement identity. + +Mappings are exact and fixed at client construction. They do not accept verifier callbacks, +regular expressions, certificate subjects, or alternate ports. This keeps transport mechanisms +out of library code and avoids exposing modalities that the demonstrated localhost-to-service +scenario does not need. + +Each backend lowers the same contract at its transport boundary: + +- Hyper with rustls dials `D`, supplies `L` to rustls, and preserves `H` on the request; +- Hyper with native TLS dials `D`, calls the native TLS handshake with `L`, and preserves `H`; +- WinHTTP passes `L` to `WinHttpConnect`, sets `D` through + `WINHTTP_OPTION_RESOLUTION_HOSTNAME`, and replaces `Host` with `H`. + +WinHTTP documents the resolution override and generic `Host` replacement. Current Windows +versions also translate the replacement `Host` into HTTP/2 `:authority`, as verified by the +executable backend probe, but Microsoft does not explicitly document that translation. The +backend retains an integration test and fails construction on Windows versions that lack the +resolution option. The complete documentation audit and executable evidence are recorded in the +[WinHTTP resolution-hostname experiment](../../../fetch_winhttp/docs/resolution-hostname-experiment.md). + +Authority replacement is transport-generated state, not a persistent user header. A redirected or +retried request recomputes `D`, `L`, and `H` from its effective origin and mapping; it must not +blindly carry an earlier origin's replacement `Host` value to another destination. + +Connection reuse must be partitioned by the effective tuple `(D, port, L, H)`. Two origins or TLS +identity mappings must never share a connection merely because WinHTTP or a Hyper pool would +otherwise consider their default authority equal. + +This exact-name contract intentionally does not preserve the current TVS validator's open-ended +SAN regular expressions or subject-name allowlists. Those rules can be replaced only when the +service supplies a concrete DNS identity present in its certificates. If flexible certificate +matching remains a real deployment requirement, it is a separately named custom-server-identity +capability rather than an expansion of this baseline API. + +## Growing the portable surface + +The backend inventory and decisions about which differences belong on the public builder are +maintained in the [capability matrix](capability-matrix.md). + +The initial surface has no capability traits. A new library-facing requirement is added to the +portable contract only when it has precise observable semantics and every supported transport can +implement it. Otherwise it remains application-owned transport configuration. If a future +requirement is both essential to libraries and fundamentally unavailable on a supported transport, +that concrete need—not the backend's native option shape—would justify revisiting typed capability +profiles. diff --git a/crates/fetch_winhttp/Cargo.toml b/crates/fetch_winhttp/Cargo.toml index 3b49f2e20..46412e6cf 100644 --- a/crates/fetch_winhttp/Cargo.toml +++ b/crates/fetch_winhttp/Cargo.toml @@ -25,5 +25,19 @@ default-target = "x86_64-pc-windows-msvc" [dev-dependencies] +[target.'cfg(windows)'.dev-dependencies] +anyhow = { workspace = true, features = ["std"] } +bytes = { workspace = true } +http-body-util = { workspace = true } +hyper = { workspace = true, features = ["http2", "server"] } +hyper-util = { workspace = true, features = ["tokio"] } +rcgen = { workspace = true, features = ["crypto", "ring"] } +rustls = { workspace = true, features = ["ring", "std"] } +tokio = { workspace = true, features = ["net", "rt"] } +tokio-rustls = { workspace = true, features = ["ring"] } +windows-sys = { workspace = true, features = [ + "Win32_Networking_WinHttp", +] } + [lints] workspace = true diff --git a/crates/fetch_winhttp/docs/design.md b/crates/fetch_winhttp/docs/design.md index d67c93e99..62f7d6868 100644 --- a/crates/fetch_winhttp/docs/design.md +++ b/crates/fetch_winhttp/docs/design.md @@ -168,6 +168,25 @@ arrives from the `fetch` layer at all, rather than being configured on the trans that owns the connections, is noted as `fetch` API feedback in the fetch API stabilization feedback (../../fetch/docs/stabilization.md). +### 2.3 TCP and flow-control policy + +WinHTTP owns opaque sockets and exposes no raw socket handle, socket factory, `TCP_NODELAY`, +`SO_RCVBUF`, `SO_SNDBUF`, or initial-congestion-window option. `WinHttpOptions` does not imitate +these mechanisms. + +`fetch` requires small writes to avoid Nagle/delayed-ACK stalls. A calibrated two-write experiment +shows the tested WinHTTP HTTP/1.1 upload path matching a raw `TCP_NODELAY` control rather than a +Nagle-enabled control. The transport therefore meets the behavioral invariant on the tested +platform even though WinHTTP does not document how it configures its socket. The experiment remains +regression evidence and is not presented as a Windows compatibility guarantee; the method and +measurements are recorded in the [Nagle behavior experiment](nagle-behavior-experiment.md). + +WinHTTP exposes an HTTP/2 receive-window option, but the transport leaves it unset. Window sizing +trades path throughput against outstanding data per stream and is only one part of the OS flow- +control policy. Kernel socket buffers and TCP congestion startup likewise remain at OS defaults. +Application buffering inside the transport may still reduce callback, copy, and allocation +overhead; it is independent of these kernel and protocol controls. + ## 3. HTTP protocol negotiation The transport supports HTTP/1.1, HTTP/2, and HTTP/3, all as first-class modes. Which diff --git a/crates/fetch_winhttp/docs/implementation.md b/crates/fetch_winhttp/docs/implementation.md index c4e6771ab..13a8ed90b 100644 --- a/crates/fetch_winhttp/docs/implementation.md +++ b/crates/fetch_winhttp/docs/implementation.md @@ -778,6 +778,12 @@ Gated behind `#[cfg(windows)]` and `#[cfg_attr(miri, ignore)]`, against a localhost server (a small `std::net`-based server, or `wiremock` as used elsewhere in `fetch`). These validate the real OS path end to end: +The [`WINHTTP_OPTION_RESOLUTION_HOSTNAME` feasibility probe](resolution-hostname-experiment.md) +is an executable precursor to these tests. It verifies that the network destination, TLS server +name, and HTTP authority can be controlled independently against the real OS API. The authority +case must remain covered because Microsoft documents replacing a `Host` header but does not +explicitly guarantee its observed translation to HTTP/2 `:authority`. + - GET/POST with small and large bodies; response body correctness and size. - Streaming upload (unknown length -> chunked) and streaming download; assert incremental delivery, not just final bytes. @@ -819,8 +825,16 @@ elsewhere in `fetch`). These validate the real OS path end to end: (dev-dependencies) using a self-signed cert and `accept_invalid_certs`. Assert the negotiated `Version` is HTTP/3, and separately assert the "h3 required but QUIC unreachable" path yields the expected failure (`0x2EFE`/`0x2EFD`). +- Independent endpoint identity: send a request whose public authority and loopback dial target + are `localhost:` while the TLS server name is a distinct DNS name. Assert that SNI and + certificate hostname validation use the TLS name, while HTTP/1.1 `Host` and HTTP/2 `:authority` + retain `localhost:`. Also retain the hostname-mismatch negative control. - Connection reuse: two sequential requests to the same authority reuse the connection (observable via server-side connection counting). +- Small-write latency follows the calibrated + [Nagle behavior experiment](nagle-behavior-experiment.md). Retain the raw Nagle-on and + `TCP_NODELAY` controls so a Windows or network change cannot produce a false classification. + This is regression evidence for the transport invariant, not proof of a WinHTTP socket option. - Timeout configuration is validated only structurally (unit, §7.4). Integration tests set every timeout large enough that it can never fire during a healthy run, so a tripped timeout is always a real failure, never a timing race. No @@ -1119,6 +1133,19 @@ regardless of executor liveness. In the normal case the `fetch`-level timeout still fires first and reports the canonical error; the native timer only bites when the upper layer cannot. +### 10.5 Data-path tuning + +The transport applies no socket-buffer or congestion controls. WinHTTP exposes no raw socket and no +equivalent to `SO_RCVBUF`, `SO_SNDBUF`, or per-socket initial-congestion-window selection. Similarly, +`WINHTTP_OPTION_HTTP2_RECEIVE_WINDOW` remains unset so WinHTTP owns the complete receive flow-control +policy rather than combining an application-selected stream window with an unknown OS update +strategy. + +There is no Nagle option to set. Conformance comes from the calibrated small-write integration +experiment in §7.3, which verifies behavior equivalent to the TCP transports' `TCP_NODELAY` +invariant. Application read/write buffering remains an internal implementation choice and must not +be described as a substitute for kernel or HTTP/2 flow control. + ## 11. Handling options the transport cannot honor `fetch`'s options arrive through its generic configuration surface, and callers set diff --git a/crates/fetch_winhttp/docs/nagle-behavior-experiment.md b/crates/fetch_winhttp/docs/nagle-behavior-experiment.md new file mode 100644 index 000000000..2f5930102 --- /dev/null +++ b/crates/fetch_winhttp/docs/nagle-behavior-experiment.md @@ -0,0 +1,73 @@ +# WinHTTP Nagle behavior experiment + +This experiment determines whether WinHTTP exhibits Nagle's delayed-ACK stall for consecutive +small writes. WinHTTP does not expose its socket or report `TCP_NODELAY`, so the experiment measures +observable behavior rather than querying the option. + +## Method + +A Linux receiver runs under WSL2 so it is separated from the Windows loopback fast path. Before +each measured pair it sets `TCP_QUICKACK` to zero, allowing the Linux delayed-ACK policy to operate. +The Windows client waits for connection setup, writes one byte, waits 5 ms, and writes a second +byte. The receiver measures the interval between receiving the bytes. + +Three fresh-connection cases run seven times: + +1. a raw Windows TCP socket with Nagle explicitly enabled; +2. the same raw socket with `TCP_NODELAY`; +3. two synchronous `WinHttpWriteData` calls in a fixed-length HTTP/1.1 upload. + +The raw cases calibrate the receiver and network path. The result is meaningful only if Nagle +produces a clear delayed-ACK stall while `TCP_NODELAY` preserves the intentional 5 ms spacing. + +Run the receiver: + +```text +wsl.exe -d Ubuntu-24.04 -- python3 \ + /mnt/d/repos/oxidizer-github/crates/fetch_winhttp/examples/nagle_receiver.py +``` + +Use the printed port and the WSL address from `wsl.exe hostname -I`: + +```text +$env:NAGLE_RECEIVER = ":" +cargo +1.93.0 run -p fetch_winhttp --example nagle_behavior +``` + +An attempted Windows-only receiver was not usable: the current host rejects +`SIO_TCP_SET_ACK_FREQUENCY` with `WSAEINVAL`, including on a routed interface. The retained probe +therefore requires a Linux receiver rather than silently testing on a path without controlled ACK +behavior. + +## Observed result + +```text +raw TCP, Nagle enabled: median=42.518 ms, + samples=[38.762, 42.077, 42.518, 43.257, 40.410, 43.063, 42.961] +raw TCP, TCP_NODELAY: median=4.642 ms, + samples=[5.230, 4.536, 4.532, 5.209, 4.642, 4.667, 4.561] +WinHTTP: median=5.354 ms, + samples=[5.354, 5.493, 5.019, 5.341, 4.769, 5.622, 5.493] +``` + +The calibration separates the policies by approximately 38 ms. WinHTTP tracks the +`TCP_NODELAY` control and not the Nagle control. + +A complete repeat produced medians of 42.671 ms, 4.640 ms, and 5.506 ms respectively, confirming +the separation. + +The approximately 5 ms interval also shows that WinHTTP did not retain the first byte and coalesce +both writes: in that case the receiver would observe the bytes together rather than at the +intentional spacing. Under this HTTP/1.1 upload scenario, WinHTTP sent the second small write while +the first remained unacknowledged. + +## Conclusion and limits + +WinHTTP behaves as though Nagle is disabled for this connection on the tested Windows host. The +experiment establishes the absence of a Nagle/delayed-ACK stall; it does not prove whether WinHTTP +called `setsockopt(TCP_NODELAY)` or established equivalent behavior through an internal mechanism. + +This is not a documented WinHTTP contract. The result may vary by Windows version, HTTP protocol, +TLS, proxy path, or internal connection implementation. Retaining a backend integration benchmark +can detect behavior changes, but a library cannot require `TCP_NODELAY` through the supported +WinHTTP API. diff --git a/crates/fetch_winhttp/docs/resolution-hostname-experiment.md b/crates/fetch_winhttp/docs/resolution-hostname-experiment.md new file mode 100644 index 000000000..1b6d20fbb --- /dev/null +++ b/crates/fetch_winhttp/docs/resolution-hostname-experiment.md @@ -0,0 +1,96 @@ +# WinHTTP resolution-hostname experiment + +This experiment determines whether WinHTTP can authenticate one logical DNS identity while +connecting to another host. It exercises `WINHTTP_OPTION_RESOLUTION_HOSTNAME` directly against a +local TLS server and does not change the machine certificate store or resolver configuration. + +The baseline positive case uses three observable values: + +- the WinHTTP server name is `winhttp-resolution.invalid`; +- the resolution hostname is `localhost`; +- the server listens only on a loopback address. + +The server certificate contains only `winhttp-resolution.invalid`. WinHTTP ignores the certificate's +unknown issuer for this isolated experiment, but hostname validation remains enabled. A successful +request therefore demonstrates that the resolution override reached loopback while TLS validation +used the logical server name. The positive case requires HTTP/2, and the server independently +records the ClientHello SNI and HTTP/2 `:authority`. + +A second positive case adds `Host: localhost:` with +`WinHttpAddRequestHeaders(WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE)`. Microsoft +documents this API as providing detailed control over the exact request and permits adding or +replacing well-formed headers. The documentation does not explicitly describe how `Host` is +translated for HTTP/2, so the server records the resulting `:authority`. + +A negative control connects as `localhost` to the same kind of server certificate. It must fail +with hostname validation enabled. This distinguishes the intended behavior from accidentally +disabling all certificate validation. + +Run the probe on Windows: + +```text +cargo +1.93.0 run -p fetch_winhttp --example resolution_hostname +``` + +The probe passes only when: + +1. the positive request reaches the loopback server; +2. the server observes `winhttp-resolution.invalid` as SNI; +3. WinHTTP negotiates HTTP/2; +4. the server observes the logical name in the HTTP/2 `:authority`; +5. the authority-override request retains the logical SNI but emits `localhost:` as + HTTP/2 `:authority`; +6. WinHTTP returns successful responses; and +7. the negative control fails before sending an HTTP request. + +`WINHTTP_OPTION_RESOLUTION_HOSTNAME` requires Windows 10 version 21H1 or later. An unsupported +system reports `ERROR_WINHTTP_INVALID_OPTION`; that result means this mechanism cannot be used on +that host rather than that the TLS behavior failed. + +## Observed result + +The probe passes on a supported Windows host: + +```text +positive: status=200, protocol=1, SNI=winhttp-resolution.invalid, \ +:authority=winhttp-resolution.invalid: +authority override: status=200, protocol=1, SNI=winhttp-resolution.invalid, \ +:authority=localhost: +negative: WinHTTP error=12175, SNI=localhost, HTTP request sent=false +PASS: WinHTTP resolved winhttp-resolution.invalid through localhost while using \ +winhttp-resolution.invalid for SNI and certificate hostname validation. A replacement Host header \ +independently controlled the HTTP/2 :authority. +``` + +`protocol=1` is `WINHTTP_PROTOCOL_FLAG_HTTP2`. The negative result is +`ERROR_WINHTTP_SECURE_FAILURE`; the server observes the `localhost` SNI but no HTTP request, +demonstrating that ignoring the unknown issuer did not disable hostname validation. The authority +override demonstrates that current WinHTTP translates an application-supplied `Host` header into +HTTP/2 `:authority` without changing SNI or certificate validation. This translation is verified +behavior rather than an explicit compatibility guarantee in the Microsoft documentation and +therefore requires a retained integration test. + +## Documented surface + +The WinHTTP request and option documentation provides no dedicated SNI or HTTP authority setter: + +- [`WinHttpConnect`](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpconnect) + accepts the logical server name and port. +- [`WinHttpOpenRequest`](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpopenrequest) + accepts only the resource path beneath that connection. +- [`WINHTTP_OPTION_RESOLUTION_HOSTNAME`](https://learn.microsoft.com/windows/win32/winhttp/option-flags#winhttp_option_resolution_hostname) + changes only the hostname used for DNS resolution. +- [`WINHTTP_OPTION_URL`](https://learn.microsoft.com/windows/win32/winhttp/option-flags#winhttp_option_url) + retrieves the effective URL and is not settable. +- [`WinHttpAddRequestHeaders`](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpaddrequestheaders) + and + [`WinHttpAddRequestHeadersEx`](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpaddrequestheadersex) + add or replace ordinary request headers. Neither page states how `Host` maps to HTTP/2 + `:authority`. + +The documented callback surface can report secure failures and expose a server certificate +context, and security flags can selectively disable built-in checks. It does not provide a +pre-disclosure certificate-validation callback that can substitute an arbitrary DNS identity. +Consequently the exact-name design relies on the documented logical connection and resolution +controls, plus the integration-tested `Host` translation for preserving an independently chosen +HTTP authority. diff --git a/crates/fetch_winhttp/examples/nagle_behavior.rs b/crates/fetch_winhttp/examples/nagle_behavior.rs new file mode 100644 index 000000000..7381d7358 --- /dev/null +++ b/crates/fetch_winhttp/examples/nagle_behavior.rs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Compares `WinHTTP`'s small-write behavior with calibrated Nagle controls. + +#[cfg(not(windows))] +fn main() { + eprintln!("This WinHTTP experiment only runs on Windows."); +} + +#[cfg(windows)] +fn main() -> anyhow::Result<()> { + windows::run() +} + +#[cfg(windows)] +mod windows { + use std::ffi::c_void; + use std::io::{Read, Write}; + use std::net::{SocketAddr, TcpStream}; + use std::time::Duration; + use std::{ptr, thread}; + + use anyhow::{Context, Result, anyhow, ensure}; + use windows_sys::Win32::Networking::WinHttp::{ + WINHTTP_ACCESS_TYPE_NO_PROXY, WinHttpCloseHandle, WinHttpConnect, WinHttpOpen, WinHttpOpenRequest, WinHttpReceiveResponse, + WinHttpSendRequest, WinHttpWriteData, + }; + + const TRIALS: usize = 7; + + pub(super) fn run() -> Result<()> { + let address = std::env::var("NAGLE_RECEIVER") + .context("set NAGLE_RECEIVER to the Linux receiver's IP address and port")? + .parse() + .context("NAGLE_RECEIVER must be an IP address and port")?; + for _ in 0..TRIALS { + raw_trial(address, false)?; + } + for _ in 0..TRIALS { + raw_trial(address, true)?; + } + for _ in 0..TRIALS { + winhttp_trial(address)?; + } + println!("External receiver completed all {TRIALS} trials per client."); + Ok(()) + } + + fn raw_trial(address: SocketAddr, no_delay: bool) -> Result<()> { + let mut stream = TcpStream::connect(address)?; + stream.set_nodelay(no_delay)?; + thread::sleep(Duration::from_millis(100)); + stream.write_all(b"a")?; + thread::sleep(Duration::from_millis(5)); + stream.write_all(b"b")?; + let mut completion = [0_u8; 1]; + stream.read_exact(&mut completion)?; + ensure!(completion == *b"K", "external receiver returned an invalid completion"); + Ok(()) + } + + fn winhttp_trial(address: SocketAddr) -> Result<()> { + let client = WinHttpUpload::open(address)?; + client.send_headers()?; + thread::sleep(Duration::from_millis(100)); + client.write(b"a")?; + thread::sleep(Duration::from_millis(5)); + client.write(b"b")?; + client.receive_response() + } + + struct InternetHandle(*mut c_void); + + impl InternetHandle { + fn new(handle: *mut c_void, operation: &'static str) -> Result { + if handle.is_null() { + return Err(last_error(operation)); + } + Ok(Self(handle)) + } + } + + impl Drop for InternetHandle { + fn drop(&mut self) { + // SAFETY: The handle is non-null, owned by this wrapper, and closed exactly once. + unsafe { + WinHttpCloseHandle(self.0); + } + } + } + + struct WinHttpUpload { + _session: InternetHandle, + _connection: InternetHandle, + request: InternetHandle, + } + + impl WinHttpUpload { + fn open(address: SocketAddr) -> Result { + let agent = wide("fetch-winhttp-nagle-probe"); + // SAFETY: All pointers reference valid, null-terminated UTF-16 strings for the call. + let session = unsafe { WinHttpOpen(agent.as_ptr(), WINHTTP_ACCESS_TYPE_NO_PROXY, ptr::null(), ptr::null(), 0) }; + let session = InternetHandle::new(session, "WinHttpOpen")?; + + let host = wide(&address.ip().to_string()); + // SAFETY: The session is live and the host pointer remains valid for the call. + let connection = unsafe { WinHttpConnect(session.0, host.as_ptr(), address.port(), 0) }; + let connection = InternetHandle::new(connection, "WinHttpConnect")?; + + let verb = wide("POST"); + let path = wide("/"); + // SAFETY: The connection is live and all UTF-16 pointers remain valid for the call. + let request = + unsafe { WinHttpOpenRequest(connection.0, verb.as_ptr(), path.as_ptr(), ptr::null(), ptr::null(), ptr::null(), 0) }; + let request = InternetHandle::new(request, "WinHttpOpenRequest")?; + + Ok(Self { + _session: session, + _connection: connection, + request, + }) + } + + fn send_headers(&self) -> Result<()> { + // SAFETY: The request is live and this fixed-size upload supplies no initial body. + if unsafe { WinHttpSendRequest(self.request.0, ptr::null(), 0, ptr::null_mut(), 0, 2, 0) } == 0 { + return Err(last_error("WinHttpSendRequest")); + } + Ok(()) + } + + fn write(&self, bytes: &[u8]) -> Result<()> { + let mut written = 0_u32; + // SAFETY: The request is live and the byte slice remains valid for this synchronous + // call. The output pointer refers to writable storage. + if unsafe { WinHttpWriteData(self.request.0, bytes.as_ptr().cast(), bytes.len().try_into()?, &raw mut written) } == 0 { + return Err(last_error("WinHttpWriteData")); + } + ensure!(written as usize == bytes.len(), "WinHttpWriteData performed a partial write"); + Ok(()) + } + + fn receive_response(&self) -> Result<()> { + // SAFETY: The request is live and the reserved argument must be null. + if unsafe { WinHttpReceiveResponse(self.request.0, ptr::null_mut()) } == 0 { + return Err(last_error("WinHttpReceiveResponse")); + } + Ok(()) + } + } + + fn last_error(operation: &'static str) -> anyhow::Error { + anyhow!( + "{operation} failed with Win32 error {}", + std::io::Error::last_os_error().raw_os_error().unwrap_or_default() + ) + } + + fn wide(value: &str) -> Vec { + value.encode_utf16().chain(Some(0)).collect() + } +} diff --git a/crates/fetch_winhttp/examples/nagle_receiver.py b/crates/fetch_winhttp/examples/nagle_receiver.py new file mode 100644 index 000000000..893b0ada2 --- /dev/null +++ b/crates/fetch_winhttp/examples/nagle_receiver.py @@ -0,0 +1,77 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Controlled Linux receiver for the WinHTTP Nagle behavior experiment.""" + +import socket +import statistics +import time + +TRIALS = 7 +TIMEOUT_SECONDS = 3 + + +def receive_exact(connection: socket.socket, size: int) -> bytes: + received = bytearray() + while len(received) < size: + chunk = connection.recv(size - len(received)) + if not chunk: + raise RuntimeError("connection closed before the expected data arrived") + received.extend(chunk) + return bytes(received) + + +def receive_http_headers(connection: socket.socket) -> None: + tail = bytearray() + while tail[-4:] != b"\r\n\r\n": + tail.extend(receive_exact(connection, 1)) + if len(tail) > 64 * 1024: + raise RuntimeError("HTTP headers exceeded 64 KiB") + + +def run_trial(listener: socket.socket, is_http: bool) -> float: + connection, _ = listener.accept() + with connection: + connection.settimeout(TIMEOUT_SECONDS) + if is_http: + receive_http_headers(connection) + + # TCP_QUICKACK is a transient hint. Set it immediately before the measured receive pair. + connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_QUICKACK, 0) + receive_exact(connection, 1) + first_at = time.monotonic_ns() + receive_exact(connection, 1) + elapsed_ms = (time.monotonic_ns() - first_at) / 1_000_000 + + if is_http: + connection.sendall( + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + else: + connection.sendall(b"K") + return elapsed_ms + + +def main() -> None: + cases = ( + ("raw TCP, Nagle enabled", False), + ("raw TCP, TCP_NODELAY", False), + ("WinHTTP", True), + ) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("0.0.0.0", 0)) + listener.listen() + print(listener.getsockname()[1], flush=True) + + for label, is_http in cases: + samples = [run_trial(listener, is_http) for _ in range(TRIALS)] + print( + f"{label}: median={statistics.median(samples):.3f} ms, " + f"samples={[round(sample, 3) for sample in samples]}", + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/crates/fetch_winhttp/examples/resolution_hostname.rs b/crates/fetch_winhttp/examples/resolution_hostname.rs new file mode 100644 index 000000000..26b5d9b8f --- /dev/null +++ b/crates/fetch_winhttp/examples/resolution_hostname.rs @@ -0,0 +1,580 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Probes `WinHTTP`'s separation of DNS resolution from TLS server identity. + +#[cfg(not(windows))] +fn main() { + eprintln!("This WinHTTP experiment only runs on Windows."); +} + +#[cfg(windows)] +fn main() -> anyhow::Result<()> { + windows::run() +} + +#[cfg(windows)] +mod windows { + use std::convert::Infallible; + use std::ffi::c_void; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::ptr; + use std::sync::{Arc, Mutex}; + use std::thread::{self, JoinHandle}; + use std::time::Duration; + + use anyhow::{Context, Result, anyhow, bail, ensure}; + use bytes::Bytes; + use http_body_util::Full; + use hyper::body::Incoming; + use hyper::service::service_fn; + use hyper::{Request, Response}; + use hyper_util::rt::{TokioExecutor, TokioIo}; + use rcgen::{CertifiedKey as GeneratedCertificate, generate_simple_self_signed}; + use rustls::crypto::ring::sign::any_supported_type; + use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; + use rustls::server::{ClientHello, ResolvesServerCert}; + use rustls::sign::CertifiedKey; + use rustls::{ServerConfig, ServerConnection, StreamOwned}; + use tokio_rustls::TlsAcceptor; + use windows_sys::Win32::Networking::WinHttp::{ + ERROR_WINHTTP_INVALID_OPTION, ERROR_WINHTTP_SECURE_CERT_CN_INVALID, ERROR_WINHTTP_SECURE_FAILURE, SECURITY_FLAG_IGNORE_UNKNOWN_CA, + WINHTTP_ACCESS_TYPE_NO_PROXY, WINHTTP_ADDREQ_FLAG_ADD, WINHTTP_ADDREQ_FLAG_REPLACE, WINHTTP_FLAG_SECURE, + WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL, WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED, WINHTTP_OPTION_HTTP_PROTOCOL_USED, + WINHTTP_OPTION_RESOLUTION_HOSTNAME, WINHTTP_OPTION_SECURITY_FLAGS, WINHTTP_PROTOCOL_FLAG_HTTP2, WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_QUERY_STATUS_CODE, WinHttpAddRequestHeaders, WinHttpCloseHandle, WinHttpConnect, WinHttpOpen, WinHttpOpenRequest, + WinHttpQueryHeaders, WinHttpQueryOption, WinHttpReceiveResponse, WinHttpSendRequest, WinHttpSetOption, + }; + + const LOGICAL_HOST: &str = "winhttp-resolution.invalid"; + const RESOLUTION_HOST: &str = "localhost"; + + pub(super) fn run() -> Result<()> { + rustls::crypto::ring::default_provider() + .install_default() + .map_err(|provider| anyhow!("a rustls crypto provider is already installed: {provider:?}"))?; + + let positive = run_positive_case()?; + println!( + "positive: status={}, protocol={}, SNI={}, :authority={}", + positive.status, + positive.protocol, + positive.sni.as_deref().unwrap_or(""), + positive.http_authority.as_deref().unwrap_or("") + ); + + ensure!(positive.status == 200, "positive request returned HTTP {}", positive.status); + ensure!( + positive.protocol == WINHTTP_PROTOCOL_FLAG_HTTP2, + "positive request did not negotiate HTTP/2" + ); + ensure!( + positive.sni.as_deref() == Some(LOGICAL_HOST), + "positive request sent unexpected SNI" + ); + ensure!( + positive + .http_authority + .as_deref() + .is_some_and(|authority| authority.starts_with(LOGICAL_HOST)), + "positive request sent unexpected HTTP/2 :authority" + ); + + let authority_override = run_authority_override_case()?; + println!( + "authority override: status={}, protocol={}, SNI={}, :authority={}", + authority_override.status, + authority_override.protocol, + authority_override.sni.as_deref().unwrap_or(""), + authority_override.http_authority.as_deref().unwrap_or("") + ); + ensure!( + authority_override.status == 200 && authority_override.protocol == WINHTTP_PROTOCOL_FLAG_HTTP2, + "authority-override request did not complete over HTTP/2" + ); + ensure!( + authority_override.sni.as_deref() == Some(LOGICAL_HOST), + "authority-override request changed the TLS SNI" + ); + ensure!( + authority_override + .http_authority + .as_deref() + .is_some_and(|authority| authority.starts_with(RESOLUTION_HOST)), + "Host replacement did not become the HTTP/2 :authority" + ); + + let negative = run_negative_case()?; + println!( + "negative: WinHTTP error={}, SNI={}, HTTP request sent={}", + negative.winhttp_error, + negative.sni.as_deref().unwrap_or(""), + negative.http_authority.is_some() + ); + + ensure!( + matches!( + negative.winhttp_error, + ERROR_WINHTTP_SECURE_CERT_CN_INVALID | ERROR_WINHTTP_SECURE_FAILURE + ), + "negative control failed with unexpected WinHTTP error {}", + negative.winhttp_error + ); + ensure!( + negative.sni.as_deref() == Some(RESOLUTION_HOST), + "negative control sent unexpected SNI" + ); + ensure!( + negative.http_authority.is_none(), + "negative control sent an HTTP request despite hostname validation failure" + ); + + println!( + "PASS: WinHTTP resolved {LOGICAL_HOST} through {RESOLUTION_HOST} while using \ + {LOGICAL_HOST} for SNI and certificate hostname validation. A replacement Host header \ + independently controlled the HTTP/2 :authority." + ); + Ok(()) + } + + fn run_positive_case() -> Result { + let server = Http2TestServer::start(LOGICAL_HOST)?; + let client = WinHttpClient::open()?; + let response = client + .get(LOGICAL_HOST, server.port(), Some(RESOLUTION_HOST), None, true) + .context("positive WinHTTP request failed")?; + drop(client); + let observation = server.join()?; + + Ok(PositiveResult { + status: response.status, + protocol: response.protocol, + sni: observation.sni, + http_authority: observation.http_authority, + }) + } + + fn run_authority_override_case() -> Result { + let server = Http2TestServer::start(LOGICAL_HOST)?; + let authority = format!("{RESOLUTION_HOST}:{}", server.port()); + let client = WinHttpClient::open()?; + let response = client + .get(LOGICAL_HOST, server.port(), Some(RESOLUTION_HOST), Some(&authority), true) + .context("authority-override WinHTTP request failed")?; + drop(client); + let observation = server.join()?; + + Ok(PositiveResult { + status: response.status, + protocol: response.protocol, + sni: observation.sni, + http_authority: observation.http_authority, + }) + } + + fn run_negative_case() -> Result { + let server = TestServer::start(LOGICAL_HOST)?; + let client = WinHttpClient::open()?; + let error = client + .get(RESOLUTION_HOST, server.port(), None, None, false) + .expect_err("hostname mismatch unexpectedly succeeded"); + let winhttp_error = error + .downcast_ref::() + .context("negative control did not return a WinHTTP error")? + .code; + let observation = server.join()?; + + Ok(NegativeResult { + winhttp_error, + sni: observation.sni, + http_authority: observation.http_authority, + }) + } + + struct PositiveResult { + status: u32, + protocol: u32, + sni: Option, + http_authority: Option, + } + + struct NegativeResult { + winhttp_error: u32, + sni: Option, + http_authority: Option, + } + + #[derive(Default)] + struct Observation { + sni: Option, + http_authority: Option, + } + + struct TestServer { + port: u16, + thread: JoinHandle>, + } + + impl TestServer { + fn start(certificate_name: &str) -> Result { + let observed_sni = Arc::new(Mutex::new(None)); + let config = server_config(certificate_name, Arc::clone(&observed_sni), Vec::new())?; + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + + let thread = thread::spawn(move || serve_one(&listener, config, &observed_sni)); + Ok(Self { port, thread }) + } + + fn port(&self) -> u16 { + self.port + } + + fn join(self) -> Result { + self.thread.join().map_err(|_panic| anyhow!("TLS server thread panicked"))? + } + } + + struct Http2TestServer { + port: u16, + thread: JoinHandle>, + } + + impl Http2TestServer { + fn start(certificate_name: &str) -> Result { + let observed_sni = Arc::new(Mutex::new(None)); + let config = server_config(certificate_name, Arc::clone(&observed_sni), vec![b"h2".to_vec()])?; + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + listener.set_nonblocking(true)?; + + let thread = thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_io() + .build()? + .block_on(serve_http2(listener, config, observed_sni)) + }); + Ok(Self { port, thread }) + } + + fn port(&self) -> u16 { + self.port + } + + fn join(self) -> Result { + self.thread.join().map_err(|_panic| anyhow!("HTTP/2 TLS server thread panicked"))? + } + } + + fn server_config( + certificate_name: &str, + observed_sni: Arc>>, + alpn_protocols: Vec>, + ) -> Result { + let GeneratedCertificate { cert, signing_key } = generate_simple_self_signed(vec![certificate_name.to_owned()])?; + let private_key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der())); + let signing_key = any_supported_type(&private_key)?; + let resolver = Arc::new(RecordingResolver { + certified_key: Arc::new(CertifiedKey::new(vec![CertificateDer::from(cert.der().to_vec())], signing_key)), + observed_sni, + }); + let mut config = ServerConfig::builder().with_no_client_auth().with_cert_resolver(resolver); + config.alpn_protocols = alpn_protocols; + Ok(config) + } + + #[derive(Debug)] + struct RecordingResolver { + certified_key: Arc, + observed_sni: Arc>>, + } + + impl ResolvesServerCert for RecordingResolver { + fn resolve(&self, client_hello: ClientHello<'_>) -> Option> { + *self.observed_sni.lock().expect("SNI recorder poisoned") = client_hello.server_name().map(ToOwned::to_owned); + Some(Arc::clone(&self.certified_key)) + } + } + + fn serve_one(listener: &TcpListener, config: ServerConfig, observed_sni: &Arc>>) -> Result { + let (stream, _) = listener.accept()?; + stream.set_read_timeout(Some(Duration::from_secs(10)))?; + stream.set_write_timeout(Some(Duration::from_secs(10)))?; + + let mut tls = StreamOwned::new(ServerConnection::new(Arc::new(config))?, stream); + let mut request = Vec::new(); + let read_result = read_http_headers(&mut tls, &mut request); + + let http_authority = match read_result { + Ok(()) => { + tls.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK")?; + parse_host_header(&request) + } + Err(error) if request.is_empty() => { + eprintln!("server observed TLS termination before HTTP: {error}"); + None + } + Err(error) => return Err(error), + }; + + let sni = observed_sni.lock().expect("SNI recorder poisoned").clone(); + Ok(Observation { sni, http_authority }) + } + + async fn serve_http2(listener: TcpListener, config: ServerConfig, observed_sni: Arc>>) -> Result { + let listener = tokio::net::TcpListener::from_std(listener)?; + let (stream, _) = listener.accept().await?; + let tls = TlsAcceptor::from(Arc::new(config)).accept(stream).await?; + let observed_authority = Arc::new(Mutex::new(None)); + let service_authority = Arc::clone(&observed_authority); + let service = service_fn(move |request: Request| { + *service_authority.lock().expect("HTTP/2 authority recorder poisoned") = request.uri().authority().map(ToString::to_string); + async { Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"OK")))) } + }); + + hyper::server::conn::http2::Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(tls), service) + .await?; + + let sni = observed_sni.lock().expect("SNI recorder poisoned").clone(); + let http_authority = observed_authority.lock().expect("HTTP/2 authority recorder poisoned").clone(); + Ok(Observation { sni, http_authority }) + } + + fn read_http_headers(stream: &mut StreamOwned, request: &mut Vec) -> Result<()> { + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut buffer)?; + if read == 0 { + bail!("connection closed before complete HTTP headers"); + } + request.extend_from_slice(&buffer[..read]); + ensure!(request.len() <= 64 * 1024, "HTTP headers exceeded 64 KiB"); + } + Ok(()) + } + + fn parse_host_header(request: &[u8]) -> Option { + String::from_utf8_lossy(request) + .lines() + .find_map(|line| line.strip_prefix("Host: ")) + .map(ToOwned::to_owned) + } + + #[derive(Debug)] + struct WinHttpError { + operation: &'static str, + code: u32, + } + + impl std::fmt::Display for WinHttpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} failed with Win32 error {}", self.operation, self.code) + } + } + + impl std::error::Error for WinHttpError {} + + struct InternetHandle(*mut c_void); + + impl InternetHandle { + fn new(handle: *mut c_void, operation: &'static str) -> Result { + if handle.is_null() { + return Err(last_error(operation)); + } + Ok(Self(handle)) + } + } + + impl Drop for InternetHandle { + fn drop(&mut self) { + // SAFETY: The handle is non-null, owned by this wrapper, and closed exactly once here. + unsafe { + WinHttpCloseHandle(self.0); + } + } + } + + struct WinHttpClient { + session: InternetHandle, + } + + #[derive(Debug)] + struct WinHttpResponse { + status: u32, + protocol: u32, + } + + impl WinHttpClient { + fn open() -> Result { + let agent = wide("fetch-winhttp-resolution-hostname-probe"); + // SAFETY: All pointers reference valid, null-terminated UTF-16 strings for the call. + let session = unsafe { WinHttpOpen(agent.as_ptr(), WINHTTP_ACCESS_TYPE_NO_PROXY, ptr::null(), ptr::null(), 0) }; + Ok(Self { + session: InternetHandle::new(session, "WinHttpOpen")?, + }) + } + + fn get( + &self, + server_name: &str, + port: u16, + resolution_hostname: Option<&str>, + http_host: Option<&str>, + require_http2: bool, + ) -> Result { + let server_name = wide(server_name); + // SAFETY: The session is live and the server-name pointer is valid for the call. + let connection = unsafe { WinHttpConnect(self.session.0, server_name.as_ptr(), port, 0) }; + let connection = InternetHandle::new(connection, "WinHttpConnect")?; + + let verb = wide("GET"); + let path = wide("/"); + // SAFETY: The connection is live and all provided UTF-16 pointers remain valid. + let request = unsafe { + WinHttpOpenRequest( + connection.0, + verb.as_ptr(), + path.as_ptr(), + ptr::null(), + ptr::null(), + ptr::null(), + WINHTTP_FLAG_SECURE, + ) + }; + let request = InternetHandle::new(request, "WinHttpOpenRequest")?; + + let security_flags = SECURITY_FLAG_IGNORE_UNKNOWN_CA; + set_option( + &request, + WINHTTP_OPTION_SECURITY_FLAGS, + (&raw const security_flags).cast(), + size_of::().try_into()?, + "WINHTTP_OPTION_SECURITY_FLAGS", + )?; + + if require_http2 { + let protocols = WINHTTP_PROTOCOL_FLAG_HTTP2; + set_option( + &request, + WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL, + (&raw const protocols).cast(), + size_of::().try_into()?, + "WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL", + )?; + let required = 1_i32; + set_option( + &request, + WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED, + (&raw const required).cast(), + size_of::().try_into()?, + "WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED", + )?; + } + + if let Some(hostname) = resolution_hostname { + let hostname = wide(hostname); + let byte_len = hostname.len() * size_of::(); + set_option( + &request, + WINHTTP_OPTION_RESOLUTION_HOSTNAME, + hostname.as_ptr().cast(), + byte_len.try_into()?, + "WINHTTP_OPTION_RESOLUTION_HOSTNAME", + ) + .map_err(|error| { + if error + .downcast_ref::() + .is_some_and(|error| error.code == ERROR_WINHTTP_INVALID_OPTION) + { + anyhow!("WINHTTP_OPTION_RESOLUTION_HOSTNAME is unsupported on this Windows host") + } else { + error + } + })?; + } + + if let Some(host) = http_host { + set_host_header(&request, host)?; + } + + // SAFETY: The request is live; optional buffers are null because this GET has no body. + if unsafe { WinHttpSendRequest(request.0, ptr::null(), 0, ptr::null(), 0, 0, 0) } == 0 { + return Err(last_error("WinHttpSendRequest")); + } + + // SAFETY: The request is live and the reserved argument is required to be null. + if unsafe { WinHttpReceiveResponse(request.0, ptr::null_mut()) } == 0 { + return Err(last_error("WinHttpReceiveResponse")); + } + + let mut status = 0_u32; + let mut status_size = size_of::().try_into()?; + // SAFETY: The output pointers refer to initialized writable storage of the declared size. + if unsafe { + WinHttpQueryHeaders( + request.0, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + ptr::null(), + (&raw mut status).cast(), + &raw mut status_size, + ptr::null_mut(), + ) + } == 0 + { + return Err(last_error("WinHttpQueryHeaders")); + } + + let protocol = query_option_u32(&request, WINHTTP_OPTION_HTTP_PROTOCOL_USED, "WINHTTP_OPTION_HTTP_PROTOCOL_USED")?; + Ok(WinHttpResponse { status, protocol }) + } + } + + fn set_host_header(request: &InternetHandle, host: &str) -> Result<()> { + let header = wide(&format!("Host: {host}")); + // SAFETY: The request is live and header is a valid null-terminated UTF-16 string. + if unsafe { + WinHttpAddRequestHeaders( + request.0, + header.as_ptr(), + u32::MAX, + WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE, + ) + } == 0 + { + return Err(last_error("WinHttpAddRequestHeaders(Host)")); + } + Ok(()) + } + + fn query_option_u32(handle: &InternetHandle, option: u32, operation: &'static str) -> Result { + let mut value = 0_u32; + let mut value_len = size_of::().try_into()?; + // SAFETY: The handle is live and the output buffer has the declared writable size. + if unsafe { WinHttpQueryOption(handle.0, option, (&raw mut value).cast(), &raw mut value_len) } == 0 { + return Err(last_error(operation)); + } + Ok(value) + } + + fn set_option(handle: &InternetHandle, option: u32, value: *const c_void, value_len: u32, operation: &'static str) -> Result<()> { + // SAFETY: The handle is live and value points to a buffer of value_len bytes for this call. + if unsafe { WinHttpSetOption(handle.0, option, value, value_len) } == 0 { + return Err(last_error(operation)); + } + Ok(()) + } + + fn last_error(operation: &'static str) -> anyhow::Error { + WinHttpError { + operation, + code: std::io::Error::last_os_error().raw_os_error().unwrap_or(0).cast_unsigned(), + } + .into() + } + + fn wide(value: &str) -> Vec { + value.encode_utf16().chain(Some(0)).collect() + } +} From f584b46039e0754532ca89cfcdd36552da1036e9 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Wed, 26 Aug 2026 12:10:44 +0200 Subject: [PATCH 2/5] docs(fetch): clarify transport extension model Require every supported transport to implement the complete portable baseline, add typed pre-build extensions for deliberate backend coupling, and make unsupported certificate policies explicit portable non-goals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c1d47b6-8039-4748-972d-20b238499d82 --- crates/fetch/docs/design/README.md | 89 ++++++++++++------- crates/fetch/docs/design/capability-matrix.md | 21 ++--- .../docs/design/transport-configuration.md | 38 ++++++-- 3 files changed, 101 insertions(+), 47 deletions(-) diff --git a/crates/fetch/docs/design/README.md b/crates/fetch/docs/design/README.md index 4abf747bf..94c4c71ad 100644 --- a/crates/fetch/docs/design/README.md +++ b/crates/fetch/docs/design/README.md @@ -19,10 +19,12 @@ Configuration is classified by semantics and ownership: constraints, certificate authentication, and portable trust policy. Every transport must honor an explicit requirement or reject client construction. 3. **Transport-specific configuration** controls mechanisms that have no portable contract. - Applications set these options on a concrete transport builder before passing it into `fetch`. - Examples include rustls verifier callbacks, WinHTTP proxy discovery, and SChannel-specific - options. A backend mechanism does not automatically deserve a public transport option; routine - flow-control, socket-buffer, and congestion tuning remains transport-owned. + Applications normally set these options on a concrete transport builder before passing it into + `fetch`. A library that deliberately supports a particular backend may inspect and modify its + registered typed transport extensions before construction. Examples include rustls verifier + callbacks, WinHTTP proxy discovery, and SChannel-specific options. A backend mechanism does not + automatically deserve a public transport option; routine flow-control, socket-buffer, and + congestion tuning remains transport-owned. The fact that a behavior is implemented by a transport does not make it transport-specific. Connection lifetime is implemented differently by Hyper and WinHTTP, but its useful contract can @@ -35,9 +37,10 @@ public-surface conclusion are in the [capability matrix](capability-matrix.md). ## Composition across application and library boundaries -`HttpClient` and `HttpClientBuilder` are concrete, transport-erased types. Every supported -transport implements the complete library-facing baseline, so retaining the transport type in the -builder would add generic complexity without preventing a demonstrated incompatibility. +`HttpClient` and `HttpClientBuilder` are concrete, transport-erased types. There are no partial +portable capability profiles: every supported transport, including external transports and test +fakes, implements the complete library-facing baseline. Retaining the transport type in the builder +would therefore add generic complexity without preventing a demonstrated incompatibility. The application configures transport-specific behavior before handing the transport to `fetch`: @@ -50,8 +53,7 @@ let builder = fetch::HttpClient::builder(transport); let client = service_library::build_client(builder)?; ``` -The library accepts any builder whose transport implements the semantic capabilities it needs. It -does not name Hyper, rustls, native TLS, or WinHTTP: +The transport-independent path does not name Hyper, rustls, native TLS, or WinHTTP: ```rust,ignore pub fn build_client( @@ -81,6 +83,31 @@ A library that requires no configuration accepts a built `HttpClient`. A library pipeline or portable transport policy accepts an `HttpClientBuilder` and builds the concrete client after applying its requirements. +### Typed transport extensions + +Some libraries intentionally integrate with one or more specific transports. The erased builder +therefore carries a type-indexed set of transport extension values until `build`. A transport +registers its public extension types, and a library may query or mutate one by its Rust type: + +```rust,ignore +let winhttp = builder + .transport_extension_mut::() + .ok_or(BuildError::WinHttpRequired)?; + +winhttp.use_integrated_proxy_discovery(true); +``` + +The presence of an extension identifies support; transport names and string comparisons are not +part of the contract. A library that supports several transports can branch over their extension +types. If a transport-specific setting is required, absence is a construction error chosen by that +library. Optional tuning may simply leave unmatched transports unchanged. + +Extensions are available only on the unbuilt builder. They are cloneable configuration values, not +access to a live handler, socket, or connection pool. Each extension type defines its own mutation +and validation rules, and the transport validates the final value during construction. Using one +creates an intentional dependency on that transport crate and provides no guarantee for other +transports; it does not enlarge the portable `fetch` contract. + ## Transport contract A transport configuration implements the complete portable contract and materializes the request @@ -122,8 +149,8 @@ connections. WinHTTP can enforce it with `WINHTTP_OPTION_EXPIRE_CONNECTION`. The the guarantee does not. When no faithful common contract exists, the option remains transport-specific. Coarse or partial -support is not silently treated as success. A weaker behavior requires a separately named portable -contract or an explicit preference API with observable resolution results. +support is not silently treated as success. A library that intentionally requires the mechanism +uses a typed transport extension and reports an unsupported-transport error when it is absent. ## Transport-owned performance policy @@ -131,10 +158,10 @@ The stable API exposes service requirements, not copies of socket and protocol-s Supported transports choose and validate defaults for HTTP flow control, kernel buffering, and congestion behavior. -Small writes are the exception because avoiding Nagle/delayed-ACK stalls is a general HTTP client -invariant rather than workload tuning. A transport that owns its sockets disables Nagle. An opaque -platform transport must demonstrate equivalent small-write behavior in an integration benchmark; -the WinHTTP probe does so for the tested HTTP/1.1 path. +Avoiding Nagle/delayed-ACK stalls is a transport invariant, not a configurable tuning choice. A +transport that owns TCP sockets disables Nagle. An opaque platform transport must demonstrate +equivalent small-write behavior in an integration benchmark; the WinHTTP probe does so for the +tested HTTP/1.1 path. HTTP/2 receive windows remain transport-owned. Their useful value depends on bandwidth-delay product, concurrent streams, response consumption, memory budget, and whether the implementation @@ -147,11 +174,11 @@ through `HttpClientBuilder`. TLS backend selection and backend-native customization are transport-specific. Portable security requirements remain on `HttpClientBuilder` because libraries may own them. -Client-certificate authentication uses a logical credential identifier. Libraries name the -credential role they require; applications bind that name to transport-native certificate sources -when constructing the transport. A Windows application may bind the name to a certificate-store -selector, while a Linux application may bind it to provisioned key material. The library does not -observe either modality. +Client-certificate authentication uses a logical credential identifier. For example, a TVS +library requests `ClientCredentialId::new("tvs-client")`. A Windows application may bind that name +to a certificate-store selector, while a Linux application binds the same name to provisioned +certificate and private-key material. The library selects the role but does not observe how the +application provides it. A named binding may represent a set of rotating certificates. Rustls and WinHTTP can select a certificate using issuer hints received during the handshake. The current native-TLS adapter must @@ -159,16 +186,18 @@ resolve the binding to one identity when constructing the connector and therefor rebuild to pick up rotation. Portable server validation is split into platform chain trust and endpoint identity. Platform -trust, hostname validation, and revocation are baseline security behavior. A library may map a -request origin to an exact TLS DNS name when the network endpoint and authenticated service name -differ. The request URI and wire authority remain unchanged; only connection establishment uses -the mapped TLS name. All supported transports can provide this contract without a custom -certificate-validation callback. - -An exact TLS-name mapping does not express arbitrary SAN patterns, subject distinguished-name -allowlists, or certificate/public-key pins. Those are separate policies and are not part of the -initial portable baseline. They should become semantic capabilities only if a service demonstrates -that a stable exact DNS identity cannot represent its requirement. +trust, hostname validation, and revocation are baseline security behavior. For example, a request +may remain addressed to `https://localhost:50042`, so the server sees `localhost:50042`, while the +builder maps that origin to the exact TLS name `tvs.prod.example`. The transport still connects to +localhost but sends `tvs.prod.example` as SNI and validates that DNS name against the certificate. +All supported transports can provide this contract without a custom validation callback. + +Arbitrary SAN patterns, subject distinguished-name allowlists, certificate/public-key pins, and +per-client custom trust roots are not portable capabilities and are explicit non-goals. WinHTTP +and the supported native-TLS path cannot safely enforce them before request headers or credentials +may be disclosed. A library that truly requires one must use a typed extension for a transport +that supports it and reject other transports. If TVS cannot use a stable exact DNS identity present +in its certificates, TVS cannot remain transport-independent under this design. A raw rustls verifier callback remains a rustls-specific mechanism. diff --git a/crates/fetch/docs/design/capability-matrix.md b/crates/fetch/docs/design/capability-matrix.md index 07c039a9b..f110264b2 100644 --- a/crates/fetch/docs/design/capability-matrix.md +++ b/crates/fetch/docs/design/capability-matrix.md @@ -18,9 +18,9 @@ the stated guarantees. | Arbitrary external signing service | Supported through rustls signing traits | Unsupported | Unsupported unless it provides a compatible Windows key handle | Keep rustls-specific until a non-Windows library use exists | | Custom verifier callback | Supported | No equivalent current `fetch_tls` API | No userspace callback | Transport-specific | | Exact TLS server-name override while preserving request authority | Connector dials the request endpoint and supplies the override to rustls | Connector dials the request endpoint and supplies the override to native TLS | `WinHttpConnect` uses the TLS name, resolution override uses the endpoint, and replaced `Host` preserves authority | Baseline | -| Custom SAN/subject server-identity policy | Enforced before application data by a verifier | No equivalent current adapter | Server certificate is queryable only after TLS negotiation; equivalent pre-disclosure enforcement is unproven | Add a semantic capability only if exact-name mapping cannot replace the TVS rules | -| Certificate or public-key pins | Implementable in a verifier | No equivalent current adapter | Certificate context is queryable after negotiation | Separate transport-specific feature until a safe portable contract exists | -| Custom trust roots | Expressible through custom rustls configuration | Not exposed by the current adapter | Uses OS trust unless additional validation is implemented | Transport-specific until a portable requirement is demonstrated | +| Custom SAN/subject server-identity policy | Enforced before application data by a verifier | No equivalent current adapter | Cannot be safely enforced before request disclosure | Explicit portable non-goal; rustls extension only | +| Certificate or public-key pins | Implementable in a verifier | No equivalent current adapter | Cannot be safely enforced before request disclosure | Explicit portable non-goal; transport extension only | +| Per-client custom trust roots | Expressible through custom rustls configuration | Not exposed by the current adapter | Uses Windows trust stores | Explicit portable non-goal; transport extension only | | TLS backend and crypto-provider selection | rustls-specific | native-TLS-specific | SChannel is fixed | Transport-specific | | Revocation | Required by the platform-verifier policy | Platform behavior | Must be enabled explicitly | Invariant, not a capability | @@ -35,14 +35,15 @@ modality a transport-construction concern rather than a library-facing capabilit | HTTP/1.1 and HTTP/2 preference | Supported | Supported | Baseline | | Strictly require HTTP/2 | Supported by Hyper's HTTP/2-only mode | Supported by enabling HTTP/2 and setting `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED` | Baseline; gRPC is a demonstrated consumer | | Initial HTTP/2 stream receive window | Fixed or adaptive policy | OS default; a fixed window option exists | Transport-owned default, not public configuration | -| HTTP/3 | Unsupported | Supported on recent Windows | Transport-specific until a library requires HTTP/3 | +| HTTP/3 | Unsupported | Supported on recent Windows | Transport-specific until Hyper and every supported transport implement it and a library requires it | | Fine-grained HTTP/2 flow control | Supported | Different partial native controls | Internal transport policy | An ordered version preference and a protocol requirement are different APIs. A transport may honor an HTTP/2 preference by falling back to HTTP/1.1, but that is not sufficient for a gRPC library that requires HTTP/2. WinHTTP can prevent fallback by combining its HTTP/2 enable flag with -`WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED`; no user-space emulation is needed. On systems that lack -the option, transport construction fails. +`WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED`; no user-space emulation is needed. The option requires +Windows 10 version 1903 or later. On an older supported host, transport construction for a strict +HTTP/2 requirement fails. The receive window is not a protocol requirement. Its optimum depends on path bandwidth and RTT, active stream count, response consumption, memory budget, and adaptive-window behavior. A numeric @@ -58,7 +59,7 @@ own defaults. | Maximum idle age | Arbitrary duration or unlimited | Shortening is supported; longer retention depends on protocol and native scavenging | Baseline with value/protocol validation | | Total connections per origin | Not provided by the current Hyper option | Native per-server cap | Baseline requirement, but Hyper needs a real total-concurrency implementation | | Maximum idle connections per host | Current Hyper `max_connections` behavior | No equivalent meaning; WinHTTP's cap is total connections | Rename and keep transport-specific | -| Coarse idle HTTP/2 health check | Supported | Supported with a native minimum interval | Avoid a trait unless a library requires this outcome independently of idle-age policy | +| Coarse idle HTTP/2 health check | Supported | Supported with a native minimum interval | Transport-owned policy; no public option | | Keep-alive interval, acknowledgement timeout, and active-only mode | Supported by Hyper | Not available at the same granularity | Transport-specific | | Multiple dispatch pools | Implemented above the transport | Can use multiple transport instances | Pipeline policy, not a transport capability | | Avoid Nagle/delayed-ACK stalls | Set `TCP_NODELAY` on owned sockets | No setter, but calibrated HTTP/1.1 measurements match `TCP_NODELAY` behavior | Transport invariant with backend regression coverage | @@ -130,9 +131,9 @@ all supported transports. Custom server identity remains outside the proposed surface. It would cover service-defined SAN patterns or known subject names only if TVS cannot migrate to an exact DNS identity. Hyper/rustls -supports that richer mechanism; the current native-TLS adapter does not, and WinHTTP has not -demonstrated equivalent enforcement before request secrets or body data may be sent. That -hypothetical need should not complicate the builder until it is demonstrated. +supports that richer mechanism; the current native-TLS adapter and WinHTTP do not. It therefore +cannot become a portable capability. A TVS library that retains it must select a supporting +transport through a typed extension and reject the others. Named client credentials, exact TLS server-name mapping, strict HTTP/2, fixed connection lifetime, connect deadline, connection limits, streaming, cancellation, and ordinary HTTP/1.1/HTTP/2 diff --git a/crates/fetch/docs/design/transport-configuration.md b/crates/fetch/docs/design/transport-configuration.md index 75df392fa..50cc04f4b 100644 --- a/crates/fetch/docs/design/transport-configuration.md +++ b/crates/fetch/docs/design/transport-configuration.md @@ -93,6 +93,29 @@ implement a library-facing baseline requirement is not a `fetch` transport. Diff supported values or operating-system availability remain construction-time validation because Rust types cannot prove those environmental facts. +### Typed transport extensions + +Erasure hides the transport from the portable API but does not make intentional backend integration +impossible. Until `build`, the builder retains cloneable, type-indexed extension values registered +by the selected transport: + +```rust,ignore +if let Some(options) = builder.transport_extension_mut::() { + options.use_integrated_proxy_discovery(true); +} +``` + +Querying an extension is the supported way to identify a transport capability outside the portable +baseline. A required extension is checked at runtime because the builder is concrete and the +application may select its transport dynamically. Libraries using this path depend on the concrete +transport crate and must define what absence means. + +Extensions contain unbuilt configuration only. They cannot expose live sockets or handlers, and +they disappear when the builder is consumed. The type's own API defines whether contributions +merge, replace, or conflict; transport construction validates the result. This keeps unchecked +`Any` downcasts and string transport identifiers out of library code while preserving the simple +non-generic builder. + ## Library-facing surface The demonstrated library requirements fit one coherent builder: @@ -340,9 +363,10 @@ otherwise consider their default authority equal. This exact-name contract intentionally does not preserve the current TVS validator's open-ended SAN regular expressions or subject-name allowlists. Those rules can be replaced only when the -service supplies a concrete DNS identity present in its certificates. If flexible certificate -matching remains a real deployment requirement, it is a separately named custom-server-identity -capability rather than an expansion of this baseline API. +service supplies a concrete DNS identity present in its certificates. Flexible matching, pinning, +and custom roots cannot be portable requirements because not every supported transport can enforce +them before disclosing a request. A transport-bound library may configure such a policy through a +typed extension and must reject transports that do not expose it. ## Growing the portable surface @@ -351,7 +375,7 @@ maintained in the [capability matrix](capability-matrix.md). The initial surface has no capability traits. A new library-facing requirement is added to the portable contract only when it has precise observable semantics and every supported transport can -implement it. Otherwise it remains application-owned transport configuration. If a future -requirement is both essential to libraries and fundamentally unavailable on a supported transport, -that concrete need—not the backend's native option shape—would justify revisiting typed capability -profiles. +implement it. Otherwise it remains transport-specific configuration, reachable by libraries only +through typed extensions. If a future requirement is essential to transport-independent libraries +but fundamentally unavailable on a supported transport, the supported transport set or this design +must change; a marker trait cannot manufacture the missing behavior. From 61906d03e1b3f4bb860d5aa8065906c51ce75496 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 28 Aug 2026 10:43:43 +0200 Subject: [PATCH 3/5] docs(fetch): split Hyper TLS composition Define fetch_hyper as a reusable TLS-neutral HTTP engine and move rustls/native-tls connector construction into dependency-isolated composition crates. Limit fetch stabilization to portable requirements and the typed config registry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c1d47b6-8039-4748-972d-20b238499d82 --- crates/fetch/docs/design/README.md | 134 ++++++++++++------ crates/fetch/docs/design/capability-matrix.md | 27 ++-- .../docs/design/transport-configuration.md | 134 ++++++++++++++---- 3 files changed, 213 insertions(+), 82 deletions(-) diff --git a/crates/fetch/docs/design/README.md b/crates/fetch/docs/design/README.md index 94c4c71ad..718226afe 100644 --- a/crates/fetch/docs/design/README.md +++ b/crates/fetch/docs/design/README.md @@ -4,8 +4,9 @@ capabilities. Applications choose a transport, while libraries can configure the portable networking behavior they require without knowing which transport the application selected. -The crate may use focused implementation crates for Hyper, TLS, and platform transports. Those -package boundaries are hidden by the supported `fetch` API. +The crate is independent of concrete HTTP and TLS implementations. Applications select focused +transport composition crates; libraries that use only portable requirements depend on `fetch` +alone. ## Configuration model @@ -19,12 +20,13 @@ Configuration is classified by semantics and ownership: constraints, certificate authentication, and portable trust policy. Every transport must honor an explicit requirement or reject client construction. 3. **Transport-specific configuration** controls mechanisms that have no portable contract. - Applications normally set these options on a concrete transport builder before passing it into - `fetch`. A library that deliberately supports a particular backend may inspect and modify its - registered typed transport extensions before construction. Examples include rustls verifier - callbacks, WinHTTP proxy discovery, and SChannel-specific options. A backend mechanism does not - automatically deserve a public transport option; routine flow-control, socket-buffer, and - congestion tuning remains transport-owned. + Applications normally set these options on a composition builder before passing the resulting + transport to `fetch`. A library that deliberately supports a particular transport setting may + use its independently versioned, dependency-light configuration crate through the builder's + typed configuration registry. Backend-typed mechanisms such as rustls verifier callbacks remain + on the backend composition crate. A mechanism does not automatically deserve public + configuration; routine flow-control, socket-buffer, and congestion tuning remains + transport-owned. The fact that a behavior is implemented by a transport does not make it transport-specific. Connection lifetime is implemented differently by Hyper and WinHTTP, but its useful contract can @@ -83,30 +85,34 @@ A library that requires no configuration accepts a built `HttpClient`. A library pipeline or portable transport policy accepts an `HttpClientBuilder` and builds the concrete client after applying its requirements. -### Typed transport extensions +### Transport configuration registry -Some libraries intentionally integrate with one or more specific transports. The erased builder -therefore carries a type-indexed set of transport extension values until `build`. A transport -registers its public extension types, and a library may query or mutate one by its Rust type: +The erased builder carries a type-indexed configuration registry until `build`. A selected +transport registers the dependency-light configuration types it supports, and a library may query +or mutate one without depending on the transport implementation: ```rust,ignore let winhttp = builder - .transport_extension_mut::() + .transport_config_mut::() .ok_or(BuildError::WinHttpRequired)?; winhttp.use_integrated_proxy_discovery(true); ``` -The presence of an extension identifies support; transport names and string comparisons are not -part of the contract. A library that supports several transports can branch over their extension -types. If a transport-specific setting is required, absence is a construction error chosen by that -library. Optional tuning may simply leave unmatched transports unchanged. +Configuration companion crates contain data and policy types only. They do not depend on Hyper, +WinHTTP FFI, a TLS implementation, or a crypto provider. Their versions and stability promises are +independent of `fetch` and the transport implementation. Presence identifies support; absence is +either ignored or reported as a construction error according to the library's requirement. -Extensions are available only on the unbuilt builder. They are cloneable configuration values, not -access to a live handler, socket, or connection pool. Each extension type defines its own mutation -and validation rules, and the transport validates the final value during construction. Using one -creates an intentional dependency on that transport crate and provides no guarantee for other -transports; it does not enlarge the portable `fetch` contract. +Registry values are available only on the unbuilt builder. They are cloneable configuration, not +access to a live handler, socket, or connection pool. Each type defines its own merge and +validation rules, and the selected transport consumes the final value during construction. + +A companion configuration crate is introduced only for a demonstrated library need. If a setting +can be expressed with the same useful semantics across multiple transports, it may instead move to +a separately versioned semantic configuration crate. If its API necessarily names rustls, +native-tls, Hyper, or WinHTTP handles, it stays on the corresponding composition crate; splitting +such a type into another crate would not isolate its dependency. ## Transport contract @@ -126,17 +132,55 @@ Runtime-selected and externally supplied transports use the same erased path. An transport is accepted only by implementing the full portable contract; partial transports do not implement `Transport`. -## Supported transports and runtimes +## Transport composition and crate boundaries + +`fetch_hyper` is the reusable TLS-neutral HTTP engine. It owns Hyper HTTP/1.1 and HTTP/2 dispatch, +pooling, connection policy, bodies, errors, and telemetry. It accepts a `Connect` service that +already produces a usable cleartext or TLS stream. + +TLS composition lives in accurately scoped crates: + +```text +fetch_hyper_rustls -> fetch_hyper + hyper-rustls + rustls +fetch_hyper_native_tls -> fetch_hyper + hyper-tls + native-tls +``` + +Each composition crate retains an application/runtime-provided network connector and backend +configuration in an unbuilt type implementing `fetch::Transport`. When `HttpClientBuilder::build` +supplies the final portable requirements, the composition crate configures TLS, SNI and ALPN, then +delegates handler construction to `fetch_hyper`. It does not duplicate the HTTP engine. +Backend-specific verifier, signer, identity, and provider types live with that composition crate. + +WinHTTP is an independent full-stack transport. `fetch_winhttp` owns its sessions, pool, SChannel +integration, and asynchronous callback bridge; it does not use `fetch_hyper`. + +Runtime integration supplies raw connectors and execution services. `fetch_m365`, for example, +adds Oxidizer runtime integration without creating another HTTP client or TLS API. + +| Crate | Responsibility | +| --- | --- | +| `fetch` | Stable client, pipeline, portable requirements, transport construction contract, and typed config registry | +| `fetch_hyper` | Reusable TLS-neutral Hyper engine | +| `fetch_hyper_rustls` | Rustls connector composition and rustls-specific mechanisms | +| `fetch_hyper_native_tls` | Native-TLS connector composition and native-tls-specific mechanisms | +| `fetch_winhttp` | Independent WinHTTP transport and SChannel integration | +| `fetch_m365` | Oxidizer runtime connectors and execution services | +| `*_config` companion | Introduced only when a library demonstrably needs dependency-light configuration after erasure | + +## Stability boundaries -The supported Hyper transport combines a connector, runtime services, a TLS backend, and -transport-specific tuning. Tokio is a supported runtime adapter in `fetch`. +Stabilizing `fetch` commits to `HttpClient`, `HttpClientBuilder`, the `Transport` construction +contract, portable requirement semantics, and the generic typed configuration-registry protocol. +It does not stabilize or re-export Hyper, WinHTTP, rustls, native-tls, or their configuration. -WinHTTP is a full-stack transport rather than a Hyper connector. It owns its sessions, connection -pool, SChannel integration, and asynchronous callback bridge. It participates in the same portable -requirements contract as Hyper while retaining its native configuration surface. +The Hyper engine, TLS composition crates, WinHTTP transport, and any dependency-light configuration +companions publish and evolve independently. A library opts into their stability and dependency +surface only by depending on them directly. Configuration that later proves useful across multiple +transports can move into a separate semantic crate without adding transport types to `fetch`. -Other runtime crates may supply connectors and execution services to supported transports without -creating a separate HTTP client API. +Moving a portable requirement type into another crate does not remove its semantic commitment from +`fetch` when the stable builder accepts it. Separate crates isolate optional configuration and +dependencies; they do not disguise baseline behavior as unstable. ## Libraries configure outcomes, not mechanisms @@ -150,7 +194,8 @@ the guarantee does not. When no faithful common contract exists, the option remains transport-specific. Coarse or partial support is not silently treated as success. A library that intentionally requires the mechanism -uses a typed transport extension and reports an unsupported-transport error when it is absent. +uses a registered companion configuration type and reports an unsupported-transport error when it +is absent. Backend-typed configuration requires an explicit dependency on the composition crate. ## Transport-owned performance policy @@ -195,9 +240,9 @@ All supported transports can provide this contract without a custom validation c Arbitrary SAN patterns, subject distinguished-name allowlists, certificate/public-key pins, and per-client custom trust roots are not portable capabilities and are explicit non-goals. WinHTTP and the supported native-TLS path cannot safely enforce them before request headers or credentials -may be disclosed. A library that truly requires one must use a typed extension for a transport -that supports it and reject other transports. If TVS cannot use a stable exact DNS identity present -in its certificates, TVS cannot remain transport-independent under this design. +may be disclosed. A library that truly requires one must depend on a supporting composition crate +and reject other transports. If TVS cannot use a stable exact DNS identity present in its +certificates, TVS cannot remain transport-independent under this design. A raw rustls verifier callback remains a rustls-specific mechanism. @@ -223,14 +268,18 @@ and transport names are stable attributes supplied by their adapters. A transport does not require callers to provide a second telemetry sink or meter. -## Features +## Dependency and feature selection -The core client and transport traits are available without selecting a runtime or TLS backend. -Features add supported runtime, transport, and TLS implementations. +`fetch` does not select a runtime, Hyper, WinHTTP, TLS backend, or crypto provider through features. +Applications select a transport by depending on a composition crate and constructing it at the +composition root. Libraries do not enable a concrete backend merely to express portable +requirements. -Feature selection never resolves ambiguity by order. When multiple TLS backends are enabled, the -application selects one on the concrete transport builder or accepts a documented preset. -Libraries do not enable a concrete backend merely to express portable requirements. +TLS features and provider dependencies remain inside their composition crates. An application +using WinHTTP does not acquire Hyper or rustls; one using Hyper with native TLS does not acquire +rustls through feature unification. A rustls-specific verifier necessarily requires +`fetch_hyper_rustls`, because hiding that real dependency behind a nominally lightweight config +crate would not improve governance or stability. ## Public API boundary @@ -238,8 +287,9 @@ Libraries do not enable a concrete backend merely to express portable requiremen available regardless of the selected supported transport. The builder does not contain transport-specific configuration or backend capability branches. -Concrete transport builders contain backend selection and native tuning. The transport interface -receives resolved portable requirements rather than a Hyper-shaped options structure. +Composition builders contain backend selection and native tuning. The transport interface receives +resolved portable requirements and registered dependency-light configuration rather than a +Hyper-shaped options structure. Building returns a concrete `HttpClient` and may fail for invalid values, unresolved named credentials, unavailable runtime resources, or an unsupported host version. Unsupported security diff --git a/crates/fetch/docs/design/capability-matrix.md b/crates/fetch/docs/design/capability-matrix.md index f110264b2..b6ed927eb 100644 --- a/crates/fetch/docs/design/capability-matrix.md +++ b/crates/fetch/docs/design/capability-matrix.md @@ -7,6 +7,9 @@ portable `fetch` API. The WinHTTP column describes the design on `u/makolnek/winhttp`; implementation work must verify the stated guarantees. +The two Hyper columns share one TLS-neutral `fetch_hyper` engine. `fetch_hyper_rustls` and +`fetch_hyper_native_tls` provide connector composition, not separate HTTP implementations. + ## TLS and client authentication | Capability | Hyper + rustls | Hyper + native TLS | WinHTTP + SChannel | Public treatment | @@ -15,13 +18,13 @@ the stated guarantees. | Named client credential | Catalog can bind key material or a signing resolver | Catalog binds a materialized native identity | Catalog can bind a store selector or imported material | Baseline | | Exportable certificate and private key binding | Supports common key encodings | Requires PKCS#8 through the current adapter | Planned import into a temporary certificate store | Transport construction | | Non-exportable Windows-store binding | Supported through a rustls signing resolver | No equivalent current API | Supported through `CERT_CONTEXT` | Transport construction | -| Arbitrary external signing service | Supported through rustls signing traits | Unsupported | Unsupported unless it provides a compatible Windows key handle | Keep rustls-specific until a non-Windows library use exists | -| Custom verifier callback | Supported | No equivalent current `fetch_tls` API | No userspace callback | Transport-specific | +| Arbitrary external signing service | Supported through rustls signing traits | Unsupported | Unsupported unless it provides a compatible Windows key handle | `fetch_hyper_rustls` composition only | +| Custom verifier callback | Supported | Unsupported | No userspace callback | `fetch_hyper_rustls` composition only | | Exact TLS server-name override while preserving request authority | Connector dials the request endpoint and supplies the override to rustls | Connector dials the request endpoint and supplies the override to native TLS | `WinHttpConnect` uses the TLS name, resolution override uses the endpoint, and replaced `Host` preserves authority | Baseline | -| Custom SAN/subject server-identity policy | Enforced before application data by a verifier | No equivalent current adapter | Cannot be safely enforced before request disclosure | Explicit portable non-goal; rustls extension only | -| Certificate or public-key pins | Implementable in a verifier | No equivalent current adapter | Cannot be safely enforced before request disclosure | Explicit portable non-goal; transport extension only | -| Per-client custom trust roots | Expressible through custom rustls configuration | Not exposed by the current adapter | Uses Windows trust stores | Explicit portable non-goal; transport extension only | -| TLS backend and crypto-provider selection | rustls-specific | native-TLS-specific | SChannel is fixed | Transport-specific | +| Custom SAN/subject server-identity policy | Enforced before application data by a verifier | Unsupported | Cannot be safely enforced before request disclosure | Explicit portable non-goal; `fetch_hyper_rustls` only | +| Certificate or public-key pins | Implementable in a verifier | Unsupported | Cannot be safely enforced before request disclosure | Explicit portable non-goal; supporting composition crate only | +| Per-client custom trust roots | Expressible through custom rustls configuration | Not exposed by the current adapter | Uses Windows trust stores | Explicit portable non-goal; supporting composition crate only | +| TLS backend and crypto-provider selection | `fetch_hyper_rustls` | `fetch_hyper_native_tls` | SChannel is fixed | Application selects a composition dependency | | Revocation | Required by the platform-verifier policy | Platform behavior | Must be enabled explicitly | Invariant, not a capability | Libraries select a stable logical client-credential identifier. Applications bind that identifier @@ -133,17 +136,17 @@ Custom server identity remains outside the proposed surface. It would cover serv patterns or known subject names only if TVS cannot migrate to an exact DNS identity. Hyper/rustls supports that richer mechanism; the current native-TLS adapter and WinHTTP do not. It therefore cannot become a portable capability. A TVS library that retains it must select a supporting -transport through a typed extension and reject the others. +transport through `fetch_hyper_rustls` and reject the others. Named client credentials, exact TLS server-name mapping, strict HTTP/2, fixed connection lifetime, connect deadline, connection limits, streaming, cancellation, and ordinary HTTP/1.1/HTTP/2 preferences belong to the baseline and do not need capability traits. -Arbitrary signers, raw verifier callbacks, key encodings, detailed keep-alive controls, HTTP/3, -proxy/WPAD, integrated authentication, phase-specific timeouts, and pool internals stay on concrete -transport builders until a library demonstrates a portable semantic requirement. HTTP/2 -flow-control sizing, socket buffers, and initial congestion are transport-owned defaults rather -than public options on either builder. +Arbitrary signers, raw verifier callbacks, and backend TLS objects stay on composition builders. +Detailed keep-alive controls, HTTP/3, proxy/WPAD, integrated authentication, phase-specific +timeouts, and pool internals stay composition-owned unless a demonstrated library need justifies a +dependency-light companion configuration crate. HTTP/2 flow-control sizing, socket buffers, and +initial congestion are transport-owned defaults rather than public options on any builder. Construction remains fallible despite the uniform surface. Invalid values, missing named credentials, unsupported operating-system versions, and unavailable resources are environmental diff --git a/crates/fetch/docs/design/transport-configuration.md b/crates/fetch/docs/design/transport-configuration.md index 50cc04f4b..91e64318f 100644 --- a/crates/fetch/docs/design/transport-configuration.md +++ b/crates/fetch/docs/design/transport-configuration.md @@ -17,7 +17,6 @@ Representative requirements include: - required and preferred HTTP protocol versions; - client-certificate authentication; - exact TLS server-name mapping; -- server-certificate trust and pinning policy, if a portable contract is later required; - cancellation and streaming guarantees. These requirements are stored separately from pipeline configuration and from the concrete @@ -93,28 +92,103 @@ implement a library-facing baseline requirement is not a `fetch` transport. Diff supported values or operating-system availability remain construction-time validation because Rust types cannot prove those environmental facts. -### Typed transport extensions +### Transport configuration registry Erasure hides the transport from the portable API but does not make intentional backend integration -impossible. Until `build`, the builder retains cloneable, type-indexed extension values registered -by the selected transport: +impossible. Until `build`, the builder retains cloneable, type-indexed configuration values +registered by the selected transport: ```rust,ignore -if let Some(options) = builder.transport_extension_mut::() { +if let Some(options) = builder.transport_config_mut::() { options.use_integrated_proxy_discovery(true); } ``` -Querying an extension is the supported way to identify a transport capability outside the portable -baseline. A required extension is checked at runtime because the builder is concrete and the -application may select its transport dynamically. Libraries using this path depend on the concrete -transport crate and must define what absence means. +Querying the registry is the supported way to identify optional configuration outside the portable +baseline. A required type is checked at runtime because the builder is concrete and the application +may select its transport dynamically. Libraries using this path depend on the configuration crate, +not the transport implementation, and must define what absence means. -Extensions contain unbuilt configuration only. They cannot expose live sockets or handlers, and -they disappear when the builder is consumed. The type's own API defines whether contributions -merge, replace, or conflict; transport construction validates the result. This keeps unchecked -`Any` downcasts and string transport identifiers out of library code while preserving the simple -non-generic builder. +Configuration crates contain no transport engine, TLS implementation, FFI binding, or crypto +provider. Their types are unbuilt values that disappear when the builder is consumed. Each type +defines whether contributions merge, replace, or conflict; transport construction validates the +result. `fetch` exposes typed accessors rather than the raw type map. + +The registry itself is the only stable `fetch` surface. Configuration types are independently +versioned. A major-version mismatch creates distinct Rust types, so lookup is fallible and errors +identify the requested type. Companion crates remain small to minimize such version churn. + +Transport-specific companion config is the default when one backend exposes a useful mechanism. +A separate semantic config crate is extracted only after multiple transports implement the same +demonstrated library-facing contract. Configuration that necessarily exposes backend types stays +with its composition crate instead of creating a second crate with the same dependency. + +## Hyper composition + +`fetch_hyper` owns the reusable HTTP engine but no TLS backend. Its connector boundary is a service +from an endpoint to a Hyper-compatible I/O stream: + +```rust,ignore +pub trait Connect: Service> + Clone +where + S: HyperIo, +{ +} +``` + +The engine applies connection deadlines and lifetime tracking around that final connector, then +hands it to Hyper for pooling and HTTP dispatch. It is invoked by a composition crate only after +the portable requirements are final. Its construction API no longer accepts a `TlsBackend`. + +```rust,ignore +let handler = fetch_hyper::build(connector, requirements, context)?; +``` + +`fetch_hyper_rustls` and `fetch_hyper_native_tls` adapt a raw runtime connector into that final +connector. They configure TLS backend policy, SNI, ALPN, certificate authentication, and +backend-specific error conversion before delegating to `fetch_hyper`. Each exposes an unbuilt +transport configuration implementing `fetch::Transport`: + +```rust,ignore +impl fetch::Transport for RustlsHyperTransport { + fn build( + self: Box, + requirements: TransportRequirements, + context: TransportContext, + ) -> Result { + let connector = self.build_tls_connector(&requirements)?; + fetch_hyper::build(connector, requirements, context) + } +} +``` + +Both composition crates materialize the same `fetch_hyper` handler; neither owns a second pool or +HTTP implementation. Deferring this work is essential because a library may add strict HTTP/2, +TLS-name mappings, or credential requirements after the application selects the transport. + +```text +raw runtime connector + | + v +unbuilt fetch_hyper_rustls or fetch_hyper_native_tls transport + | + | HttpClientBuilder::build(final requirements) + | + v +TLS connector composition + | + v +fetch_hyper connection policy and HTTP engine + | + v +Hyper HTTP/1.1 and HTTP/2 +``` + +The current `fetch_hyper::HyperTransportBuilder::build(TlsBackend)` and internal TLS connector are +split at this boundary. TLS-neutral engine construction remains in `fetch_hyper`; backend matching +and connector wrapping move to the two composition crates. The current `fetch_tls` container is +decomposed: portable requirements move to the portable requirement model, while +rustls/native-tls objects move to their respective composition crates. ## Library-facing surface @@ -200,12 +274,13 @@ The application supplies a certificate catalog when constructing the transport a identifiers to transport-native sources: ```rust,ignore -let transport = fetch::transport::hyper(runtime) - .rustls(rustls) +let transport = fetch_hyper_rustls::builder(runtime, connector) + .tls(rustls) .client_certificates( ClientCertificateCatalog::new() .bind_windows_store("service-client", service_selector), - ); + ) + .build(); ``` Concrete transport builders expose the binding forms they can consume. Rustls can bind key material, @@ -293,8 +368,8 @@ advanced transport option. ## TLS policy -TLS backend selection belongs to the concrete Hyper transport builder. WinHTTP always uses -SChannel. +TLS backend selection belongs to the application through its transport composition dependency. +WinHTTP always uses SChannel. Portable security policy is configured through semantic requirements: @@ -307,9 +382,11 @@ Portable security policy is configured through semantic requirements: Each transport either enforces the policy or rejects construction. Security policy is never approximated. -Backend-native extension points stay on concrete builders. A raw rustls verifier, prebuilt rustls -configuration, native-TLS connector, or SChannel option is intentionally unavailable through the -portable builder. +Backend-native extension points stay on composition builders. A raw rustls verifier or prebuilt +rustls configuration belongs to `fetch_hyper_rustls`; a native-TLS connector belongs to +`fetch_hyper_native_tls`; and SChannel mechanisms belong to `fetch_winhttp`. These types are +intentionally unavailable through the portable builder and do not justify separate config crates, +because their public APIs already require the backend dependency. ### Endpoint and TLS identity @@ -365,8 +442,8 @@ This exact-name contract intentionally does not preserve the current TVS validat SAN regular expressions or subject-name allowlists. Those rules can be replaced only when the service supplies a concrete DNS identity present in its certificates. Flexible matching, pinning, and custom roots cannot be portable requirements because not every supported transport can enforce -them before disclosing a request. A transport-bound library may configure such a policy through a -typed extension and must reject transports that do not expose it. +them before disclosing a request. A transport-bound library may configure such a policy through +the supporting composition crate and must reject other transports. ## Growing the portable surface @@ -375,7 +452,8 @@ maintained in the [capability matrix](capability-matrix.md). The initial surface has no capability traits. A new library-facing requirement is added to the portable contract only when it has precise observable semantics and every supported transport can -implement it. Otherwise it remains transport-specific configuration, reachable by libraries only -through typed extensions. If a future requirement is essential to transport-independent libraries -but fundamentally unavailable on a supported transport, the supported transport set or this design -must change; a marker trait cannot manufacture the missing behavior. +implement it. Otherwise it remains composition-owned or is represented by an independently +versioned, dependency-light companion configuration type when libraries demonstrate a need to +modify it after transport erasure. If a future requirement is essential to transport-independent +libraries but fundamentally unavailable on a supported transport, the supported transport set or +this design must change; a marker trait cannot manufacture the missing behavior. From 79be3884c11369814b0c7d5e5f7e35fa56baf20c Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 28 Aug 2026 11:01:26 +0200 Subject: [PATCH 4/5] docs(fetch): name shared Hyper engine Rename the proposed TLS-neutral engine to fetch_hyper_common and retain fetch_hyper only when referring to the current crate being split into common and TLS composition crates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c1d47b6-8039-4748-972d-20b238499d82 --- crates/fetch/docs/design/README.md | 16 ++++++------ crates/fetch/docs/design/capability-matrix.md | 2 +- .../docs/design/transport-configuration.md | 26 +++++++++---------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/crates/fetch/docs/design/README.md b/crates/fetch/docs/design/README.md index 718226afe..66c60282f 100644 --- a/crates/fetch/docs/design/README.md +++ b/crates/fetch/docs/design/README.md @@ -134,25 +134,25 @@ implement `Transport`. ## Transport composition and crate boundaries -`fetch_hyper` is the reusable TLS-neutral HTTP engine. It owns Hyper HTTP/1.1 and HTTP/2 dispatch, -pooling, connection policy, bodies, errors, and telemetry. It accepts a `Connect` service that -already produces a usable cleartext or TLS stream. +`fetch_hyper_common` is the reusable TLS-neutral HTTP engine. It owns Hyper HTTP/1.1 and HTTP/2 +dispatch, pooling, connection policy, bodies, errors, and telemetry. It accepts a `Connect` service +that already produces a usable cleartext or TLS stream. TLS composition lives in accurately scoped crates: ```text -fetch_hyper_rustls -> fetch_hyper + hyper-rustls + rustls -fetch_hyper_native_tls -> fetch_hyper + hyper-tls + native-tls +fetch_hyper_rustls -> fetch_hyper_common + hyper-rustls + rustls +fetch_hyper_native_tls -> fetch_hyper_common + hyper-tls + native-tls ``` Each composition crate retains an application/runtime-provided network connector and backend configuration in an unbuilt type implementing `fetch::Transport`. When `HttpClientBuilder::build` supplies the final portable requirements, the composition crate configures TLS, SNI and ALPN, then -delegates handler construction to `fetch_hyper`. It does not duplicate the HTTP engine. +delegates handler construction to `fetch_hyper_common`. It does not duplicate the HTTP engine. Backend-specific verifier, signer, identity, and provider types live with that composition crate. WinHTTP is an independent full-stack transport. `fetch_winhttp` owns its sessions, pool, SChannel -integration, and asynchronous callback bridge; it does not use `fetch_hyper`. +integration, and asynchronous callback bridge; it does not use `fetch_hyper_common`. Runtime integration supplies raw connectors and execution services. `fetch_m365`, for example, adds Oxidizer runtime integration without creating another HTTP client or TLS API. @@ -160,7 +160,7 @@ adds Oxidizer runtime integration without creating another HTTP client or TLS AP | Crate | Responsibility | | --- | --- | | `fetch` | Stable client, pipeline, portable requirements, transport construction contract, and typed config registry | -| `fetch_hyper` | Reusable TLS-neutral Hyper engine | +| `fetch_hyper_common` | Reusable TLS-neutral Hyper engine | | `fetch_hyper_rustls` | Rustls connector composition and rustls-specific mechanisms | | `fetch_hyper_native_tls` | Native-TLS connector composition and native-tls-specific mechanisms | | `fetch_winhttp` | Independent WinHTTP transport and SChannel integration | diff --git a/crates/fetch/docs/design/capability-matrix.md b/crates/fetch/docs/design/capability-matrix.md index b6ed927eb..d1bed6fee 100644 --- a/crates/fetch/docs/design/capability-matrix.md +++ b/crates/fetch/docs/design/capability-matrix.md @@ -7,7 +7,7 @@ portable `fetch` API. The WinHTTP column describes the design on `u/makolnek/winhttp`; implementation work must verify the stated guarantees. -The two Hyper columns share one TLS-neutral `fetch_hyper` engine. `fetch_hyper_rustls` and +The two Hyper columns share one TLS-neutral `fetch_hyper_common` engine. `fetch_hyper_rustls` and `fetch_hyper_native_tls` provide connector composition, not separate HTTP implementations. ## TLS and client authentication diff --git a/crates/fetch/docs/design/transport-configuration.md b/crates/fetch/docs/design/transport-configuration.md index 91e64318f..8f90c3dbe 100644 --- a/crates/fetch/docs/design/transport-configuration.md +++ b/crates/fetch/docs/design/transport-configuration.md @@ -125,8 +125,8 @@ with its composition crate instead of creating a second crate with the same depe ## Hyper composition -`fetch_hyper` owns the reusable HTTP engine but no TLS backend. Its connector boundary is a service -from an endpoint to a Hyper-compatible I/O stream: +`fetch_hyper_common` owns the reusable HTTP engine but no TLS backend. Its connector boundary is a +service from an endpoint to a Hyper-compatible I/O stream: ```rust,ignore pub trait Connect: Service> + Clone @@ -141,12 +141,12 @@ hands it to Hyper for pooling and HTTP dispatch. It is invoked by a composition the portable requirements are final. Its construction API no longer accepts a `TlsBackend`. ```rust,ignore -let handler = fetch_hyper::build(connector, requirements, context)?; +let handler = fetch_hyper_common::build(connector, requirements, context)?; ``` `fetch_hyper_rustls` and `fetch_hyper_native_tls` adapt a raw runtime connector into that final connector. They configure TLS backend policy, SNI, ALPN, certificate authentication, and -backend-specific error conversion before delegating to `fetch_hyper`. Each exposes an unbuilt +backend-specific error conversion before delegating to `fetch_hyper_common`. Each exposes an unbuilt transport configuration implementing `fetch::Transport`: ```rust,ignore @@ -157,14 +157,14 @@ impl fetch::Transport for RustlsHyperTransport { context: TransportContext, ) -> Result { let connector = self.build_tls_connector(&requirements)?; - fetch_hyper::build(connector, requirements, context) + fetch_hyper_common::build(connector, requirements, context) } } ``` -Both composition crates materialize the same `fetch_hyper` handler; neither owns a second pool or -HTTP implementation. Deferring this work is essential because a library may add strict HTTP/2, -TLS-name mappings, or credential requirements after the application selects the transport. +Both composition crates materialize the same `fetch_hyper_common` handler; neither owns a second +pool or HTTP implementation. Deferring this work is essential because a library may add strict +HTTP/2, TLS-name mappings, or credential requirements after the application selects the transport. ```text raw runtime connector @@ -178,16 +178,16 @@ unbuilt fetch_hyper_rustls or fetch_hyper_native_tls transport TLS connector composition | v -fetch_hyper connection policy and HTTP engine +fetch_hyper_common connection policy and HTTP engine | v Hyper HTTP/1.1 and HTTP/2 ``` -The current `fetch_hyper::HyperTransportBuilder::build(TlsBackend)` and internal TLS connector are -split at this boundary. TLS-neutral engine construction remains in `fetch_hyper`; backend matching -and connector wrapping move to the two composition crates. The current `fetch_tls` container is -decomposed: portable requirements move to the portable requirement model, while +The current `fetch_hyper` crate is renamed and split at this boundary. Its +`HyperTransportBuilder::build(TlsBackend)` and internal TLS connector move to the two composition +crates, while its TLS-neutral engine becomes `fetch_hyper_common`. The current `fetch_tls` +container is decomposed: portable requirements move to the portable requirement model, while rustls/native-tls objects move to their respective composition crates. ## Library-facing surface From a6310b1f7251c059115853b5b7aa6c8fbfa410b5 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 28 Aug 2026 16:45:08 +0200 Subject: [PATCH 5/5] docs(fetch): define protocol and duplex behavior Give portable HTTP/1.1 and HTTP/2 requirements precedence over WinHTTP's HTTP/3 preference, place response decompression in fetch, and define fallible trailer semantics. Add a direct WinHTTP probe demonstrating known- and unknown-length full-duplex HTTP/2 streaming and revise the implementation design around independent send and receive lanes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c1d47b6-8039-4748-972d-20b238499d82 --- Cargo.lock | 1 + crates/fetch/docs/design/README.md | 35 + crates/fetch/docs/design/capability-matrix.md | 28 +- .../docs/design/transport-configuration.md | 72 +- crates/fetch_winhttp/Cargo.toml | 4 +- crates/fetch_winhttp/docs/design.md | 66 +- .../docs/full-duplex-streaming-experiment.md | 328 ++++ crates/fetch_winhttp/docs/implementation.md | 249 ++- .../examples/full_duplex_streaming.rs | 1485 +++++++++++++++++ 9 files changed, 2098 insertions(+), 170 deletions(-) create mode 100644 crates/fetch_winhttp/docs/full-duplex-streaming-experiment.md create mode 100644 crates/fetch_winhttp/examples/full_duplex_streaming.rs diff --git a/Cargo.lock b/Cargo.lock index 23f3bbeb7..b8b6094b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1997,6 +1997,7 @@ dependencies = [ "http", "http-body", "pin-project-lite", + "tokio", ] [[package]] diff --git a/crates/fetch/docs/design/README.md b/crates/fetch/docs/design/README.md index 66c60282f..760baed9b 100644 --- a/crates/fetch/docs/design/README.md +++ b/crates/fetch/docs/design/README.md @@ -197,6 +197,18 @@ support is not silently treated as success. A library that intentionally require uses a registered companion configuration type and reports an unsupported-transport error when it is absent. Backend-typed configuration requires an explicit dependency on the composition crate. +## Protocol selection + +Portable protocol configuration constrains the common HTTP/1.1 and HTTP/2 baseline. Its default is +no caller-imposed constraint; each composition supplies its normal protocol set. An explicit +portable requirement always takes precedence over a transport preference. + +WinHTTP may expose `prefer_http3` on its composition builder. With no portable constraint, that +allows WinHTTP to try HTTP/3 and fall back to HTTP/2 or HTTP/1.1. An exact HTTP/2 requirement removes +HTTP/3 from consideration rather than conflicting with the preference. There is no transport- +specific `require_http3`; HTTP/3 becomes a portable requirement only when every supported transport +can implement it. + ## Transport-owned performance policy The stable API exposes service requirements, not copies of socket and protocol-stack knobs. @@ -214,6 +226,29 @@ uses adaptive flow control. Kernel send and receive buffers remain under operati autotuning. Initial congestion behavior remains operating-system policy. None is configurable through `HttpClientBuilder`. +## Body and content semantics + +Request and response bodies are fallible streams of data and terminal trailers. Request APIs can +attach an asynchronously produced `Result` of trailers and declare that possibility before any +network I/O. A transport that cannot send request trailers rejects such a request before polling +or transmitting its body; it never discovers the mismatch after partial disclosure. Response +trailers are surfaced as a terminal fallible body frame. + +Request trailers are intentionally not part of the universal transport baseline. Their +representation and failure semantics are stable in `fetch`, but execution remains fallible on a +transport such as WinHTTP whose native API cannot send them. + +HTTP/2 transports support full-duplex streaming: response headers and body data may arrive before +the request body completes, and upload may continue afterward. An upload or trailer failure before +response headers fails request execution. A later upload failure remains observable through the +response lifecycle rather than being discarded. Dropping either side cancels the shared request +according to the normal cancellation contract. + +Response decompression is an invariant `fetch` layer immediately above every transport, including +minimal and custom pipelines. Transports return wire-encoded bodies and do not enable native +automatic decompression. `fetch` advertises only encodings it can decode, streams decompression, +and normalizes the corresponding response headers uniformly across transports. + ## TLS and credentials TLS backend selection and backend-native customization are transport-specific. Portable security diff --git a/crates/fetch/docs/design/capability-matrix.md b/crates/fetch/docs/design/capability-matrix.md index d1bed6fee..87b662e78 100644 --- a/crates/fetch/docs/design/capability-matrix.md +++ b/crates/fetch/docs/design/capability-matrix.md @@ -35,24 +35,28 @@ modality a transport-construction concern rather than a library-facing capabilit | Capability | Hyper + either TLS backend | WinHTTP | Public treatment | | --- | --- | --- | --- | -| HTTP/1.1 and HTTP/2 preference | Supported | Supported | Baseline | +| Portable HTTP/1.1 and HTTP/2 constraints | Supported | Supported | Baseline | | Strictly require HTTP/2 | Supported by Hyper's HTTP/2-only mode | Supported by enabling HTTP/2 and setting `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED` | Baseline; gRPC is a demonstrated consumer | | Initial HTTP/2 stream receive window | Fixed or adaptive policy | OS default; a fixed window option exists | Transport-owned default, not public configuration | -| HTTP/3 | Unsupported | Supported on recent Windows | Transport-specific until Hyper and every supported transport implement it and a library requires it | +| Prefer HTTP/3 | Unsupported | Supported on recent Windows with fallback | WinHTTP composition preference; portable requirements take precedence | +| Require HTTP/3 | Unsupported | Mechanically supported | Not exposed until HTTP/3 joins the portable baseline | | Fine-grained HTTP/2 flow control | Supported | Different partial native controls | Internal transport policy | -An ordered version preference and a protocol requirement are different APIs. A transport may honor -an HTTP/2 preference by falling back to HTTP/1.1, but that is not sufficient for a gRPC library -that requires HTTP/2. WinHTTP can prevent fallback by combining its HTTP/2 enable flag with -`WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED`; no user-space emulation is needed. The option requires -Windows 10 version 1903 or later. On an older supported host, transport construction for a strict -HTTP/2 requirement fails. +Portable protocol configuration constrains HTTP/1.1 and HTTP/2 rather than ordering every protocol +a transport may implement. A gRPC library can require exact HTTP/2. WinHTTP enforces that with its +HTTP/2 enable flag and `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED`; no user-space emulation is needed. +The option requires Windows 10 version 1903 or later, older than the WinHTTP transport's supported +platform baseline. The receive window is not a protocol requirement. Its optimum depends on path bandwidth and RTT, active stream count, response consumption, memory budget, and adaptive-window behavior. A numeric cross-transport option would expose only part of that policy. Transports select and benchmark their own defaults. +The portable default imposes no protocol constraint. A WinHTTP HTTP/3 preference expands its +transport candidates but cannot override an exact HTTP/1.1 or HTTP/2 library requirement. Removing +a preference is not a conflict; no transport-specific API can require HTTP/3. + ## Connections | Capability | Hyper + either TLS backend | WinHTTP | Public treatment | @@ -97,6 +101,10 @@ Application buffers remain separate implementation details. | Connect deadline | Wraps connector establishment | Native resolve/connect controls with different phase boundaries | Baseline after defining one observable deadline | | Separate resolve/send/receive timers | Not exposed by the supported Hyper path | Native controls | Transport-specific | | Streaming request and response bodies | Supported | Planned | Required `Transport` invariant | +| Full-duplex HTTP/2 | Supported | Demonstrated on Windows 11 build 26100 | Required invariant; retain platform compatibility coverage | +| Request trailers | Supported by Hyper body frames | No public WinHTTP send API | Fallible request feature; WinHTTP rejects before sending | +| Response trailers | Supported | Queryable after body completion, including HTTP/1.1 on the supported platform | Fallible terminal response-body frame | +| Response decompression | Can be implemented natively or above transport | Native support differs by encoding | Always implemented by `fetch`; transports return encoded bodies | | Cancellation when the request future is dropped | Supported | Planned through handle closure | Required `Transport` invariant | | Plain HTTP opt-in | Pipeline request validation plus transport support | Supported | Pipeline policy | | Runtime/executor selection | Hyper requires an adapter | WinHTTP owns asynchronous I/O callbacks | Transport construction | @@ -139,8 +147,8 @@ cannot become a portable capability. A TVS library that retains it must select a transport through `fetch_hyper_rustls` and reject the others. Named client credentials, exact TLS server-name mapping, strict HTTP/2, fixed connection lifetime, -connect deadline, connection limits, streaming, cancellation, and ordinary HTTP/1.1/HTTP/2 -preferences belong to the baseline and do not need capability traits. +connect deadline, connection limits, streaming, cancellation, and HTTP/1.1/HTTP/2 constraints +belong to the baseline and do not need capability traits. Arbitrary signers, raw verifier callbacks, and backend TLS objects stay on composition builders. Detailed keep-alive controls, HTTP/3, proxy/WPAD, integrated authentication, phase-specific diff --git a/crates/fetch/docs/design/transport-configuration.md b/crates/fetch/docs/design/transport-configuration.md index 8f90c3dbe..7c1912a79 100644 --- a/crates/fetch/docs/design/transport-configuration.md +++ b/crates/fetch/docs/design/transport-configuration.md @@ -200,7 +200,7 @@ The demonstrated library requirements fit one coherent builder: | Idle lifetime | Do not reuse a connection after the configured idle age | | Connection limit | Bound total concurrent connections per origin | | Connect deadline | Bound establishment of a usable connection | -| HTTP versions | Express ordered preferences and strict protocol requirements | +| HTTP versions | Constrain the common HTTP/1.1 and HTTP/2 baseline | | Client authentication | Select a logical credential role provisioned by the application | | TLS endpoint identity | Authenticate an exact DNS name for a scoped request origin | | Pipeline behavior | Compose routing, resilience, telemetry, redaction, and response policy | @@ -225,7 +225,7 @@ Requirement types encode the guarantee: - `ConnectionLifetime::at_most(duration)` limits reuse by connection age; - `ConnectionIdleAge::at_most(duration)` limits reuse after inactivity; -- a protocol requirement distinguishes an ordered preference from a strict minimum or prohibition; +- a protocol requirement constrains the common HTTP/1.1 and HTTP/2 baseline; - security policies are always required. An implementation either establishes the guarantee or returns an error. An implementation with @@ -233,9 +233,29 @@ coarser behavior can satisfy a requirement only when the coarse behavior still i guarantee. For example, retiring a connection earlier than a configured maximum lifetime is valid; retiring it later is not. -Portable preferences are a separate concept. If introduced, construction returns a resolution -report containing every unmet or coarsened preference. A required option never degrades through the -preference mechanism. +Transport preferences are composition configuration. They may use additional protocols only where +portable requirements leave that choice open and never weaken a portable requirement. + +## Protocol resolution + +Portable protocol configuration is a constraint, not the transport's candidate list. The default +is unconstrained. Hyper compositions and WinHTTP normally supply HTTP/1.1 and HTTP/2 candidates; +WinHTTP's `prefer_http3` adds HTTP/3 ahead of its ordinary fallbacks. + +Resolution filters transport candidates through the portable constraint: + +| Portable requirement | WinHTTP preference | Effective protocols | +| --- | --- | --- | +| Unspecified | Default | HTTP/1.1 and HTTP/2 | +| Unspecified | Prefer HTTP/3 | HTTP/3 with HTTP/2 and HTTP/1.1 fallback | +| Exact HTTP/2 | Prefer HTTP/3 | HTTP/2 only | +| HTTP/1.1 or HTTP/2 | Prefer HTTP/3 | HTTP/1.1 and HTTP/2 | +| Exact HTTP/1.1 | Prefer HTTP/3 | HTTP/1.1 only | + +A preference removed by a requirement is not an error. WinHTTP lowers the resolved set into its +HTTP/2/HTTP/3 enable mask and sets `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED` only when HTTP/1.1 is +forbidden. The portable API does not expose HTTP/3 until it joins the common baseline; applications +cannot require it through a transport-specific setting. ## Constraint composition @@ -247,7 +267,7 @@ Monotonic constraints combine naturally: - maximum ages and connection counts take the lowest bound; - minimum protocol or security constraints take the strongest compatible bound; - allowed sets intersect; -- preferred orderings combine only when they do not violate requirements. +- transport preferences apply only after portable constraints are resolved. Singleton resources require agreement. Two equivalent client-certificate sources are one requirement; two distinct required sources conflict unless the policy explicitly scopes selection. @@ -327,6 +347,46 @@ rest. Fine-grained HTTP/2 PING interval, acknowledgement timeout, and pool-poisoning settings remain Hyper-specific because they configure mechanisms rather than portable outcomes. +## Streaming and trailers + +The transport request contract is full duplex for HTTP/2: request upload and response reception +make independent progress. Response headers may complete `execute` while the upload remains active. +The response retains the shared request lifetime until upload and download complete or either side +is cancelled. + +Request bodies expose whether they may produce trailers before execution. Trailer production is +asynchronous and fallible, like data-frame production. A transport validates support and framing +before opening or sending the request. If unsupported, execution returns an explicit error without +polling the body. Once response headers have been returned, any subsequent upload or trailer error +must remain observable through the response lifecycle or an explicit request-completion result. + +```rust,ignore +let body = body.with_trailers(async { + Ok(HeaderMap::from_iter([("digest", computed_digest()?)])) +}); +``` + +Response bodies yield data and a terminal `Result` of trailers. A transport preserves received +trailers for every protocol on which its platform exposes them. WinHTTP can query trailing headers +after body completion on the supported Windows baseline; its implementation must not limit that +path to HTTP/2 and HTTP/3. + +WinHTTP cannot send request trailers through its public API. A request declaring trailers therefore +fails preflight on WinHTTP. This is an explicitly fallible request feature rather than a property +silently omitted by the transport, and it is not part of the universal transport baseline. + +## Response decompression + +Transports preserve the wire response and leave native automatic decompression disabled. A +mandatory `fetch` normalization layer advertises the supported content encodings, incrementally +decodes response bodies, and removes or rewrites metadata that described the encoded +representation. Because the layer is below pipeline selection, minimal and custom pipelines have +the same behavior as the standard pipeline. + +Keeping decompression above transports prevents backend differences such as WinHTTP decoding only +gzip/deflate while another transport supports Brotli or zstd. Request compression remains explicit +caller behavior and is not implied by response decompression. + ## Data-path tuning policy The initial API does not expose the inherited socket and HTTP/2 tuning knobs. diff --git a/crates/fetch_winhttp/Cargo.toml b/crates/fetch_winhttp/Cargo.toml index 46412e6cf..4c2ae10d6 100644 --- a/crates/fetch_winhttp/Cargo.toml +++ b/crates/fetch_winhttp/Cargo.toml @@ -28,12 +28,12 @@ default-target = "x86_64-pc-windows-msvc" [target.'cfg(windows)'.dev-dependencies] anyhow = { workspace = true, features = ["std"] } bytes = { workspace = true } -http-body-util = { workspace = true } +http-body-util = { workspace = true, features = ["channel"] } hyper = { workspace = true, features = ["http2", "server"] } hyper-util = { workspace = true, features = ["tokio"] } rcgen = { workspace = true, features = ["crypto", "ring"] } rustls = { workspace = true, features = ["ring", "std"] } -tokio = { workspace = true, features = ["net", "rt"] } +tokio = { workspace = true, features = ["net", "rt", "time"] } tokio-rustls = { workspace = true, features = ["ring"] } windows-sys = { workspace = true, features = [ "Win32_Networking_WinHttp", diff --git a/crates/fetch_winhttp/docs/design.md b/crates/fetch_winhttp/docs/design.md index 62f7d6868..db75ec906 100644 --- a/crates/fetch_winhttp/docs/design.md +++ b/crates/fetch_winhttp/docs/design.md @@ -17,8 +17,9 @@ Why a WinHTTP transport: TLS stack. (Client certificates are a Schannel capability but are not exposed in v1; see §4.1.) - **OS-managed protocol stack.** HTTP/1.1, HTTP/2 and HTTP/3 negotiation, - connection pooling, keep-alive, proxy discovery and automatic gzip/deflate - decompression are handled by the OS. + connection pooling, keep-alive, and proxy discovery are handled by the OS. + Response decompression remains in `fetch` so every transport has identical + content semantics. - **Smaller dependency surface.** No rustls/aws-lc-rs/native-tls/hyper on the request path. @@ -189,16 +190,20 @@ overhead; it is independent of these kernel and protocol controls. ## 3. HTTP protocol negotiation -The transport supports HTTP/1.1, HTTP/2, and HTTP/3, all as first-class modes. Which -versions a request may use comes from `fetch`'s `TransportOptions.supported_http_versions`: +The transport normally offers HTTP/1.1 and HTTP/2. Its composition builder may enable +`prefer_http3`, which allows WinHTTP to try HTTP/3 and fall back to the normal protocols. +This is a preference, never an HTTP/3 requirement. -- The listed versions are the ones allowed. An empty list means "no preference" and uses - `fetch`'s default (HTTP/1.1 and HTTP/2). -- Listing only versions newer than HTTP/1.1 (for example HTTP/2 and/or HTTP/3 without - HTTP/1.1) disables the HTTP/1.1 fallback: if none of the required protocols can be - negotiated the request fails rather than downgrading. -- A version the transport cannot speak (`HTTP/0.9`, `HTTP/1.0`) is rejected at request - construction with an `invalid_request` error, never silently dropped. +Portable `fetch` protocol requirements take precedence. With no portable constraint, +`prefer_http3` offers HTTP/3, HTTP/2, and HTTP/1.1. An exact HTTP/2 requirement disables +HTTP/3 and sets `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED`; an HTTP/1.1 requirement likewise +disables the newer protocols. A removed preference is not a conflict or construction +error. + +The portable default is unconstrained rather than the closed set HTTP/1.1 and HTTP/2. +Without `prefer_http3`, the transport's own default still produces those two protocols. +There is no WinHTTP-specific `require_http3`; that requirement belongs in `fetch` only +after HTTP/3 joins the baseline supported by every transport. Negotiation, including ALPN, is performed by the OS during the TLS handshake; the transport does not negotiate manually. The version actually negotiated is reported on the @@ -238,14 +243,9 @@ if a concrete need appears. The OS handles several HTTP behaviors internally. The transport configures each so it behaves consistently with the rest of `fetch`: -- **Automatic decompression (always on).** The transport advertises - `Accept-Encoding: gzip, deflate`; gzip/deflate responses are transparently decoded - before the body is streamed up, with `Content-Encoding`/`Content-Length` stripped, so - callers always see a decoded body. `fetch` itself has no content decoding, so there is - no double-decode risk. No opt-out is exposed in v1, since it would only hand callers an - encoded body nothing downstream can decode. -- **Brotli/zstd.** Not decoded (the OS does not support them); such responses arrive - still-encoded with `Content-Encoding` intact and pass through verbatim. +- **Native automatic decompression is disabled.** WinHTTP returns the encoded body and + its original headers. The mandatory `fetch` normalization layer advertises supported + encodings and performs streaming decompression uniformly for every transport. - **Request-body compression.** Not performed automatically; a caller that pre-encodes its body and sets `Content-Encoding` has it sent as-is. - **Redirects are not followed.** Like `fetch_hyper` (and unlike WinHTTP's own default), @@ -258,6 +258,28 @@ behaves consistently with the rest of `fetch`: (The specific OS options behind each behavior are implementation.md §10.3.) +### 5.1 Full-duplex streaming and trailers + +For HTTP/2, the send and receive sides progress independently. Response headers and body +data may become available before request upload completes, and `WinHttpWriteData` may +continue afterward. A direct experiment demonstrates both sequential interleaving and +overlapping send/receive operations on Windows 11 build 26100; the method and compatibility +limits are recorded in the +[full-duplex streaming experiment](full-duplex-streaming-experiment.md). + +The implementation uses separate send and receive operation lanes on one request handle. +After response headers are returned, the response retains the shared request lifetime while +upload continues. A late upload failure remains visible through the response/request +completion contract rather than being dropped. Compatibility coverage must include every +supported Windows baseline because Microsoft documents concurrent send/receive support only +for "some versions of Windows." + +`fetch` request bodies can declare an asynchronous, fallible terminal trailer result before +execution. WinHTTP has no public API for sending request trailers, so such a request fails +before its body is polled or any bytes are sent. WinHTTP can query response trailers after +body completion on the supported Windows baseline, including HTTP/1.1; those trailers are +returned as the terminal fallible response-body frame. + ## 6. Timeouts and time `fetch` enforces most timeouts above the transport; WinHTTP provides native @@ -363,9 +385,9 @@ transport noise or a deterministic condition. HTTP status codes (4xx/5xx) never enter this mapping: they are successful transport outcomes carrying an error status, surfaced as `Ok(HttpResponse)`, and -any retry policy on them lives in `seatbelt` above the transport. Automatic -decompression handled by WinHTTP never surfaces as a transport error; only genuine -wire/OS failures do. +any retry policy on them lives in `seatbelt` above the transport. Response +decompression occurs above this transport in `fetch`; only genuine wire/OS failures +enter this mapping. [`RequestHandler`]: https://github.com/microsoft/oxidizer/tree/main/crates/http_extensions [WinHTTP]: https://learn.microsoft.com/en-us/windows/win32/winhttp/using-winhttp diff --git a/crates/fetch_winhttp/docs/full-duplex-streaming-experiment.md b/crates/fetch_winhttp/docs/full-duplex-streaming-experiment.md new file mode 100644 index 000000000..c058dbd47 --- /dev/null +++ b/crates/fetch_winhttp/docs/full-duplex-streaming-experiment.md @@ -0,0 +1,328 @@ +# WinHTTP full-duplex request/response streaming experiment + +This experiment determines whether native WinHTTP, on this host, can continue writing HTTP/2 +request body data with `WinHttpWriteData` while the response headers/body are already being +received with `WinHttpReceiveResponse`/`WinHttpReadData` on the same request handle. Microsoft's +concurrency documentation is ambiguous by design: + +- [Concurrency in WinHTTP](https://learn.microsoft.com/windows/win32/winhttp/concurrency-in-winhttp) + states that "in some versions of Windows, the send and receive sides of a request are separate + and may be used concurrently; an application may do a send-only operation on one thread at the + same time that another thread is performing a receive-only operation," without stating which + versions or what happens otherwise. +- [`WinHttpWriteData`](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpwritedata) + states that "when the application is sending data, it can call `WinHttpReceiveResponse` to end + the data transfer," which reads as though receiving the response forecloses further writes. + +The experiment therefore requires empirical evidence rather than a documentation reading: a +controlled local HTTP/2-over-TLS server that deliberately responds before the request body is +complete, and a client that tries every operation order the concurrency documentation could +plausibly justify. + +## Method + +The probe uses a self-signed `localhost` certificate (rustls server, `h2`-only ALPN) and three +cases, each against a fresh loopback listener: + +1. **Baseline (sequencing control).** The server reads the entire two-chunk request body before + responding, as an ordinary non-duplex handler would. This validates that the shared + `ServerObservation` timestamps genuinely distinguish "responded before the final chunk" from + "responded after it," rather than the duplex cases below being an artifact of how the harness + measures time. +2. **Sequential interleave.** The duplex server observes the first request chunk, waits 200 ms, + then sends response headers and a first response body chunk *before* the client sends its + second (final) chunk. The client, on a single thread, writes the first chunk, calls + `WinHttpReceiveResponse`, reads the first response chunk, and only then attempts a further + `WinHttpWriteData` call for the second chunk. Every operation here fully completes before the + next begins, so this case isolates whether `WinHttpReceiveResponse` itself ends the data + transfer for a still-incomplete, known-length upload - independent of the multithreading + question. +3. **Concurrent send-only/receive-only threads.** Against a second duplex server (400 ms response + delay), the client writes the first chunk, then releases two threads through a + `std::sync::Barrier`: one calls `WinHttpReceiveResponse` (a receive-only operation), the other + calls `WinHttpWriteData` for the second chunk (a send-only operation) on the very same request + handle. Both calls are timed; the case reports whether their active windows genuinely overlap in + wall-clock time, not merely whether both calls returned successfully. + +In every duplex case the server independently confirms delivery: it timestamps when it observes +each request chunk, when it hands the response to the HTTP/2 stack, and it re-reads a later +request chunk only after the response has already started flowing - all recorded in a shared +`Arc>` that the client inspects directly (not inferred from client-side +call success). The client also verifies exact response/request byte content, not just status +codes. `WinHttpSetTimeouts` bounds every blocking WinHTTP call (5 s resolve/connect, 8 s +send/receive) and every server-side frame wait is wrapped in a `tokio::time::timeout` (5 s), so an +unsupported handle state surfaces as a bounded `ERROR_WINHTTP_TIMEOUT` rather than an indefinite +hang, and is distinguishable from an explicit rejection error. + +HTTP/2 is required via `WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL` + +`WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED` in every case, and the negotiated protocol is asserted +after each response. + +Run the probe on Windows: + +```text +cargo +1.93.0 run -p fetch_winhttp --example full_duplex_streaming --all-features +``` + +### A pooling pitfall this experiment exposed + +An earlier version of this probe hung indefinitely after a successful duplex exchange while +joining the server thread. The request and connect handles were dropped, but the *session* +handle was not: WinHTTP pools HTTP/2 connections at the session level for reuse (see +`implementation.md` section 9.1 on `WinHttpConnect`/session pooling), so the underlying TCP +connection stayed open and the server's `serve_connection` future never observed a clean +shutdown. Dropping the whole session (not just the request) after each case reproduces a clean +connection close. This is itself a useful, generalizable finding for anything that authors +short-lived WinHTTP integration probes: joining a server on connection closure requires closing +the session, not only the request/connect handles. + +## Observed result + +On a supported Windows host, all three cases pass and the probe exits successfully: + +```text +baseline (sequencing control): status=200, protocol=1, response body="response-chunk-a" +sequencing control confirmed: non-duplex handling responds only after the full request body arrives. + +sequential: status=200, protocol=1, response observed before the final chunk was sent (server chunk2 not yet seen, response already sent). +sequential: WinHttpWriteData(chunk2) succeeded while the response was already flowing. +sequential: server confirmed receiving the second chunk after already responding. + +concurrent: receive-only WinHttpReceiveResponse active for 402.1833ms (result="Ok"); send-only WinHttpWriteData(chunk2) active for 249.3µs (result="Ok"); overlapping=true +concurrent: status=200, protocol=1 +concurrent: server confirmed receiving the second chunk after already responding. + +DECISIVE: after WinHttpReceiveResponse observed headers and a response body chunk for a still-incomplete upload, a further WinHttpWriteData call on the same request handle succeeded on a single thread (sequential interleave). Full-duplex request/response streaming is supported on this host. +``` + +`protocol=1` is `WINHTTP_PROTOCOL_FLAG_HTTP2` in every case. The result was reproduced across five +consecutive runs with identical outcomes (the ~400 ms receive-only window and ~200 µs send-only +window in the concurrent case varied by tens of microseconds between runs but never lost the +`overlapping=true` result). + +Two independent, decisive facts follow from this: + +1. **`WinHttpReceiveResponse` does not, by itself, end the data transfer for a still-incomplete, + known-length (`dwTotalLength`-declared) HTTP/2 upload on this host.** The client observed + response headers and a response body chunk, then successfully wrote and completed the + remaining upload on the very same request handle from the very same thread - the simplest + possible operation order. This directly resolves the ambiguity in the `WinHttpWriteData` + remark: that remark describes callers who choose to stop uploading and finalize early, not a + statement that receiving forecloses further sends in general. The corrected unknown-length + cases below establish the same result for automatic-chunking uploads. +2. **A send-only `WinHttpWriteData` call and a receive-only `WinHttpReceiveResponse` call on the + same request handle from two different threads genuinely overlap in wall-clock time and both + succeed**, directly confirming the concurrency documentation's "in some versions of Windows" + claim holds on this host. This case was not required to reach the decisive result above (the + single-thread sequential case already succeeded), but it independently corroborates the same + conclusion through the documented concurrent code path. + +No case in this probe produced a Win32-level rejection; there was no "sequential fails, escalate +to concurrent threads" branch to exercise on this host, because the simplest order already +succeeded. + +## Documented surface and remaining uncertainty + +- [`WinHttpSendRequest`](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpsendrequest) + documents `dwTotalLength` as a fixed value that "must not change between calls," used here as + the two chunks' combined length so WinHTTP can track completion without chunked encoding. +- [`WinHttpReceiveResponse`](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpreceiveresponse) + and [`WinHttpWriteData`](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpwritedata) + do not document a success/failure contract for writing more data after receiving the response + headers of a still-incomplete upload; this probe fills that gap empirically for one Windows + build. +- [Concurrency in WinHTTP](https://learn.microsoft.com/windows/win32/winhttp/concurrency-in-winhttp) + explicitly scopes the send/receive concurrency exception to "some versions of Windows" without + naming them, and does not document what happens on versions where it does not apply (a + synchronous error, silent internal serialization, or something else). + +This probe ran on Windows build 26100.9106 (Windows 11, version 24H2; the registry's +`ProductName` value on this host reports "Windows 10 Enterprise N", a known cosmetic artifact of +that key not being updated for Windows 11 - the build number is authoritative). The result is +empirical and version-specific: + +- It is not known whether earlier Windows 10 builds, other Windows 11 builds, or Windows Server + builds preserve this behavior, silently serialize the concurrent case without erroring, or + reject the sequential/concurrent write with a Win32 error such as + `ERROR_WINHTTP_INCORRECT_HANDLE_STATE` or `ERROR_WINHTTP_CONNECTION_ERROR`. +- The probe's diagnostics (exact Win32 error codes via `WinHttpError`, negotiated protocol, + server-observed byte content and timestamps, and the overlap computation in the concurrent + case) are designed to make that distinction unambiguous if re-run on a different build: a + recorded Win32 error means the order is rejected on that host; a bounded `ERROR_WINHTTP_TIMEOUT` + after full send/receive timeouts elapse means the operation hung rather than failed fast; and a + successful write whose duration matches the peer's response delay (rather than completing + quickly) would indicate silent internal serialization rather than genuine concurrency. +- Anything that depends on this capability in production should not assume it holds universally + across the Windows fleet without a compatibility check or a runtime capability probe, and should + retain an integration test (mirroring this probe) to catch a regression if a future Windows + update changes the behavior. + +## Unknown-length (`WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH`) request uploads + +The known-length result above answers a real question, but gRPC/client-streaming uploads do not +know their total size up front: a gRPC client stream sends an unbounded number of messages and +only "half-closes" the request when it decides it has no more to send. The same probe binary +therefore also determines what happens when the request body's total length is unknown. + +### A previous version of this probe used the wrong API and was invalid + +An earlier version of this probe combined the documented `WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH` +sentinel with a manually added `Transfer-Encoding: chunked` header added via +`WinHttpAddRequestHeaders`, following +[Microsoft's guidance for `WinHttpSendRequest`](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpsendrequest) +read literally as the HTTP/1.1 chunked-transfer idiom. **That combination is not the API +`fetch_winhttp_impl` uses in production, and it produced a false negative result:** +`WinHttpSendRequest` rejected `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED` alongside the header outright +(Win32 error 12190, `ERROR_WINHTTP_HTTP_PROTOCOL_MISMATCH`), and with only +`WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL` set, exactly one `WinHttpWriteData` call ever succeeded +before every later operation failed with `ERROR_WINHTTP_INVALID_SERVER_RESPONSE` (12152). **Those +results are superseded by the corrected probe below and must not be read as evidence that WinHTTP +cannot support unbounded, unknown-length HTTP/2 uploads** - they only show that this particular, +incorrect way of asking for one does not work. + +`fetch_winhttp_impl` (`crates/fetch_winhttp_impl/src/body/write.rs`'s `RequestBodyFraming` and +`WinHttpBodyWriter`, and `crates/fetch_winhttp_impl/src/request.rs`'s request lifecycle, as landed +by PR #687) instead: + +- opens the request handle with `WINHTTP_FLAG_AUTOMATIC_CHUNKING` set on `WinHttpOpenRequest` + (`convert.rs`'s `request_open_flags`) whenever the body reports no length and no `Content-Length` + header is present; +- passes `WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH` for `dwTotalLength` to `WinHttpSendRequest`, exactly + as the earlier, invalid probe did; +- **never adds a `Transfer-Encoding` header** - `RequestBodyFraming::new` rejects one outright if + the caller supplies it, because `WinHTTP` performs the chunked framing itself once + `WINHTTP_FLAG_AUTOMATIC_CHUNKING` is set, and forwarding a caller-supplied transfer coding next + to `WinHTTP`'s own framing is the classic request-smuggling primitive (RFC 9112 §6.1); +- ends the body with a single, final `WinHttpWriteData` call whose buffer pointer is `NULL` and + whose length is `0` (`WinHttpBodyWriter::end_automatic_chunking`) - not a zero-length write over + a valid-but-empty buffer pointer - and awaits its completion before calling + `WinHttpReceiveResponse`. + +PR #687's own `http2_streams_unknown_length_uploads_and_preserves_response_trailers` integration +test drives exactly this combination - `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED` set (because its +test client's supported versions are `[Version::HTTP_2]` only, and `protocol_options` requires the +protocol whenever HTTP/1.1 is excluded) together with `WINHTTP_FLAG_AUTOMATIC_CHUNKING` - against a +real local WinHTTP connection, and it passes. This probe reproduces that exact native +flag/header/total-length lowering directly, so it can also exercise the sequential/concurrent +duplex reordering PR #687's own test does not attempt. + +### Method + +The corrected unknown-length cases mirror the known-length baseline/sequential/concurrent trio +above, with two API-level differences: the request handle is opened with +`WinHttpOpenRequest(..., WINHTTP_FLAG_SECURE | WINHTTP_FLAG_AUTOMATIC_CHUNKING)` instead of +`WINHTTP_FLAG_SECURE` alone, and `WinHttpSendRequest` receives `WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH` +instead of the two chunks' combined length. `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED` is still set in +every case, matching every known-length case above and PR #687's own required-HTTP/2 test. + +1. **Baseline (sequencing control).** Identical in shape to the known-length baseline: the server + reads the entire request body - including the null-buffer terminal write - before responding. + This proves the corrected native API completes an HTTP/2-required, unknown-length upload + end-to-end, with no `Transfer-Encoding` header and no protocol-mismatch error, before the duplex + cases below reorder it. +2. **Sequential interleave.** The duplex server responds after the first chunk, exactly as the + known-length sequential case does. The client writes the first chunk, calls + `WinHttpReceiveResponse` and reads the first response chunk *before* the upload is complete - + before the second chunk or the null-buffer terminal write have been sent - then writes the + remaining chunk and performs the terminal write on the same thread. +3. **Concurrent send-only/receive-only threads.** Direct analogue of the known-length concurrent + case: a receive-only thread calls `WinHttpReceiveResponse` while a send-only thread writes the + remaining chunk and the null-buffer terminal write, released through the same `Barrier` pattern + and timed for genuine overlap the same way. + +Every case still bounds every blocking `WinHTTP` call via `WinHttpSetTimeouts` and every +server-side frame wait via a `tokio::time::timeout`, exactly as the known-length cases do. + +Run the probe on Windows (the same binary covers both the known-length and unknown-length cases): + +```text +cargo +1.93.0 run -p fetch_winhttp --example full_duplex_streaming --all-features +``` + +### Observed result + +On the same Windows host, the corrected unknown-length cases are positive and fully reproducible +across five consecutive runs with identical results every time: + +```text +unknown-length baseline (sequencing control): status=200, protocol=1, response body="response-chunk-a" +unknown-length sequencing control confirmed: WINHTTP_FLAG_AUTOMATIC_CHUNKING + WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH complete an HTTP/2-required upload end-to-end with no Transfer-Encoding header. + +unknown-length sequential: status=200, protocol=1, response observed before the final chunk was sent (server chunk2 not yet seen, response already sent). +unknown-length sequential: WinHttpWriteData(chunk2) succeeded while the response was already flowing. +unknown-length sequential: the null-buffer terminal write succeeded, ending the automatically chunked upload. +unknown-length sequential: server confirmed receiving the second chunk after already responding. + +unknown-length concurrent: receive-only WinHttpReceiveResponse active for 404.9356ms (result=Ok); send-only WinHttpWriteData(chunk2)+terminal active for 399.4µs (chunk2=Succeeded, terminal=Succeeded); overlapping=true +unknown-length concurrent: status=200, protocol=1 +unknown-length concurrent: server confirmed receiving the second chunk after already responding. + +DECISIVE (unknown length): after WinHttpReceiveResponse observed headers and a response body chunk for a still-incomplete WINHTTP_FLAG_AUTOMATIC_CHUNKING upload (no Transfer-Encoding header, dwTotalLength=WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH, HTTP/2 required), a further WinHttpWriteData call and the documented null-buffer terminal write both succeeded on the same request handle from the same thread (sequential interleave). True unbounded, gRPC-style full-duplex request/response streaming is supported on this host using the same native automatic-chunking API PR #687 uses. +``` + +The receive-only window (~400 ms) and send-only window (hundreds of microseconds) in the +concurrent case varied by tens of microseconds between runs, exactly as in the known-length +concurrent case, but `overlapping=true` and every step succeeded identically every time. + +Two independent, decisive facts follow, directly correcting the previous, invalid probe's +conclusions: + +1. **`WINHTTP_FLAG_AUTOMATIC_CHUNKING` combined with `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED` is not + mutually exclusive**, unlike the manually added `Transfer-Encoding: chunked` header the earlier + probe used. `WinHttpSendRequest` accepts `WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH` on an + automatically chunked, HTTP/2-required request without error, and the request negotiates HTTP/2 + as required. +2. **`WinHttpReceiveResponse` does not end an unknown-length upload's data transfer just because + the automatic-chunking terminal write has not been sent yet**, mirroring the known-length + result. The client observed response headers and a response body chunk for a still-incomplete, + automatically chunked upload, then successfully wrote the remaining chunk and the documented + null-buffer terminal write on the same request handle - both sequentially on one thread and + concurrently across a genuinely overlapping send-only/receive-only thread pair. Native WinHTTP + therefore does support true, unbounded gRPC-style full-duplex request/response streaming, using + the same `WINHTTP_FLAG_AUTOMATIC_CHUNKING` + `WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH` API PR #687's + `fetch_winhttp_impl` uses in production - the earlier probe's negative conclusion was an + artifact of using the wrong native API, not a genuine limitation of WinHTTP or of HTTP/2 itself. + +## Design implications for gRPC-style duplex support over WinHTTP + +- **True, unbounded gRPC/client- and bidi-streaming is supported over native WinHTTP** through + `WINHTTP_FLAG_AUTOMATIC_CHUNKING` (set on `WinHttpOpenRequest`) combined with + `WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH` (passed to `WinHttpSendRequest`), with no + `Transfer-Encoding` header ever added by the caller - exactly the API `fetch_winhttp_impl`'s + `RequestBodyFraming` and `WinHttpBodyWriter` use (PR #687). **This supersedes this document's + earlier conclusion**, which was reached with a manually added `Transfer-Encoding: chunked` header + instead of the automatic-chunking flag and found the opposite (negative) result; that earlier + finding was a consequence of using the wrong native API, not a real limitation. +- **The known-length result remains useful as a fallback for other backends or older Windows + builds**, but a WinHTTP-backed `fetch` streaming-body implementation does not need to fall back + to a concrete `dwTotalLength` ceiling to support unbounded uploads: `WINHTTP_FLAG_AUTOMATIC_CHUNKING` + gives it a genuinely unknown-length path with the same full-duplex behavior this probe already + proved for known-length uploads. +- **This result is empirical and host/version-specific**, exactly like the known-length result + above: it is not known whether other Windows builds preserve the same behavior, and any + `fetch_winhttp` implementation that relies on it should retain an integration test (mirroring + PR #687's `http2_streams_unknown_length_uploads_and_preserves_response_trailers`, and ideally this + probe's duplex reordering) to catch a regression if a future Windows update changes it. + +## Documented surface and remaining uncertainty (unknown length) + +- [`WinHttpOpenRequest`](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpopenrequest) + documents `WINHTTP_FLAG_AUTOMATIC_CHUNKING` only as enabling "automatic chunked transfer encoding + ... when the exact content length is not known," with no explicit statement of its interaction + with a negotiated or required HTTP/2 connection, nor with `WinHttpReceiveResponse` being called + while the automatically chunked upload is still open; this probe fills that gap empirically for + one Windows build. +- [`WinHttpSendRequest`](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpsendrequest) + documents `WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH` only by name and value (`0`), with the same gap. +- [`WinHttpWriteData`](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpwritedata) + does not explicitly document that a `NULL` buffer paired with a zero length is how an + automatically chunked upload ends, as opposed to a zero-length write over a valid (if empty) + buffer pointer; `fetch_winhttp_impl`'s `WinHttpBodyWriter::end_automatic_chunking` uses the + `NULL`-buffer form, and this probe reproduces that exact call rather than testing whether the two + forms are equivalent. +- This probe ran on the same Windows build 26100.9106 (Windows 11, version 24H2) as the + known-length probe above, immediately afterward in the same process, so all results in this + document share an identical environment. It is not known whether other Windows builds preserve + this exact behavior, silently serialize the concurrent case without erroring, or reject any of + these calls with a Win32 error - the same open question the known-length result above already + carries. diff --git a/crates/fetch_winhttp/docs/implementation.md b/crates/fetch_winhttp/docs/implementation.md index 13a8ed90b..19ee7a240 100644 --- a/crates/fetch_winhttp/docs/implementation.md +++ b/crates/fetch_winhttp/docs/implementation.md @@ -13,9 +13,9 @@ This is the whole design in one picture; the numbered chapters below elaborate e - **One transport instance per core.** `fetch` clones and relocates the transport per core (`Isolation::Isolated`, §3.2); each instance owns its object and event pools (§5) and one session `Arc` per pool slot. -- **One `RequestDriver` future per request** (§4.4). It owns a `RequestGuard` - bundling the request handle, its connect handle, and a session-`Arc` clone, and - rents a pooled `RequestContext` - the small slot WinHTTP calls back into (§4.1). +- **One shared request state with directional drivers** (§4.4). Setup owns the + `RequestGuard` and rents a pooled `RequestContext`; after headers are sent, independent + upload and response drivers share the guard and use separate callback slots (§4.1). - **WinHTTP drives the I/O on its own threads** (§3). The transport issues asynchronous calls and each one signals completion back to the awaiting future through an `events_once` one-shot (§3.3). A completion runs either inline on the @@ -56,6 +56,7 @@ pub(crate) trait Bindings: Send + Sync + 'static { fn query_protocol_used(&self, h: RawHandle) -> Result; // WINHTTP_OPTION_HTTP_PROTOCOL_USED fn query_data_available(&self, h: RawHandle) -> Result<()>; // async -> DATA_AVAILABLE fn read_data(&self, h: RawHandle, buf: *mut u8, len: u32) -> Result<()>; // async -> READ_COMPLETE + fn query_trailers(&self, h: RawHandle) -> Result; // WINHTTP_QUERY_FLAG_TRAILERS after EOF fn close_handle(&self, h: RawHandle); } ``` @@ -85,7 +86,8 @@ and relied on throughout §4 and §6: - The `RequestContext` must be fully populated and every borrow of it dropped **before** the async call is issued, so the completion (possibly reentrant, §2.1) has exclusive access via the context pointer. -- At most one async operation is outstanding per request handle at a time. +- At most one send operation and one receive operation are outstanding per request handle + at a time. The two directional lanes may overlap after request headers are sent. - The status callback must be registered (with the handle-close flag) and the context installed before the first async call, and each handle is closed exactly once (§4.3). @@ -106,7 +108,7 @@ crates/fetch_winhttp/ read.rs // bytesbuf_io::Read over WinHttpReadData (response) write.rs // bytesbuf_io::Write over WinHttpWriteData (request) tls.rs // WinHttpTlsConfig -> security flags - options.rs // protocol/decompression option mapping + options.rs // validated native protocol option mapping handle.rs // RAII handle wrappers (Send/Sync assertions) error.rs // Win32 -> HttpError mapping error_labels.rs // ErrorLabel constants @@ -135,11 +137,11 @@ A single request drives this WinHTTP handle chain and callback sequence: | 4 | `WinHttpOpenRequest` | sync | - | | 5 | `WinHttpSetOption`xN (incl. context), `WinHttpSetTimeouts` | sync | - | | 6 | `WinHttpSendRequest` | async | `SENDREQUEST_COMPLETE` | -| 6a| `WinHttpWriteData` (streaming body, per chunk) | async | `WRITE_COMPLETE` | -| 7 | `WinHttpReceiveResponse` | async | `HEADERS_AVAILABLE` | +| 6a| `WinHttpWriteData` (streaming body, per chunk) | async send lane | `WRITE_COMPLETE` | +| 7 | `WinHttpReceiveResponse` | async receive lane; may overlap 6a | `HEADERS_AVAILABLE` | | 8 | `WinHttpQueryHeaders` | sync (buffered) | - | -| 9 | `WinHttpQueryDataAvailable` | async | `DATA_AVAILABLE` (n bytes) | -| 10| `WinHttpReadData` | async | `READ_COMPLETE` (n bytes) then loop 9/10 until 0 | +| 9 | `WinHttpQueryDataAvailable` | async receive lane | `DATA_AVAILABLE` (n bytes) | +| 10| `WinHttpReadData` | async receive lane; may overlap 6a | `READ_COMPLETE` (n bytes) then loop 9/10 until 0 | | 11| `WinHttpCloseHandle` | sync | `HANDLE_CLOSING` (final callback) | Errors on any async step arrive as `REQUEST_ERROR` carrying a @@ -170,8 +172,9 @@ completion callbacks never block, and in return WinHTTP may invoke a callback worker. We want this - it removes a thread-pool hop on the hot path (§3.1). The callback trampoline (§4) is safe to run reentrantly because it does a small, -bounded, non-blocking amount of work: recover the `*mut RequestContext`, take the -in-flight `events_once` sender and buffer, and send the `CompletionResult`. It +bounded, non-blocking amount of work: recover the `*mut RequestContext`, identify +the directional lane, take that lane's `events_once` sender and buffer, and send +the `CompletionResult`. It performs no I/O and never waits on WinHTTP. Returning pooled memory (an `events_once` endpoint, the context `Box`, a `BytesBuf`) on a cancellation or `HANDLE_CLOSING` path is likewise non-blocking. The one heavier case - the last @@ -208,7 +211,7 @@ process-global Win32 thread pool, from which a worker dispatches our callback. T is no per-request or per-handle thread affinity: successive completions for one request can land on different workers, and we do **not** assume WinHTTP serializes callbacks per handle. Soundness rests only on "exactly one completion per async -operation" plus "one operation outstanding per handle" (§4.5), with the single +operation" plus "one operation outstanding per directional lane" (§4.5), with the status-vs-completion race closed by an atomic (§4.5). Two consequences shape the design: all per-request callback state must be reachable @@ -315,20 +318,20 @@ enum CompletionResult { `events_once` is the right primitive because each step is a single, non-blocking, one-shot, payload-carrying signal with exactly one waiter. -### 3.4 `Send` (not `Sync`) across the FFI boundary +### 3.4 Cross-thread handles Raw WinHTTP handles are `*mut c_void` and thus neither `Send` nor `Sync`. They are wrapped in `handle.rs` newtypes with explicit unsafe marker impls justified by WinHTTP's documented cross-thread handle usability, mirroring the -`ThreadSafe` technique in `oxidizer_io`. Two tiers, because their sharing +`ThreadSafe` technique in `oxidizer_io`. Three tiers, because their sharing needs differ: -- **Request and connect handles are `Send` but not `Sync`.** Each belongs to one - request; the handle is only ever *moved* between threads (the future migrates - across executor threads, and a completion may arrive on a different thread than - the submit), never shared by reference from two threads at once. The driver keeps - at most one operation outstanding per handle and holds the only reference, so - `Send` alone is what we need and all we can honestly assert. +- **Request handles are `Send + Sync` behind the shared request state.** One send-only + and one receive-only operation may use the same handle concurrently. The unsafe + `Sync` implementation is limited to methods that preserve WinHTTP's documented + directional concurrency rules and is covered by the full-duplex probe. +- **Connect handles are `Send` but not `Sync`.** They are retained for request lifetime + but never used concurrently after request construction. - **The session handle is `Send + Sync`.** A session `Arc` is cloned into every in-flight request on its core and is touched by WinHTTP's process-global callback threads (§3.1), so it is shared by reference across threads. @@ -356,17 +359,13 @@ request - dropping the in-flight `execute` future before headers, or the respons body while a read is outstanding (timeout, `select!`, client shutdown) - we must not free the buffer or the context until WinHTTP promises it is finished. -### 4.1 The per-request operation slot +### 4.1 Per-direction operation slots -WinHTTP allows at most one outstanding async operation per request handle at a -time, and it delivers every completion for a handle to the same callback context -pointer. `RequestContext` is therefore really an operation slot: all of its data -is operation-level (the current completion sender and the buffer that operation -borrows), and it holds no request-level state of its own. It exists at request -scope purely so a single allocation is reused across the request's sequence of -sequential operations (send, then receive, then each read) instead of being -reallocated per step. Its pointer is what we hand to WinHTTP as the callback -context; WinHTTP echoes it back on every notification for that request handle. +WinHTTP allows one send-only and one receive-only operation to overlap on supported +systems, while still permitting only one outstanding operation within each direction. +`RequestContext` therefore contains independent send and receive slots plus shared +request-level state. WinHTTP delivers every completion for the handle through the same +callback context pointer; the completion status and failing API identify its lane. The request handle lives in the driver (§4.4), not in this context: the callback only recovers the context, takes the sender and buffer, and signals (§2.1), while @@ -374,25 +373,17 @@ the driver uses the handle to issue the next call and, once, to close. That spli gives a single close authority (the driver's `RequestGuard`). ```rust,ignore -// `Idle` between operations; `Active` for the single in-flight async operation. -// Modeling it as an enum makes the invariant structural: there is no completion -// sender, borrowed buffer, or cert-failure flag unless an operation is running. -enum RequestContext { +struct RequestContext { + send: OperationSlot, + receive: OperationSlot, + secure_failure_flags: core::sync::atomic::AtomicU32, +} + +enum OperationSlot { Idle, Active { - // Completion sender for the in-flight operation; the callback takes it. completion: events_once::PooledSender, - // The buffer this operation borrows (if any). Read ops borrow a mutable - // BytesBuf (WinHTTP appends response bytes); write ops borrow an immutable - // BytesView (WinHTTP reads request bytes); send/receive/query ops borrow - // none. Ownership passes to WinHTTP for the operation's duration (§4). buffer: OperationBuffer, - // Set by a SECURE_FAILURE status callback. On the send path WinHTTP fires - // SECURE_FAILURE before the operation's terminal REQUEST_ERROR, so the - // occurrence order is WinHTTP-guaranteed; the AtomicU32 (vs Cell) only - // supplies the cross-thread publication edge, since the two callbacks may - // run on different threads. See §4.5. - secure_failure_flags: core::sync::atomic::AtomicU32, }, } @@ -403,10 +394,11 @@ enum OperationBuffer { } ``` -The enum makes the field relationships explicit: `Active` always carries a -completion sender, at most one borrowed buffer (a handle never has a read and a -write outstanding at once), and the cert-failure flag; `Idle` carries nothing. The -callback moves `Active -> Idle` by `take`-ing the sender and buffer. +Each lane moves independently between `Idle` and `Active`. Its active state carries +one completion sender and at most one borrowed buffer. A read and a write may therefore +borrow different buffers concurrently without aliasing. The implementation uses +separate interior-mutable cells with a one-driver/one-callback temporal ownership proof +per lane; callbacks never create a shared mutable reference spanning both slots. ### 4.2 dwContext is pointer-sized @@ -463,46 +455,45 @@ Reclaiming the `Box` across the FFI boundary uses `plurality::Box::into_raw` / `from_raw`, so the context pointer both identifies and owns the `RequestContext` with no side registry. -### 4.4 The request lifecycle is the `RequestDriver` +### 4.4 Request setup splits into directional drivers + +`RequestDriver` performs translation, handle setup, and `WinHttpSendRequest`. It initially +owns the `RequestGuard`: the request handle, connect handle, session +`Arc`, and raw `RequestContext` pointer whose close path is described in +§4.3. After send completion it creates shared request state and starts an upload driver +and a response driver. Either may make progress independently; response headers can return +an `HttpResponse` while the upload driver still owns request body state. -The "state machine" that issues the calls above is `RequestDriver` in -`request.rs`: the concrete async body that `WinHttpTransport::execute` returns and -polls. It walks the steps of §2, awaiting each step's `events_once` receiver, and -owns the `RequestGuard` - the request handle, its connect handle, a clone of the -core's session `Arc`, and the raw `RequestContext` pointer - whose drop -performs the synchronous teardown described in §4.3. When the design refers to "the -driver" it means this type. The handles live here, not in the context (§4.1); the -context is only the completion mailbox. +The shared state, not either directional driver, owns the single close authority. The +context remains only the callback mailbox and contains no ownership of native handles. -### 4.5 Exclusive access without locks +### 4.5 Per-lane exclusive access without locks -The buffers and sender in `RequestContext` are shared between the driver and the -callback with no lock. This is sound because access is strictly non-overlapping in -time, enforced by one discipline: +Each slot's buffer and sender are shared between its driver and callback with no lock. +This is sound because access within that lane is strictly non-overlapping in time: -- **The driver populates the context and drops every borrow to it before issuing - the async call.** It moves the `RequestContext` to `Active { .. }` (installing the - completion sender and the operation's buffer) through the raw pointer, ends that - borrow, *then* calls `Bindings::read_data` (etc.). It holds no +- **A directional driver populates its slot and drops every borrow before issuing + the async call.** It moves that `OperationSlot` to `Active { .. }` (installing the + completion sender and operation buffer) through the raw pointer, ends that borrow, + *then* calls `Bindings::read_data` (etc.). It holds no `&mut RequestContext` across the submit boundary. -- From the submit call until the `events_once` receiver resolves, the driver - touches nothing in the context. WinHTTP holds exclusive ownership of the leaked - pointer for the operation's duration (§4.2). +- From the submit call until the `events_once` receiver resolves, that driver touches + nothing in its slot. WinHTTP holds its temporal ownership for the operation's duration + (§4.2); the other lane remains independent. - The **completion** for the operation - inline on the submitting thread, or later on a worker thread - is the sole accessor of the `Active` fields: it `take`s the sender and buffer (moving the context back to `Idle`) and sends. WinHTTP delivers - exactly one completion per async operation, and the driver keeps one operation - outstanding per handle, so no second callback ever touches those fields. This - exclusivity does **not** rely on any undocumented per-handle callback - serialization; it follows from "one completion per op" plus "one op outstanding". + exactly one completion per async operation, and each driver keeps one operation + outstanding in its lane, so no second callback touches those fields. Send and receive + callbacks may execute concurrently, but access disjoint slots. This exclusivity does + **not** rely on undocumented callback serialization. - The `events_once` send-then-receive is the release/acquire edge that transfers buffer ownership back to the driver. Only after the receiver resolves does the driver read the returned buffer. -This is exactly the "WinHTTP takes exclusive ownership via a leaked pointer, we -recover it at the callback" model: the leaked pointer *is* the ownership token, -and the two sides never hold it at the same time. No lock is needed on the -sender/buffer fields; the temporal hand-off does the work. +This is the "WinHTTP takes exclusive ownership of one lane through the leaked context, +we recover it at the callback" model. No lock is needed on sender/buffer fields; each +lane's temporal hand-off and the structural separation between lanes do the work. **The one field that is not covered by the temporal hand-off** is `secure_failure_flags`, and that is why it is an `AtomicU32` rather than a `Cell`. @@ -655,6 +646,11 @@ driver programs that native per-read timer from the request's `BodyTimeout` (§1 keeping the body path free of a self-scheduled timer (the connect deadline stays the sole exception, §4.6). +After the authoritative zero-length read, the body reader queries +`WINHTTP_QUERY_FLAG_TRAILERS`. A non-empty result becomes the terminal trailer frame for +HTTP/1.1, HTTP/2, or HTTP/3. The supported Windows baseline provides this query flag, so +HTTP/1.1 trailers are not discarded merely because older WinHTTP versions lacked the API. + The `READ_COMPLETE` buffer WinHTTP fills is a slice reserved inside a pooled `BytesBuf`; it stays pinned until the callback fires (§4), then the filled prefix is yielded as a zero-copy `BytesView`. @@ -668,33 +664,32 @@ is yielded as a zero-copy `BytesView`. ```text translate req (method/uri/headers -> UTF-16) -> open connect handle (inline WinHttpConnect; non-blocking, no cache, §9.1) - -> WinHttpOpenRequest + set options (protocol, decompression, redirect, cookies/auth off, security, timeouts) + -> WinHttpOpenRequest + set options (protocol, redirect, cookies/auth off, security, timeouts) -> set RequestContext pointer as WINHTTP_OPTION_CONTEXT_VALUE -> WinHttpSendRequest ->async SENDREQUEST_COMPLETE - -> [streaming body] loop poll_frame -> WinHttpBodyWriter.write ->async WRITE_COMPLETE - -> WinHttpReceiveResponse ->async HEADERS_AVAILABLE + -> start independent send and receive lanes: + send: poll request data -> WinHttpWriteData ->async WRITE_COMPLETE [repeat] + receive: WinHttpReceiveResponse ->async HEADERS_AVAILABLE -> WinHttpQueryHeaders (status, negotiated version, header block) [sync] - -> build HttpResponse { parts, HttpBody streamed from WinHttpBodyReader } - -> return Ok(response) // body streamed lazily by the caller + -> build HttpResponse { parts, duplex request lifetime, HttpBody streamed from WinHttpBodyReader } + -> return Ok(response) // upload may still be active while the caller reads the response ``` Header translation is mechanical: request headers serialize to a WinHTTP CRLF header blob; the response `WINHTTP_QUERY_RAW_HEADERS_CRLF` blob parses back into an `http::HeaderMap`. Method and URI come from the `http::Request` parts. -**Response-body handle ownership.** The request handle must outlive `execute`'s -return, because the body is read lazily *after* the driver returns the -`HttpResponse`. So at the point the response is built the `RequestGuard` **moves -into** the `WinHttpBodyReader`, carrying the request handle, its per-request connect -handle, the core's session `Arc`, and the `RequestContext` pointer -with it. Holding the session `Arc` in every live request (driver or body reader) -keeps the session handle alive for exactly as long as any request needs it. +**Duplex handle ownership.** The request handle must outlive `execute` because response +body reads and request body writes may both continue after headers arrive. At that point +the `RequestGuard` moves into shared request state owned by the upload driver and +`WinHttpBodyReader`. It carries the request and connect handles, session +`Arc`, context pointer, and single close authority. Holding the session +in every live request keeps it alive for as long as either direction needs it. -Whoever owns the guard when the request ends runs the single synchronous teardown of -§4.3: the body reader on EOF (a zero-length `READ_COMPLETE`, §6.2), on error, or on -drop; or the driver itself when there is no response body (HEAD, 204) and it never -hands off a guard. This is the one close authority on every path - exactly one owner, -closing exactly once. +The shared state closes only after both directions finish, or immediately when either side +requests cancellation. A send failure before headers fails `execute`; a later send or trailer +failure is published to the response/request completion path. Dropping the response cancels an +unfinished upload. The close authority remains unique and closes exactly once. ## 7. Test plan @@ -727,11 +722,11 @@ after the table. | Factor | Key assertions | Notable adverse / edge case | |--------|----------------|-----------------------------| -| Threading (§3) | completions fired from a foreign OS thread reach the awaiting future; `static_assertions` for `execute`'s future `Send`, handles `Send`+`!Sync`, handler `Send + Sync`, and per-core-owned pools | all setup calls run inline on the caller's thread | +| Threading (§3) | completions fired from a foreign OS thread reach the awaiting future; `static_assertions` for `execute`'s future `Send`, request/session handles `Send + Sync`, connect handles `Send`+`!Sync`, handler `Send + Sync`, and per-core-owned pools | send and receive callbacks may overlap without sharing one operation slot | | Error handling (design.md §7) | table-driven Win32/`WINHTTP_*` code -> `ErrorLabel` + `RecoveryInfo`; `GetLastError` mapping on a failing synchronous call | a 4xx/5xx response is `Ok`, not `Err` | -| Protocol negotiation (design.md §3) | protocol-flag bitmask + `HTTP_PROTOCOL_REQUIRED` per `supported_http_versions` (empty -> `fetch` default; h2/h3-only -> required); response `Version` from the queried negotiated protocol | unmappable version (`HTTP/1.0`, `HTTP/0.9`) rejected as `invalid_request` | +| Protocol negotiation (design.md §3) | portable HTTP/1.1/2 constraint filters WinHTTP's defaults and optional HTTP/3 preference; response `Version` comes from the queried negotiated protocol | exact HTTP/2 suppresses the HTTP/3 preference and sets `HTTP_PROTOCOL_REQUIRED` | | TLS (design.md §4) | `WINHTTP_FLAG_SECURE` iff `https`; security-flags bitmask per `accept_invalid_*`, each flag setting only its own `SECURITY_FLAGS` bit (the two are independent, not coupled); `SECURE_FAILURE` -> `tls`-labeled, non-retryable | mTLS out of scope (design.md §4.1) - nothing to assert | -| Compression / redirects / statelessness (design.md §5) | `DECOMPRESSION`, `REDIRECT_POLICY_NEVER`, `DISABLE_COOKIES`, `DISABLE_AUTHENTICATION` set; an already-decoded body streams untouched; a 3xx is surfaced verbatim | brotli/zstd response passes through still-encoded | +| Encoded responses / redirects / statelessness (design.md §5) | native decompression remains disabled; `REDIRECT_POLICY_NEVER`, `DISABLE_COOKIES`, and `DISABLE_AUTHENTICATION` are set; a 3xx is surfaced verbatim | encoded bytes and headers reach fetch-level decompression unchanged | | Connection management (design.md §2) | connect handle opened per request and closed with it; max-conns mapping; `ConnectionKeepAlive` mapped to `HTTP2/3_KEEPALIVE` interval (§10.3); `DISABLE_GLOBAL_POOLING` on the session | `connection_lifetime` Fixed/PerConnection: accepted, no recycling, emits the `warn` "not honored" event; keep-alive `timeout`/active-only nuances emit the same warn | | Timeouts (design.md §6) | `WinHttpSetTimeouts` gets resolve from `WinHttpOptions` and connect from `TransportOptions.connect_timeout` (the single connect-timeout source, §10.4); per-request `BodyTimeout` -> `WINHTTP_OPTION_RECEIVE_TIMEOUT` and `ResponseTimeout` -> backstop `RECEIVE_RESPONSE_TIMEOUT`, both read from request extensions; mock-clock connect deadline (design.md §6.2): advance past `connect_timeout` -> handle closed + `HttpError::timeout` | a connect completing first drops the timer unfired; a per-request `BodyTimeout` overrides the session default on the native receive timer | @@ -785,9 +780,16 @@ case must remain covered because Microsoft documents replacing a `Host` header b explicitly guarantee its observed translation to HTTP/2 `:authority`. - GET/POST with small and large bodies; response body correctness and size. -- Streaming upload (unknown length -> chunked) and streaming download; assert +- Streaming upload (unknown length -> automatic protocol framing) and streaming download; assert incremental delivery, not just final bytes. -- Real gzip/deflate responses are transparently decoded. +- Encoded gzip/deflate/Brotli/zstd responses and their original headers reach `fetch` + unchanged; fetch-level tests cover uniform streaming decompression. +- Full-duplex required HTTP/2 follows the executable + [full-duplex streaming probe](full-duplex-streaming-experiment.md), for both known-length + and unknown-length automatic-chunking uploads. Assert response data arrives before the + final request chunk and upload continues afterward. +- HTTP/1.1 response trailers are queried after EOF and surfaced alongside HTTP/2/3 trailers. + A request body declaring trailers is rejected before `WinHttpSendRequest`. - Redirects are never followed (`REDIRECT_POLICY_NEVER`, §5/§10.3): a request to a localhost endpoint returning a 302 whose `Location` points at a sentinel endpoint asserts the 3xx status and `Location` header are surfaced unchanged and that the @@ -1010,43 +1012,32 @@ public contract. ### 10.1 HTTP protocol flags -The version set from design.md §3 maps to WinHTTP request options as follows. +The resolved portable constraint and WinHTTP preference from design.md §3 map to request +options as follows. - HTTP/1.1 is WinHTTP's baseline and is always available unless explicitly disallowed (below). - HTTP/2 is enabled by `WinHttpSetOption(WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL, WINHTTP_PROTOCOL_FLAG_HTTP2)`. -- HTTP/3 is enabled by the analogous `WINHTTP_PROTOCOL_FLAG_HTTP3`. HTTP/3 is a - first-class, supported mode, not an opt-in experiment: modern Windows ships it, - and enabling it is a single protocol flag. QUIC reachability is a runtime - property (a forced-h3 request against an unreachable QUIC endpoint fails with - `0x2EFE`/`0x2EFD`), which is a negotiation outcome, not a build gate. +- `prefer_http3` adds `WINHTTP_PROTOCOL_FLAG_HTTP3` only when the portable requirement + leaves HTTP/3 available. It never sets a strict HTTP/3 requirement. ALPN is performed by Schannel during the TLS handshake; there is no manual ALPN wiring. The negotiated version is read back after `HEADERS_AVAILABLE` via `WINHTTP_OPTION_HTTP_PROTOCOL_USED` and set on the `HttpResponse`, so upstream telemetry reflects what was actually negotiated rather than what was requested. -**Version-set semantics** (`supported_http_versions` -> options): - -- Contains `HTTP_11`: baseline allowed. -- Contains `HTTP_2`: set the HTTP/2 flag. -- Contains `HTTP_3`: set the HTTP/3 flag. -- Does not contain `HTTP_11` (only h2 and/or h3): additionally set - `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED = TRUE`, which disables the HTTP/1.1 - fallback so only the enabled newer protocols are used. This is how an - "HTTP/2-or-newer only" (or HTTP/3-only) mode is expressed; if negotiation - cannot reach a required protocol the request fails rather than downgrading. -- Empty list: use the `fetch` default. `fetch`'s `TransportOptions::default` - sets `supported_http_versions = [HTTP_11, HTTP_2]`, and an empty list is - `fetch`'s documented "no explicit preference" signal, so we apply the same - default (HTTP/1.1 baseline + HTTP/2 enabled, no required-protocol restriction). -- Unmappable entries: WinHTTP speaks only HTTP/1.1, /2, and /3. A version WinHTTP - cannot express (`HTTP/0.9`, `HTTP/1.0`) is rejected at request construction with - an `invalid_request` error rather than being silently dropped - silently - ignoring it could, for a single-element list like `[HTTP_10]`, leave *no* - protocol selected. A list containing only unmappable versions is likewise an - error, not a fall-through to the default. +**Resolved-set semantics:** + +- Unspecified + default transport policy: enable HTTP/2 and allow HTTP/1.1 fallback. +- Unspecified + `prefer_http3`: enable HTTP/3 and HTTP/2, allowing HTTP/1.1 fallback. +- Exact HTTP/2: enable only HTTP/2 and set `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED`. +- HTTP/1.1 or HTTP/2: enable HTTP/2 and allow HTTP/1.1 fallback; ignore `prefer_http3`. +- Exact HTTP/1.1: enable neither advanced protocol. + +The portable configuration does not accept HTTP/3. A transport preference eliminated by an +explicit portable requirement is silently narrowed because satisfying requirements is the +documented precedence rule, not an option-honorability failure. ### 10.2 TLS flags @@ -1071,11 +1062,9 @@ applied with `WinHttpSetOption` on the request handle before `WinHttpSendRequest The behaviors in design.md §5 are configured through these options. -- **Automatic decompression.** - `WinHttpSetOption(WINHTTP_OPTION_DECOMPRESSION, WINHTTP_DECOMPRESSION_FLAG_GZIP - | WINHTTP_DECOMPRESSION_FLAG_DEFLATE)` makes WinHTTP advertise - `Accept-Encoding: gzip, deflate`, transparently decode the response, and strip - `Content-Encoding`/`Content-Length`. +- **Automatic decompression remains disabled.** Do not set + `WINHTTP_OPTION_DECOMPRESSION` and do not synthesize `Accept-Encoding`; the raw encoded + body and headers must reach the mandatory fetch-level decompression layer. - **Redirects.** `WINHTTP_OPTION_REDIRECT_POLICY = WINHTTP_OPTION_REDIRECT_POLICY_NEVER`, so redirect responses (3xx) are surfaced to the caller unchanged rather than diff --git a/crates/fetch_winhttp/examples/full_duplex_streaming.rs b/crates/fetch_winhttp/examples/full_duplex_streaming.rs new file mode 100644 index 000000000..f70fa45f9 --- /dev/null +++ b/crates/fetch_winhttp/examples/full_duplex_streaming.rs @@ -0,0 +1,1485 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Probes whether native `WinHTTP` can perform full-duplex HTTP/2 request/response streaming: +//! continuing `WinHttpWriteData` uploads while `WinHttpReceiveResponse`/`WinHttpReadData` observe +//! the response on the same request handle. +//! +//! It also probes the unknown-length case that matters for gRPC/client-streaming uploads, whose +//! total size is never known up front: requests sent with `dwTotalLength` set to the +//! `WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH` sentinel on a request handle opened with +//! `WINHTTP_FLAG_AUTOMATIC_CHUNKING` - the exact native flag/total-length lowering +//! `fetch_winhttp_impl` uses for an unknown-length body (`crates/fetch_winhttp_impl/src/body/write.rs` +//! and `request.rs`, as landed by PR #687), and never a manually added `Transfer-Encoding` header. +//! An earlier version of this probe instead added `Transfer-Encoding: chunked` by hand, which is +//! not the API `fetch_winhttp_impl` uses and produced a false negative result; see +//! `docs/full-duplex-streaming-experiment.md` for why that probe was invalid and what the +//! corrected one found. See that same document for the full empirical record and its implications +//! for gRPC-style duplex support over `WinHTTP`. + +#[cfg(not(windows))] +fn main() { + eprintln!("This WinHTTP experiment only runs on Windows."); +} + +#[cfg(windows)] +fn main() -> anyhow::Result<()> { + windows::run() +} + +#[cfg(windows)] +mod windows { + use std::convert::Infallible; + use std::ffi::c_void; + use std::net::TcpListener; + use std::ptr; + use std::sync::{Arc, Barrier, Mutex}; + use std::thread::{self, JoinHandle}; + use std::time::{Duration, Instant}; + + use anyhow::{Context, Result, anyhow, ensure}; + use bytes::Bytes; + use http_body_util::{BodyExt, Channel, Full}; + use hyper::body::Incoming; + use hyper::service::service_fn; + use hyper::{Request, Response, StatusCode}; + use hyper_util::rt::{TokioExecutor, TokioIo}; + use rcgen::{CertifiedKey as GeneratedCertificate, generate_simple_self_signed}; + use rustls::ServerConfig; + use rustls::crypto::ring::sign::any_supported_type; + use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; + use rustls::server::{ClientHello, ResolvesServerCert}; + use rustls::sign::CertifiedKey; + use tokio::time::{sleep, timeout}; + use tokio_rustls::TlsAcceptor; + use windows_sys::Win32::Networking::WinHttp::{ + SECURITY_FLAG_IGNORE_UNKNOWN_CA, WINHTTP_ACCESS_TYPE_NO_PROXY, WINHTTP_FLAG_AUTOMATIC_CHUNKING, WINHTTP_FLAG_SECURE, + WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH, WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL, WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED, + WINHTTP_OPTION_HTTP_PROTOCOL_USED, WINHTTP_OPTION_SECURITY_FLAGS, WINHTTP_PROTOCOL_FLAG_HTTP2, WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_QUERY_STATUS_CODE, WinHttpCloseHandle, WinHttpConnect, WinHttpOpen, WinHttpOpenRequest, WinHttpQueryDataAvailable, + WinHttpQueryHeaders, WinHttpQueryOption, WinHttpReadData, WinHttpReceiveResponse, WinHttpSendRequest, WinHttpSetOption, + WinHttpSetTimeouts, WinHttpWriteData, + }; + + /// Certificate/connect name. This experiment is about send/receive concurrency, not host + /// separation, so the client connects straight to the name the certificate was issued for. + const HOST: &str = "localhost"; + const CHUNK1: &[u8] = b"upload-chunk-one"; + const CHUNK2: &[u8] = b"upload-chunk-two-final"; + const RESPONSE_FIRST_CHUNK: &[u8] = b"response-chunk-a"; + const RESPONSE_FINAL_CHUNK: &[u8] = b"response-chunk-b-final"; + + /// Bounds every server-side frame wait so a client that never sends the expected chunk cannot + /// hang this experiment; the connection is abandoned with a recorded note instead. + const FRAME_TIMEOUT: Duration = Duration::from_secs(5); + /// Bounds every blocking `WinHTTP` call so an unsupported handle state manifests as + /// `ERROR_WINHTTP_TIMEOUT` rather than an indefinite hang. + const RESOLVE_TIMEOUT_MS: i32 = 5_000; + const CONNECT_TIMEOUT_MS: i32 = 5_000; + const DATA_TIMEOUT_MS: i32 = 8_000; + /// Delay the duplex server inserts between observing the first request chunk and sending + /// response headers/body, so the client's blocking receive call has a clear, measurable + /// window during which the upload has deliberately not finished. + const SEQUENTIAL_RESPONSE_DELAY: Duration = Duration::from_millis(200); + const CONCURRENT_RESPONSE_DELAY: Duration = Duration::from_millis(400); + + pub(super) fn run() -> Result<()> { + rustls::crypto::ring::default_provider() + .install_default() + .map_err(|provider| anyhow!("a rustls crypto provider is already installed: {provider:?}"))?; + + run_baseline_case().context("sequencing control failed")?; + println!(); + + let sequential = run_sequential_case().context("sequential-interleave case failed")?; + println!(); + + let concurrent = run_concurrent_case().context("concurrent send/receive case failed")?; + println!(); + + match (sequential, concurrent) { + (ChunkWriteOutcome::Succeeded, _) => println!( + "DECISIVE: after WinHttpReceiveResponse observed headers and a response body chunk \ + for a still-incomplete upload, a further WinHttpWriteData call on the same request \ + handle succeeded on a single thread (sequential interleave). Full-duplex request/\ + response streaming is supported on this host." + ), + (ChunkWriteOutcome::Failed(sequential_error), ChunkWriteOutcome::Succeeded) => println!( + "DECISIVE: sequential interleave rejected the follow-up write (Win32 error \ + {sequential_error}), but a send-only WinHttpWriteData call genuinely overlapping a \ + receive-only WinHttpReceiveResponse call on a second thread succeeded. Full-duplex \ + streaming requires the documented concurrent send-only/receive-only thread pairing \ + on this host." + ), + (ChunkWriteOutcome::Failed(sequential_error), ChunkWriteOutcome::Failed(concurrent_error)) => println!( + "DECISIVE (negative): neither sequential interleave (Win32 error {sequential_error}) \ + nor a genuinely overlapping concurrent send-only/receive-only thread pairing (Win32 \ + error {concurrent_error}) permits writing more request data once \ + WinHttpReceiveResponse has observed the response for an incomplete upload. This host \ + does not support full-duplex HTTP/2 request/response streaming through WinHTTP." + ), + } + println!(); + + run_unknown_length_baseline_case().context("unknown-length sequencing control failed")?; + println!(); + + let unknown_sequential = run_unknown_length_sequential_case().context("unknown-length sequential-interleave case failed")?; + println!(); + + let unknown_concurrent = run_unknown_length_concurrent_case().context("unknown-length concurrent send/receive case failed")?; + println!(); + + print_unknown_length_verdict(unknown_sequential, unknown_concurrent); + + Ok(()) + } + + /// Outcome of attempting to continue an upload after the response has begun arriving. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum ChunkWriteOutcome { + Succeeded, + Failed(u32), + } + + /// Outcome of the two send-only writes this probe layers onto an unknown-length upload: the + /// remaining payload chunk, and - always attempted regardless of whether that first write + /// succeeded - the documented null-buffer, zero-length write that ends a + /// `WINHTTP_FLAG_AUTOMATIC_CHUNKING` request body (`fetch_winhttp_impl`'s + /// `WinHttpBodyWriter::end_automatic_chunking`). + #[derive(Debug, Clone, Copy)] + struct UnknownLengthWriteAttempt { + chunk2: ChunkWriteOutcome, + terminal: ChunkWriteOutcome, + } + + impl UnknownLengthWriteAttempt { + /// Collapses the two send-only steps into one outcome: the upload only completed if both + /// the remaining chunk and the terminal write succeeded, and the first Win32 error is the + /// one that best explains an incomplete upload. + fn combined(self) -> ChunkWriteOutcome { + match (self.chunk2, self.terminal) { + (ChunkWriteOutcome::Succeeded, ChunkWriteOutcome::Succeeded) => ChunkWriteOutcome::Succeeded, + (ChunkWriteOutcome::Failed(code), _) | (_, ChunkWriteOutcome::Failed(code)) => ChunkWriteOutcome::Failed(code), + } + } + } + + /// Non-duplex sequencing control: the server only responds after observing the complete + /// request body. This validates that the shared `ServerObservation` timestamps actually + /// distinguish "responded before the final chunk" from "responded after it", rather than the + /// duplex cases below merely being an artifact of how this harness measures time. + fn run_baseline_case() -> Result<()> { + let (server, observation) = BaselineServer::start(HOST)?; + let client = DuplexClient::open()?; + let total_len = u32::try_from(CHUNK1.len() + CHUNK2.len())?; + let request = client.start_post(HOST, server.port(), total_len)?; + + let written1 = request.write_chunk(CHUNK1)?; + ensure!( + usize::try_from(written1)? == CHUNK1.len(), + "baseline: the first chunk was not fully written" + ); + let written2 = request.write_chunk(CHUNK2)?; + ensure!( + usize::try_from(written2)? == CHUNK2.len(), + "baseline: the second chunk was not fully written" + ); + + request.receive_response().context("baseline: WinHttpReceiveResponse failed")?; + let status = request.status_code()?; + let protocol = request.protocol_used()?; + let body = request.read_remaining()?; + + drop(request); + // WinHTTP keeps HTTP/2 connections pooled at the session level for reuse, so the + // underlying socket does not necessarily close just because the request handle does. + // Drop the session too so the server observes a clean connection shutdown. + drop(client); + server.join()?; + + let recorded = observation.lock().expect("observation mutex poisoned").clone(); + println!( + "baseline (sequencing control): status={status}, protocol={protocol}, response body={:?}", + String::from_utf8_lossy(&body) + ); + print_server_notes(&recorded); + + ensure!(status == 200, "baseline request did not return HTTP 200"); + ensure!(protocol == WINHTTP_PROTOCOL_FLAG_HTTP2, "baseline request did not negotiate HTTP/2"); + ensure!( + recorded.chunk1.as_deref() == Some(CHUNK1), + "baseline server did not observe the first chunk correctly" + ); + ensure!( + recorded.chunk2.as_deref() == Some(CHUNK2), + "baseline server did not observe the second chunk correctly" + ); + let chunk2_at = recorded.chunk2_at.context("baseline server never recorded the second chunk")?; + let response_at = recorded + .response_sent_at + .context("baseline server never recorded sending a response")?; + ensure!( + response_at >= chunk2_at, + "sequencing control invalid: the baseline server responded before observing the complete request body" + ); + println!("sequencing control confirmed: non-duplex handling responds only after the full request body arrives."); + Ok(()) + } + + /// Writes the first chunk, then calls `WinHttpReceiveResponse` and reads the first response + /// chunk on a single thread, before attempting a further `WinHttpWriteData` call for the + /// second chunk. All of this is inherently "sequential" from `WinHTTP`'s perspective (each + /// blocking call fully completes before the next begins), so this case isolates whether + /// `WinHttpReceiveResponse` itself ends the data transfer for a still-incomplete upload. + fn run_sequential_case() -> Result { + let (server, observation) = DuplexServer::start(HOST, SEQUENTIAL_RESPONSE_DELAY)?; + let client = DuplexClient::open()?; + let total_len = u32::try_from(CHUNK1.len() + CHUNK2.len())?; + let request = client.start_post(HOST, server.port(), total_len)?; + + let written1 = request.write_chunk(CHUNK1)?; + ensure!( + usize::try_from(written1)? == CHUNK1.len(), + "sequential: the first chunk was not fully written" + ); + + request.receive_response().context("sequential: WinHttpReceiveResponse failed")?; + let status = request.status_code()?; + let protocol = request.protocol_used()?; + ensure!( + protocol == WINHTTP_PROTOCOL_FLAG_HTTP2, + "sequential: request did not negotiate HTTP/2" + ); + + // Decisive, non-timing-based proof that the response was already flowing while the + // upload was still incomplete: inspect the server's live state the instant headers + // became available on the client, before attempting to send any more request data. + let response_before_final_chunk = { + let recorded = observation.lock().expect("observation mutex poisoned"); + recorded.response_sent_at.is_some() && recorded.chunk2.is_none() + }; + ensure!( + response_before_final_chunk, + "sequential: the response was not observably available before the client attempted the final upload chunk" + ); + + let first_chunk = request + .read_available()? + .context("sequential: no response body was available immediately after headers arrived")?; + ensure!( + first_chunk == RESPONSE_FIRST_CHUNK, + "sequential: unexpected response body before the final upload chunk" + ); + println!( + "sequential: status={status}, protocol={protocol}, response observed before the final chunk \ + was sent (server chunk2 not yet seen, response already sent)." + ); + + let write2 = request.write_chunk(CHUNK2); + let outcome = match &write2 { + Ok(bytes_written) => { + ensure!( + usize::try_from(*bytes_written)? == CHUNK2.len(), + "sequential: the second chunk was not fully written" + ); + println!("sequential: WinHttpWriteData(chunk2) succeeded while the response was already flowing."); + ChunkWriteOutcome::Succeeded + } + Err(error) => { + let code = error.downcast_ref::().map_or(0, |error| error.code); + println!("sequential: WinHttpWriteData(chunk2) failed after headers were received: {error} (Win32 error {code})"); + ChunkWriteOutcome::Failed(code) + } + }; + + if write2.is_ok() { + let rest = request.read_remaining()?; + ensure!(rest == RESPONSE_FINAL_CHUNK, "sequential: unexpected trailing response content"); + } + drop(request); + // WinHTTP keeps HTTP/2 connections pooled at the session level for reuse, so the + // underlying socket does not necessarily close just because the request handle does. + drop(client); + server.join()?; + + let recorded = observation.lock().expect("observation mutex poisoned").clone(); + print_server_notes(&recorded); + if write2.is_ok() { + ensure!( + recorded.chunk2.as_deref() == Some(CHUNK2), + "sequential: the server did not observe the second chunk despite a successful write" + ); + println!("sequential: server confirmed receiving the second chunk after already responding."); + } + + Ok(outcome) + } + + /// Writes the first chunk, then starts a receive-only `WinHttpReceiveResponse` call on one + /// thread and a send-only `WinHttpWriteData` call for the second chunk on another thread, + /// releasing both through a barrier so their blocking windows genuinely overlap. This directly + /// exercises the documented exception: "an application may do a send-only operation on one + /// thread at the same time that another thread is performing a receive-only operation." + fn run_concurrent_case() -> Result { + let (server, observation) = DuplexServer::start(HOST, CONCURRENT_RESPONSE_DELAY)?; + let client = DuplexClient::open()?; + let total_len = u32::try_from(CHUNK1.len() + CHUNK2.len())?; + let request = client.start_post(HOST, server.port(), total_len)?; + + let written1 = request.write_chunk(CHUNK1)?; + ensure!( + usize::try_from(written1)? == CHUNK1.len(), + "concurrent: the first chunk was not fully written" + ); + + let raw = SendPtr(request.raw()); + let barrier = Barrier::new(2); + let (receive_outcome, write_outcome) = thread::scope(|scope| -> Result<(ThreadOutcome<()>, ThreadOutcome)> { + let barrier = &barrier; + let receiver = scope.spawn(move || { + let raw = raw; + barrier.wait(); + let start = Instant::now(); + let result = winhttp_receive_response(raw.0); + let end = Instant::now(); + ThreadOutcome { start, end, result } + }); + let writer = scope.spawn(move || { + let raw = raw; + barrier.wait(); + let start = Instant::now(); + let result = winhttp_write(raw.0, CHUNK2); + let end = Instant::now(); + ThreadOutcome { start, end, result } + }); + let receive_outcome = receiver.join().map_err(|_panic| anyhow!("receive-only thread panicked"))?; + let write_outcome = writer.join().map_err(|_panic| anyhow!("send-only thread panicked"))?; + Ok((receive_outcome, write_outcome)) + })?; + + let overlap = write_outcome.start < receive_outcome.end && receive_outcome.start < write_outcome.end; + println!( + "concurrent: receive-only WinHttpReceiveResponse active for {:?} (result={:?}); send-only \ + WinHttpWriteData(chunk2) active for {:?} (result={:?}); overlapping={overlap}", + receive_outcome.end.duration_since(receive_outcome.start), + result_summary(&receive_outcome.result), + write_outcome.end.duration_since(write_outcome.start), + result_summary(&write_outcome.result), + ); + + let outcome = match &write_outcome.result { + Ok(bytes_written) => { + ensure!( + usize::try_from(*bytes_written)? == CHUNK2.len(), + "concurrent: the second chunk was not fully written" + ); + ChunkWriteOutcome::Succeeded + } + Err(error) => ChunkWriteOutcome::Failed(error.downcast_ref::().map_or(0, |error| error.code)), + }; + + if receive_outcome.result.is_ok() { + let status = request.status_code()?; + let protocol = request.protocol_used()?; + ensure!( + protocol == WINHTTP_PROTOCOL_FLAG_HTTP2, + "concurrent: request did not negotiate HTTP/2" + ); + println!("concurrent: status={status}, protocol={protocol}"); + + if outcome == ChunkWriteOutcome::Succeeded { + let body = request.read_remaining()?; + ensure!( + body.starts_with(RESPONSE_FIRST_CHUNK), + "concurrent: unexpected response body content" + ); + ensure!( + body.ends_with(RESPONSE_FINAL_CHUNK), + "concurrent: response body did not reach the final chunk" + ); + } + } + + drop(request); + // WinHTTP keeps HTTP/2 connections pooled at the session level for reuse, so the + // underlying socket does not necessarily close just because the request handle does. + drop(client); + server.join()?; + + let recorded = observation.lock().expect("observation mutex poisoned").clone(); + print_server_notes(&recorded); + if outcome == ChunkWriteOutcome::Succeeded { + ensure!( + recorded.chunk2.as_deref() == Some(CHUNK2), + "concurrent: the server did not observe the second chunk despite a successful write" + ); + println!("concurrent: server confirmed receiving the second chunk after already responding."); + } + + Ok(outcome) + } + + /// Writes the final upload chunk and then, always, the documented null-buffer, zero-length + /// write that ends a `WINHTTP_FLAG_AUTOMATIC_CHUNKING` upload - bundled together so the + /// concurrent case's send-only thread performs both send-only operations from a single scoped + /// closure. The terminal write is attempted even when the chunk above was rejected, because + /// this probe wants to know whether `WinHTTP` treats the "end of body" signal as exempt from + /// whatever caused an ordinary payload write to fail, not only whether it is accepted on the + /// already-known-good path. + fn write_final_chunk_then_end_automatic_chunking(request: *mut c_void) -> Result { + let chunk2 = match winhttp_write(request, CHUNK2) { + Ok(bytes_written) => { + ensure!( + usize::try_from(bytes_written)? == CHUNK2.len(), + "the final upload chunk was not fully written" + ); + ChunkWriteOutcome::Succeeded + } + Err(error) => ChunkWriteOutcome::Failed(error.downcast_ref::().map_or(0, |error| error.code)), + }; + + let terminal = match winhttp_end_automatic_chunking(request) { + Ok(()) => ChunkWriteOutcome::Succeeded, + Err(error) => ChunkWriteOutcome::Failed(error.downcast_ref::().map_or(0, |error| error.code)), + }; + + Ok(UnknownLengthWriteAttempt { chunk2, terminal }) + } + + /// Non-duplex sequencing control for the unknown-length upload: the server reads the entire + /// request body - including the documented null-buffer, zero-length terminal write that ends a + /// `WINHTTP_FLAG_AUTOMATIC_CHUNKING` request - before responding, exactly as `run_baseline_case` + /// does for a known-length upload. This validates that the corrected native API + /// (`WINHTTP_FLAG_AUTOMATIC_CHUNKING` on `WinHttpOpenRequest` plus + /// `WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH` on `WinHttpSendRequest`, with + /// `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED` set and no manually added `Transfer-Encoding` header - + /// exactly what PR #687's `fetch_winhttp_impl` sends) completes an HTTP/2-required + /// unknown-length upload end to end, before the duplex cases below reorder it. The earlier + /// version of this probe added `Transfer-Encoding: chunked` by hand instead and could not even + /// reach this sequencing control: `WinHttpSendRequest` itself rejected + /// `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED` alongside that header (Win32 error 12190) - the + /// automatic-chunking flag has no such conflict. + fn run_unknown_length_baseline_case() -> Result<()> { + let (server, observation) = BaselineServer::start(HOST)?; + let client = DuplexClient::open()?; + let request = client.start_post_unknown_length(HOST, server.port())?; + + let written1 = request.write_chunk(CHUNK1)?; + ensure!( + usize::try_from(written1)? == CHUNK1.len(), + "unknown-length baseline: the first chunk was not fully written" + ); + let written2 = request.write_chunk(CHUNK2)?; + ensure!( + usize::try_from(written2)? == CHUNK2.len(), + "unknown-length baseline: the second chunk was not fully written" + ); + request + .end_automatic_chunking() + .context("unknown-length baseline: the null-buffer terminal write failed")?; + + request + .receive_response() + .context("unknown-length baseline: WinHttpReceiveResponse failed")?; + let status = request.status_code()?; + let protocol = request.protocol_used()?; + let body = request.read_remaining()?; + + drop(request); + // WinHTTP keeps HTTP/2 connections pooled at the session level for reuse, so the + // underlying socket does not necessarily close just because the request handle does. + drop(client); + server.join()?; + + let recorded = observation.lock().expect("observation mutex poisoned").clone(); + println!( + "unknown-length baseline (sequencing control): status={status}, protocol={protocol}, \ + response body={:?}", + String::from_utf8_lossy(&body) + ); + print_server_notes(&recorded); + + ensure!(status == 200, "unknown-length baseline request did not return HTTP 200"); + ensure!( + protocol == WINHTTP_PROTOCOL_FLAG_HTTP2, + "unknown-length baseline request did not negotiate HTTP/2" + ); + ensure!( + recorded.chunk1.as_deref() == Some(CHUNK1), + "unknown-length baseline server did not observe the first chunk correctly" + ); + ensure!( + recorded.chunk2.as_deref() == Some(CHUNK2), + "unknown-length baseline server did not observe the second chunk correctly" + ); + let chunk2_at = recorded + .chunk2_at + .context("unknown-length baseline server never recorded the second chunk")?; + let response_at = recorded + .response_sent_at + .context("unknown-length baseline server never recorded sending a response")?; + ensure!( + response_at >= chunk2_at, + "sequencing control invalid: the unknown-length baseline server responded before observing \ + the complete request body" + ); + println!( + "unknown-length sequencing control confirmed: WINHTTP_FLAG_AUTOMATIC_CHUNKING + \ + WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH complete an HTTP/2-required upload end-to-end with no \ + Transfer-Encoding header." + ); + Ok(()) + } + + /// Unknown-length analogue of `run_sequential_case`, using the corrected + /// `WINHTTP_FLAG_AUTOMATIC_CHUNKING` + `WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH` API instead of the + /// earlier, invalid probe's manually added `Transfer-Encoding: chunked` header: writes the + /// first chunk, then calls `WinHttpReceiveResponse` and reads the first response chunk while + /// the automatically-chunked upload is still open - before the remaining chunk or the required + /// null-buffer terminal write have been sent - and only then attempts to finish the upload on + /// the same thread. + fn run_unknown_length_sequential_case() -> Result { + let (server, observation) = DuplexServer::start(HOST, SEQUENTIAL_RESPONSE_DELAY)?; + let client = DuplexClient::open()?; + let request = client.start_post_unknown_length(HOST, server.port())?; + + let written1 = request.write_chunk(CHUNK1)?; + ensure!( + usize::try_from(written1)? == CHUNK1.len(), + "unknown-length sequential: the first chunk was not fully written" + ); + + request + .receive_response() + .context("unknown-length sequential: WinHttpReceiveResponse failed before the upload finished")?; + let status = request.status_code()?; + let protocol = request.protocol_used()?; + ensure!( + protocol == WINHTTP_PROTOCOL_FLAG_HTTP2, + "unknown-length sequential: request did not negotiate HTTP/2" + ); + + let response_before_final_chunk = { + let recorded = observation.lock().expect("observation mutex poisoned"); + recorded.response_sent_at.is_some() && recorded.chunk2.is_none() + }; + ensure!( + response_before_final_chunk, + "unknown-length sequential: the response was not observably available before the client \ + attempted the final upload chunk" + ); + + let first_chunk = request + .read_available()? + .context("unknown-length sequential: no response body was available immediately after headers arrived")?; + ensure!( + first_chunk == RESPONSE_FIRST_CHUNK, + "unknown-length sequential: unexpected response body before the final upload chunk" + ); + println!( + "unknown-length sequential: status={status}, protocol={protocol}, response observed \ + before the final chunk was sent (server chunk2 not yet seen, response already sent)." + ); + + let attempt = write_final_chunk_then_end_automatic_chunking(request.raw())?; + match attempt.chunk2 { + ChunkWriteOutcome::Succeeded => { + println!("unknown-length sequential: WinHttpWriteData(chunk2) succeeded while the response was already flowing."); + } + ChunkWriteOutcome::Failed(code) => { + println!("unknown-length sequential: WinHttpWriteData(chunk2) failed after headers were received (Win32 error {code})."); + } + } + match attempt.terminal { + ChunkWriteOutcome::Succeeded => println!( + "unknown-length sequential: the null-buffer terminal write succeeded, ending the \ + automatically chunked upload." + ), + ChunkWriteOutcome::Failed(code) => { + println!("unknown-length sequential: the null-buffer terminal write failed (Win32 error {code})."); + } + } + + let outcome = attempt.combined(); + if outcome == ChunkWriteOutcome::Succeeded { + let rest = request.read_remaining()?; + ensure!( + rest == RESPONSE_FINAL_CHUNK, + "unknown-length sequential: unexpected trailing response content" + ); + } + drop(request); + // WinHTTP keeps HTTP/2 connections pooled at the session level for reuse, so the + // underlying socket does not necessarily close just because the request handle does. + drop(client); + server.join()?; + + let recorded = observation.lock().expect("observation mutex poisoned").clone(); + print_server_notes(&recorded); + if outcome == ChunkWriteOutcome::Succeeded { + ensure!( + recorded.chunk2.as_deref() == Some(CHUNK2), + "unknown-length sequential: the server did not observe the second chunk despite a successful write" + ); + println!("unknown-length sequential: server confirmed receiving the second chunk after already responding."); + } + + Ok(outcome) + } + + /// Unknown-length analogue of `run_concurrent_case`: releases a receive-only + /// `WinHttpReceiveResponse` call and a send-only thread that writes the remaining chunk and the + /// null-buffer terminal write, through a barrier on two threads sharing the same request + /// handle, corroborating whatever `run_unknown_length_sequential_case` found through the + /// documented concurrent send-only/receive-only exception instead of strict single-thread + /// ordering. + fn run_unknown_length_concurrent_case() -> Result { + let (server, observation) = DuplexServer::start(HOST, CONCURRENT_RESPONSE_DELAY)?; + let client = DuplexClient::open()?; + let request = client.start_post_unknown_length(HOST, server.port())?; + + let written1 = request.write_chunk(CHUNK1)?; + ensure!( + usize::try_from(written1)? == CHUNK1.len(), + "unknown-length concurrent: the first chunk was not fully written" + ); + + let raw = SendPtr(request.raw()); + let barrier = Barrier::new(2); + let (receive_outcome, write_outcome) = + thread::scope(|scope| -> Result<(ThreadOutcome<()>, ThreadOutcome)> { + let barrier = &barrier; + let receiver = scope.spawn(move || { + let raw = raw; + barrier.wait(); + let start = Instant::now(); + let result = winhttp_receive_response(raw.0); + let end = Instant::now(); + ThreadOutcome { start, end, result } + }); + let writer = scope.spawn(move || { + let raw = raw; + barrier.wait(); + let start = Instant::now(); + let result = write_final_chunk_then_end_automatic_chunking(raw.0); + let end = Instant::now(); + ThreadOutcome { start, end, result } + }); + let receive_outcome = receiver.join().map_err(|_panic| anyhow!("receive-only thread panicked"))?; + let write_outcome = writer.join().map_err(|_panic| anyhow!("send-only thread panicked"))?; + Ok((receive_outcome, write_outcome)) + })?; + + let overlap = write_outcome.start < receive_outcome.end && receive_outcome.start < write_outcome.end; + let write_duration = write_outcome.end.duration_since(write_outcome.start); + let receive_duration = receive_outcome.end.duration_since(receive_outcome.start); + let attempt = match &write_outcome.result { + Ok(attempt) => *attempt, + Err(error) => return Err(anyhow!("unknown-length concurrent: send-only thread failed unexpectedly: {error}")), + }; + println!( + "unknown-length concurrent: receive-only WinHttpReceiveResponse active for {receive_duration:?} \ + (result={}); send-only WinHttpWriteData(chunk2)+terminal active for {write_duration:?} \ + (chunk2={:?}, terminal={:?}); overlapping={overlap}", + result_summary(&receive_outcome.result), + attempt.chunk2, + attempt.terminal, + ); + + let outcome = attempt.combined(); + + if receive_outcome.result.is_ok() { + let status = request.status_code()?; + let protocol = request.protocol_used()?; + ensure!( + protocol == WINHTTP_PROTOCOL_FLAG_HTTP2, + "unknown-length concurrent: request did not negotiate HTTP/2" + ); + println!("unknown-length concurrent: status={status}, protocol={protocol}"); + + if outcome == ChunkWriteOutcome::Succeeded { + let body = request.read_remaining()?; + ensure!( + body.starts_with(RESPONSE_FIRST_CHUNK), + "unknown-length concurrent: unexpected response body content" + ); + ensure!( + body.ends_with(RESPONSE_FINAL_CHUNK), + "unknown-length concurrent: response body did not reach the final chunk" + ); + } + } + + drop(request); + // WinHTTP keeps HTTP/2 connections pooled at the session level for reuse, so the + // underlying socket does not necessarily close just because the request handle does. + drop(client); + server.join()?; + + let recorded = observation.lock().expect("observation mutex poisoned").clone(); + print_server_notes(&recorded); + if outcome == ChunkWriteOutcome::Succeeded { + ensure!( + recorded.chunk2.as_deref() == Some(CHUNK2), + "unknown-length concurrent: the server did not observe the second chunk despite a successful write" + ); + println!("unknown-length concurrent: server confirmed receiving the second chunk after already responding."); + } + + Ok(outcome) + } + + /// Prints the combined decisive verdict for the unknown-length probe, mirroring the combined + /// verdict `run()` already prints for the known-length probe. + fn print_unknown_length_verdict(sequential: ChunkWriteOutcome, concurrent: ChunkWriteOutcome) { + match (sequential, concurrent) { + (ChunkWriteOutcome::Succeeded, _) => println!( + "DECISIVE (unknown length): after WinHttpReceiveResponse observed headers and a \ + response body chunk for a still-incomplete WINHTTP_FLAG_AUTOMATIC_CHUNKING upload (no \ + Transfer-Encoding header, dwTotalLength=WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH, HTTP/2 \ + required), a further WinHttpWriteData call and the documented null-buffer terminal \ + write both succeeded on the same request handle from the same thread (sequential \ + interleave). True unbounded, gRPC-style full-duplex request/response streaming is \ + supported on this host using the same native automatic-chunking API PR #687 uses." + ), + (ChunkWriteOutcome::Failed(sequential_error), ChunkWriteOutcome::Succeeded) => println!( + "DECISIVE (unknown length): sequential interleave rejected the follow-up chunk/terminal \ + write (Win32 error {sequential_error}), but a send-only chunk+terminal write genuinely \ + overlapping a receive-only WinHttpReceiveResponse call on a second thread succeeded. \ + Unbounded full-duplex streaming with WINHTTP_FLAG_AUTOMATIC_CHUNKING requires the \ + documented concurrent send-only/receive-only thread pairing on this host." + ), + (ChunkWriteOutcome::Failed(sequential_error), ChunkWriteOutcome::Failed(concurrent_error)) => println!( + "DECISIVE (unknown length, negative): neither sequential interleave (Win32 error \ + {sequential_error}) nor a genuinely overlapping concurrent send-only/receive-only \ + thread pairing (Win32 error {concurrent_error}) permits completing a \ + WINHTTP_FLAG_AUTOMATIC_CHUNKING upload once WinHttpReceiveResponse has observed the \ + response for an incomplete body. This host does not support true unbounded, gRPC-style \ + full-duplex request/response streaming through native WinHTTP even with the correct \ + automatic-chunking API PR #687 uses." + ), + } + } + + fn result_summary(result: &Result) -> String { + match result { + Ok(_) => "Ok".to_owned(), + Err(error) => { + let code = error.downcast_ref::().map_or(0, |error| error.code); + format!("Err(Win32 error {code}: {error})") + } + } + } + + struct ThreadOutcome { + start: Instant, + end: Instant, + result: Result, + } + + /// Server-observed timeline for one request. Shared with the test driver through an + /// `Arc>` so the client can inspect live server-side ordering rather than inferring + /// success merely because a `WinHTTP` call returned. + #[derive(Default, Debug, Clone)] + struct ServerObservation { + sni: Option, + alpn: Option, + chunk1: Option>, + chunk1_at: Option, + response_sent_at: Option, + chunk2: Option>, + chunk2_at: Option, + request_end_at: Option, + notes: Vec, + } + + type SharedObservation = Arc>; + + fn note(observation: &SharedObservation, message: impl Into) { + observation.lock().expect("observation mutex poisoned").notes.push(message.into()); + } + + /// Prints any server-side notes recorded during a case (timeouts, unexpected stream + /// endings, and similar events), so a case that only partially completes still leaves an + /// exact, explained sequence in the output rather than silence. + fn print_server_notes(recorded: &ServerObservation) { + for note in &recorded.notes { + println!(" server note: {note}"); + } + } + + /// Duplex HTTP/2 server: responds with headers and a first body chunk as soon as it observes + /// the first request chunk, then keeps reading the request body (observing a later chunk) + /// concurrently with the response already flowing. + struct DuplexServer { + port: u16, + thread: JoinHandle>, + } + + impl DuplexServer { + fn start(certificate_name: &str, response_delay: Duration) -> Result<(Self, SharedObservation)> { + let observed_sni = Arc::new(Mutex::new(None)); + let config = server_config(certificate_name, Arc::clone(&observed_sni))?; + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + listener.set_nonblocking(true)?; + + let observation: SharedObservation = Arc::new(Mutex::new(ServerObservation::default())); + let observation_for_thread = Arc::clone(&observation); + let thread = thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build()? + .block_on(serve_duplex(listener, config, observed_sni, observation_for_thread, response_delay)) + }); + + Ok((Self { port, thread }, observation)) + } + + fn port(&self) -> u16 { + self.port + } + + fn join(self) -> Result<()> { + self.thread.join().map_err(|_panic| anyhow!("duplex server thread panicked"))? + } + } + + async fn serve_duplex( + listener: TcpListener, + config: ServerConfig, + observed_sni: Arc>>, + observation: SharedObservation, + response_delay: Duration, + ) -> Result<()> { + let listener = tokio::net::TcpListener::from_std(listener)?; + let (stream, _) = listener.accept().await?; + let tls = TlsAcceptor::from(Arc::new(config)).accept(stream).await?; + let alpn = tls + .get_ref() + .1 + .alpn_protocol() + .map(|protocol| String::from_utf8_lossy(protocol).into_owned()); + { + let mut recorded = observation.lock().expect("observation mutex poisoned"); + recorded.sni.clone_from(&observed_sni.lock().expect("SNI recorder poisoned")); + recorded.alpn = alpn; + } + + let observation_for_service = Arc::clone(&observation); + let service = service_fn(move |request: Request| { + let observation = Arc::clone(&observation_for_service); + async move { + let mut incoming = request.into_body(); + let chunk1 = match timeout(FRAME_TIMEOUT, next_data_frame(&mut incoming)).await { + Ok(Ok(Some(bytes))) => bytes, + Ok(Ok(None)) => { + note(&observation, "request ended before the first chunk arrived"); + return Ok::<_, Infallible>(duplex_error_response()); + } + Ok(Err(error)) => { + note(&observation, format!("error reading the first chunk: {error}")); + return Ok::<_, Infallible>(duplex_error_response()); + } + Err(_elapsed) => { + note(&observation, "timed out waiting for the first chunk"); + return Ok::<_, Infallible>(duplex_error_response()); + } + }; + { + let mut recorded = observation.lock().expect("observation mutex poisoned"); + recorded.chunk1 = Some(chunk1.to_vec()); + recorded.chunk1_at = Some(Instant::now()); + } + + sleep(response_delay).await; + + let (mut sender, body) = Channel::::new(4); + let response = Response::builder() + .status(StatusCode::OK) + .body(body) + .expect("a status code and a streaming body always build a valid response"); + + let observation_for_task = Arc::clone(&observation); + tokio::spawn(async move { + if sender.send_data(Bytes::from_static(RESPONSE_FIRST_CHUNK)).await.is_ok() { + let mut recorded = observation_for_task.lock().expect("observation mutex poisoned"); + recorded.response_sent_at = Some(Instant::now()); + } else { + note( + &observation_for_task, + "the client closed the response body before the first chunk was sent", + ); + } + + match timeout(FRAME_TIMEOUT, next_data_frame(&mut incoming)).await { + Ok(Ok(Some(bytes))) => { + let mut recorded = observation_for_task.lock().expect("observation mutex poisoned"); + recorded.chunk2 = Some(bytes.to_vec()); + recorded.chunk2_at = Some(Instant::now()); + } + Ok(Ok(None)) => note(&observation_for_task, "request ended before a second chunk arrived"), + Ok(Err(error)) => note(&observation_for_task, format!("error reading the second chunk: {error}")), + Err(_elapsed) => note(&observation_for_task, "timed out waiting for the second chunk"), + } + + if let Err(error) = timeout(FRAME_TIMEOUT, drain_to_end(&mut incoming)).await { + note(&observation_for_task, format!("timed out draining the request body: {error}")); + } + { + let mut recorded = observation_for_task.lock().expect("observation mutex poisoned"); + recorded.request_end_at = Some(Instant::now()); + } + + if let Err(error) = sender.send_data(Bytes::from_static(RESPONSE_FINAL_CHUNK)).await { + note(&observation_for_task, format!("could not send the final response chunk: {error}")); + } + drop(sender); + }); + + Ok::<_, Infallible>(response) + } + }); + + if let Err(error) = hyper::server::conn::http2::Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(tls), service) + .await + { + note(&observation, format!("HTTP/2 connection ended with an error: {error}")); + } + Ok(()) + } + + fn duplex_error_response() -> Response> { + let (sender, body) = Channel::::new(1); + drop(sender); + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(body) + .expect("a status code and an empty streaming body always build a valid response") + } + + /// Non-duplex baseline server: reads the entire request body before responding, matching + /// ordinary request/response handling for the sequencing control. + struct BaselineServer { + port: u16, + thread: JoinHandle>, + } + + impl BaselineServer { + fn start(certificate_name: &str) -> Result<(Self, SharedObservation)> { + let observed_sni = Arc::new(Mutex::new(None)); + let config = server_config(certificate_name, Arc::clone(&observed_sni))?; + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + listener.set_nonblocking(true)?; + + let observation: SharedObservation = Arc::new(Mutex::new(ServerObservation::default())); + let observation_for_thread = Arc::clone(&observation); + let thread = thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build()? + .block_on(serve_baseline(listener, config, observed_sni, observation_for_thread)) + }); + + Ok((Self { port, thread }, observation)) + } + + fn port(&self) -> u16 { + self.port + } + + fn join(self) -> Result<()> { + self.thread.join().map_err(|_panic| anyhow!("baseline server thread panicked"))? + } + } + + async fn serve_baseline( + listener: TcpListener, + config: ServerConfig, + observed_sni: Arc>>, + observation: SharedObservation, + ) -> Result<()> { + let listener = tokio::net::TcpListener::from_std(listener)?; + let (stream, _) = listener.accept().await?; + let tls = TlsAcceptor::from(Arc::new(config)).accept(stream).await?; + { + let mut recorded = observation.lock().expect("observation mutex poisoned"); + recorded.sni.clone_from(&observed_sni.lock().expect("SNI recorder poisoned")); + } + + let observation_for_service = Arc::clone(&observation); + let service = service_fn(move |request: Request| { + let observation = Arc::clone(&observation_for_service); + async move { + let mut incoming = request.into_body(); + let chunk1 = timeout(FRAME_TIMEOUT, next_data_frame(&mut incoming)) + .await + .map_err(|elapsed| anyhow!("timed out waiting for the first chunk: {elapsed}")) + .and_then(|inner| inner)? + .context("request ended before the first chunk arrived")?; + { + let mut recorded = observation.lock().expect("observation mutex poisoned"); + recorded.chunk1 = Some(chunk1.to_vec()); + recorded.chunk1_at = Some(Instant::now()); + } + + let chunk2 = timeout(FRAME_TIMEOUT, next_data_frame(&mut incoming)) + .await + .map_err(|elapsed| anyhow!("timed out waiting for the second chunk: {elapsed}")) + .and_then(|inner| inner)? + .context("request ended before the second chunk arrived")?; + { + let mut recorded = observation.lock().expect("observation mutex poisoned"); + recorded.chunk2 = Some(chunk2.to_vec()); + recorded.chunk2_at = Some(Instant::now()); + } + + timeout(FRAME_TIMEOUT, drain_to_end(&mut incoming)) + .await + .map_err(|elapsed| anyhow!("timed out draining the request body: {elapsed}")) + .and_then(|inner| inner)?; + + let response = Response::builder() + .status(StatusCode::OK) + .body(Full::new(Bytes::from_static(RESPONSE_FIRST_CHUNK))) + .expect("a status code and a fixed body always build a valid response"); + { + let mut recorded = observation.lock().expect("observation mutex poisoned"); + recorded.request_end_at = Some(Instant::now()); + recorded.response_sent_at = Some(Instant::now()); + } + + Ok::<_, anyhow::Error>(response) + } + }); + + hyper::server::conn::http2::Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(tls), service) + .await + .context("HTTP/2 baseline connection ended with an error") + } + + fn server_config(certificate_name: &str, observed_sni: Arc>>) -> Result { + let GeneratedCertificate { cert, signing_key } = generate_simple_self_signed(vec![certificate_name.to_owned()])?; + let private_key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der())); + let signing_key = any_supported_type(&private_key)?; + let resolver = Arc::new(RecordingResolver { + certified_key: Arc::new(CertifiedKey::new(vec![CertificateDer::from(cert.der().to_vec())], signing_key)), + observed_sni, + }); + let mut config = ServerConfig::builder().with_no_client_auth().with_cert_resolver(resolver); + config.alpn_protocols = vec![b"h2".to_vec()]; + Ok(config) + } + + #[derive(Debug)] + struct RecordingResolver { + certified_key: Arc, + observed_sni: Arc>>, + } + + impl ResolvesServerCert for RecordingResolver { + fn resolve(&self, client_hello: ClientHello<'_>) -> Option> { + *self.observed_sni.lock().expect("SNI recorder poisoned") = client_hello.server_name().map(ToOwned::to_owned); + Some(Arc::clone(&self.certified_key)) + } + } + + async fn next_data_frame(body: &mut Incoming) -> Result> { + loop { + match body.frame().await { + None => return Ok(None), + Some(Ok(frame)) => match frame.into_data() { + Ok(data) if !data.is_empty() => return Ok(Some(data)), + Ok(_) | Err(_) => {} + }, + Some(Err(error)) => return Err(error.into()), + } + } + } + + async fn drain_to_end(body: &mut Incoming) -> Result<()> { + while let Some(frame) = body.frame().await { + frame?; + } + Ok(()) + } + + #[derive(Debug)] + struct WinHttpError { + operation: &'static str, + code: u32, + } + + impl std::fmt::Display for WinHttpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} failed with Win32 error {}", self.operation, self.code) + } + } + + impl std::error::Error for WinHttpError {} + + struct InternetHandle(*mut c_void); + + impl InternetHandle { + fn new(handle: *mut c_void, operation: &'static str) -> Result { + if handle.is_null() { + return Err(last_error(operation)); + } + Ok(Self(handle)) + } + } + + impl Drop for InternetHandle { + fn drop(&mut self) { + // SAFETY: The handle is non-null, owned by this wrapper, and closed exactly once here. + unsafe { + WinHttpCloseHandle(self.0); + } + } + } + + /// A `Copy`, thread-movable handle value used only to hand the same request handle to a + /// matched send-only/receive-only thread pair, as `WinHTTP`'s concurrency documentation + /// permits. The owning `InternetHandle` in `DuplexRequest` is never dropped while any thread + /// holding a copy is still running, because `thread::scope` joins both threads first. + #[derive(Clone, Copy)] + struct SendPtr(*mut c_void); + + // SAFETY: See the `SendPtr` doc comment: WinHTTP documents that "an application may do a + // send-only operation on one thread at the same time that another thread is performing a + // receive-only operation" using the same request handle. This wrapper exists solely to move a + // copy of that handle into exactly one send-only and one receive-only thread for the duration + // of `run_concurrent_case`, never to close the handle or perform any other operation from + // those threads. + unsafe impl Send for SendPtr {} + + struct DuplexClient { + session: InternetHandle, + } + + impl DuplexClient { + fn open() -> Result { + let agent = wide("fetch-winhttp-full-duplex-probe"); + // SAFETY: All pointers reference valid, null-terminated UTF-16 strings for the call. + let session = unsafe { WinHttpOpen(agent.as_ptr(), WINHTTP_ACCESS_TYPE_NO_PROXY, ptr::null(), ptr::null(), 0) }; + Ok(Self { + session: InternetHandle::new(session, "WinHttpOpen")?, + }) + } + + fn start_post(&self, host: &str, port: u16, total_len: u32) -> Result { + let host_wide = wide(host); + // SAFETY: The session is live and the host pointer is valid for the call. + let connection = unsafe { WinHttpConnect(self.session.0, host_wide.as_ptr(), port, 0) }; + let connection = InternetHandle::new(connection, "WinHttpConnect")?; + + let verb = wide("POST"); + let path = wide("/"); + // SAFETY: The connection is live and all provided UTF-16 pointers remain valid. + let request = unsafe { + WinHttpOpenRequest( + connection.0, + verb.as_ptr(), + path.as_ptr(), + ptr::null(), + ptr::null(), + ptr::null(), + WINHTTP_FLAG_SECURE, + ) + }; + let request = InternetHandle::new(request, "WinHttpOpenRequest")?; + + let security_flags = SECURITY_FLAG_IGNORE_UNKNOWN_CA; + set_option( + &request, + WINHTTP_OPTION_SECURITY_FLAGS, + (&raw const security_flags).cast(), + size_of::().try_into()?, + "WINHTTP_OPTION_SECURITY_FLAGS", + )?; + + let protocols = WINHTTP_PROTOCOL_FLAG_HTTP2; + set_option( + &request, + WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL, + (&raw const protocols).cast(), + size_of::().try_into()?, + "WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL", + )?; + let required = 1_i32; + set_option( + &request, + WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED, + (&raw const required).cast(), + size_of::().try_into()?, + "WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED", + )?; + + // SAFETY: The request is live; these timeouts bound every subsequent blocking call so + // an unsupported or serialized handle state cannot hang this experiment indefinitely. + if unsafe { WinHttpSetTimeouts(request.0, RESOLVE_TIMEOUT_MS, CONNECT_TIMEOUT_MS, DATA_TIMEOUT_MS, DATA_TIMEOUT_MS) } == 0 { + return Err(last_error("WinHttpSetTimeouts")); + } + + // SAFETY: The request is live; no optional data accompanies the headers because the + // whole body is streamed afterward through WinHttpWriteData. + if unsafe { WinHttpSendRequest(request.0, ptr::null(), 0, ptr::null(), 0, total_len, 0) } == 0 { + return Err(last_error("WinHttpSendRequest")); + } + + Ok(DuplexRequest { request, connection }) + } + + /// Starts a POST request the same way `start_post` does, except the request handle is + /// opened with `WINHTTP_FLAG_AUTOMATIC_CHUNKING` and `WinHttpSendRequest` receives + /// `WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH` for `dwTotalLength`, matching a + /// gRPC/client-streaming upload whose total size is not known up front. This is the exact + /// native flag/total-length lowering `fetch_winhttp_impl` uses for an unknown-length + /// request body (`crates/fetch_winhttp_impl/src/body/write.rs`'s `RequestBodyFraming` and + /// `request.rs`'s `execute`, plus `convert.rs`'s `request_open_flags`, as landed by + /// PR #687): no `Transfer-Encoding` header is ever added by the caller, because `WinHTTP` + /// performs the chunked framing itself once `WINHTTP_FLAG_AUTOMATIC_CHUNKING` is set on the + /// request handle - `fetch_winhttp_impl` in fact rejects a caller-supplied + /// `Transfer-Encoding` header outright rather than forwarding it. Duplicated rather than + /// routed through `start_post` so the known-length setup this probe already validated is + /// never touched by the unknown-length path. + /// + /// `WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED` is set here exactly as `start_post` sets it: + /// `fetch_winhttp_impl`'s `protocol_options` sets it whenever the caller's supported HTTP + /// versions exclude HTTP/1.1, and PR #687's own + /// `http2_streams_unknown_length_uploads_and_preserves_response_trailers` integration test + /// drives exactly that combination successfully - `WINHTTP_FLAG_AUTOMATIC_CHUNKING` plus a + /// required HTTP/2 negotiation raises none of the earlier, invalid + /// `Transfer-Encoding`-header probe's conflicts. An earlier version of this probe added + /// `Transfer-Encoding: chunked` by hand instead of setting this flag; see + /// `docs/full-duplex-streaming-experiment.md` for why that was invalid. + fn start_post_unknown_length(&self, host: &str, port: u16) -> Result { + let host_wide = wide(host); + // SAFETY: The session is live and the host pointer is valid for the call. + let connection = unsafe { WinHttpConnect(self.session.0, host_wide.as_ptr(), port, 0) }; + let connection = InternetHandle::new(connection, "WinHttpConnect")?; + + let verb = wide("POST"); + let path = wide("/"); + // SAFETY: The connection is live and all provided UTF-16 pointers remain valid. + let request = unsafe { + WinHttpOpenRequest( + connection.0, + verb.as_ptr(), + path.as_ptr(), + ptr::null(), + ptr::null(), + ptr::null(), + WINHTTP_FLAG_SECURE | WINHTTP_FLAG_AUTOMATIC_CHUNKING, + ) + }; + let request = InternetHandle::new(request, "WinHttpOpenRequest")?; + + let security_flags = SECURITY_FLAG_IGNORE_UNKNOWN_CA; + set_option( + &request, + WINHTTP_OPTION_SECURITY_FLAGS, + (&raw const security_flags).cast(), + size_of::().try_into()?, + "WINHTTP_OPTION_SECURITY_FLAGS", + )?; + + let protocols = WINHTTP_PROTOCOL_FLAG_HTTP2; + set_option( + &request, + WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL, + (&raw const protocols).cast(), + size_of::().try_into()?, + "WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL", + )?; + let required = 1_i32; + set_option( + &request, + WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED, + (&raw const required).cast(), + size_of::().try_into()?, + "WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED", + )?; + + // SAFETY: The request is live; these timeouts bound every subsequent blocking call so + // an unsupported or serialized handle state cannot hang this experiment indefinitely. + if unsafe { WinHttpSetTimeouts(request.0, RESOLVE_TIMEOUT_MS, CONNECT_TIMEOUT_MS, DATA_TIMEOUT_MS, DATA_TIMEOUT_MS) } == 0 { + return Err(last_error("WinHttpSetTimeouts")); + } + + // SAFETY: The request is live; no optional data accompanies the headers because the + // whole body is streamed afterward through WinHttpWriteData, with WinHTTP performing + // the chunked framing itself under WINHTTP_FLAG_AUTOMATIC_CHUNKING. + if unsafe { WinHttpSendRequest(request.0, ptr::null(), 0, ptr::null(), 0, WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH, 0) } == 0 { + return Err(last_error("WinHttpSendRequest")); + } + + Ok(DuplexRequest { request, connection }) + } + } + + struct DuplexRequest { + request: InternetHandle, + // Declared after `request` so it is dropped after the request handle, and kept only to + // hold the connection open for the request's lifetime; its handle value is never read. + #[expect(dead_code, reason = "held only for RAII drop-order relative to `request`, never read")] + connection: InternetHandle, + } + + impl DuplexRequest { + fn raw(&self) -> *mut c_void { + self.request.0 + } + + fn write_chunk(&self, data: &[u8]) -> Result { + winhttp_write(self.raw(), data) + } + + fn receive_response(&self) -> Result<()> { + winhttp_receive_response(self.raw()) + } + + /// Ends a `WINHTTP_FLAG_AUTOMATIC_CHUNKING` upload with the documented null-buffer, + /// zero-length write, mirroring `fetch_winhttp_impl`'s + /// `WinHttpBodyWriter::end_automatic_chunking` exactly - a null `lpBuffer`, not a + /// zero-length write over a valid (if empty) buffer pointer. + fn end_automatic_chunking(&self) -> Result<()> { + winhttp_end_automatic_chunking(self.raw()) + } + + fn read_available(&self) -> Result>> { + winhttp_read_available(self.raw()) + } + + fn read_remaining(&self) -> Result> { + let mut all = Vec::new(); + while let Some(chunk) = self.read_available()? { + all.extend_from_slice(&chunk); + } + Ok(all) + } + + fn status_code(&self) -> Result { + let mut status = 0_u32; + let mut status_size = size_of::().try_into()?; + // SAFETY: The output pointers refer to initialized writable storage of the declared size. + if unsafe { + WinHttpQueryHeaders( + self.raw(), + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + ptr::null(), + (&raw mut status).cast(), + &raw mut status_size, + ptr::null_mut(), + ) + } == 0 + { + return Err(last_error("WinHttpQueryHeaders")); + } + Ok(status) + } + + fn protocol_used(&self) -> Result { + query_option_u32( + &self.request, + WINHTTP_OPTION_HTTP_PROTOCOL_USED, + "WINHTTP_OPTION_HTTP_PROTOCOL_USED", + ) + } + } + + fn winhttp_write(request: *mut c_void, data: &[u8]) -> Result { + let mut written = 0_u32; + let len = u32::try_from(data.len())?; + // SAFETY: `request` is a live WinHTTP request handle and `data` remains valid for the call. + if unsafe { WinHttpWriteData(request, data.as_ptr().cast(), len, &raw mut written) } == 0 { + return Err(last_error("WinHttpWriteData")); + } + Ok(written) + } + + /// Sends the documented null-buffer, zero-length `WinHttpWriteData` call that ends a + /// `WINHTTP_FLAG_AUTOMATIC_CHUNKING` request body, matching `fetch_winhttp_impl`'s + /// `WinHttpBodyWriter::end_automatic_chunking` (`body/write.rs`) exactly: a null `lpBuffer` + /// paired with a zero length, not `winhttp_write(request, &[])`'s valid-but-empty slice + /// pointer. Whether this distinction matters on native `WinHTTP` is untested by this probe - + /// it exists so the probe reproduces the same call PR #687 makes rather than an + /// implementation detail this probe happened to differ on. + fn winhttp_end_automatic_chunking(request: *mut c_void) -> Result<()> { + let mut written = 0_u32; + // SAFETY: `request` is a live WinHTTP request handle opened with + // WINHTTP_FLAG_AUTOMATIC_CHUNKING; a null buffer paired with a zero length is the + // documented way to end an automatically chunked upload. + if unsafe { WinHttpWriteData(request, ptr::null(), 0, &raw mut written) } == 0 { + return Err(last_error("WinHttpWriteData(terminal)")); + } + ensure!( + written == 0, + "the null-buffer terminal write reported writing a nonzero number of bytes" + ); + Ok(()) + } + + fn winhttp_receive_response(request: *mut c_void) -> Result<()> { + // SAFETY: `request` is a live request handle; the reserved parameter must be null. + if unsafe { WinHttpReceiveResponse(request, ptr::null_mut()) } == 0 { + return Err(last_error("WinHttpReceiveResponse")); + } + Ok(()) + } + + fn winhttp_read_available(request: *mut c_void) -> Result>> { + let mut available = 0_u32; + // SAFETY: `request` is a live request handle and `available` is a valid output location. + if unsafe { WinHttpQueryDataAvailable(request, &raw mut available) } == 0 { + return Err(last_error("WinHttpQueryDataAvailable")); + } + if available == 0 { + return Ok(None); + } + let mut buffer = vec![0_u8; available as usize]; + let mut read = 0_u32; + // SAFETY: `buffer` has `available` writable bytes and `read` is a valid output location. + if unsafe { WinHttpReadData(request, buffer.as_mut_ptr().cast(), available, &raw mut read) } == 0 { + return Err(last_error("WinHttpReadData")); + } + buffer.truncate(read as usize); + Ok(Some(buffer)) + } + + fn query_option_u32(handle: &InternetHandle, option: u32, operation: &'static str) -> Result { + let mut value = 0_u32; + let mut value_len = size_of::().try_into()?; + // SAFETY: The handle is live and the output buffer has the declared writable size. + if unsafe { WinHttpQueryOption(handle.0, option, (&raw mut value).cast(), &raw mut value_len) } == 0 { + return Err(last_error(operation)); + } + Ok(value) + } + + fn set_option(handle: &InternetHandle, option: u32, value: *const c_void, value_len: u32, operation: &'static str) -> Result<()> { + // SAFETY: The handle is live and value points to a buffer of value_len bytes for this call. + if unsafe { WinHttpSetOption(handle.0, option, value, value_len) } == 0 { + return Err(last_error(operation)); + } + Ok(()) + } + + fn last_error(operation: &'static str) -> anyhow::Error { + WinHttpError { + operation, + code: std::io::Error::last_os_error().raw_os_error().unwrap_or(0).cast_unsigned(), + } + .into() + } + + fn wide(value: &str) -> Vec { + value.encode_utf16().chain(Some(0)).collect() + } +}