diff --git a/Cargo.lock b/Cargo.lock index 0aeffa52e..b8b6094b0 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" @@ -1917,6 +1997,7 @@ dependencies = [ "http", "http-body", "pin-project-lite", + "tokio", ] [[package]] @@ -2650,6 +2731,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 +2875,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 +2900,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 +3009,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 +3688,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 +3952,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 +3982,7 @@ checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -5504,12 +5643,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..760baed9b --- /dev/null +++ b/crates/fetch/docs/design/README.md @@ -0,0 +1,332 @@ +# `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 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 + +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 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 +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. 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`: + +```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 transport-independent path 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 configuration registry + +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_config_mut::() + .ok_or(BuildError::WinHttpRequired)?; + +winhttp.use_integrated_proxy_discovery(true); +``` + +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. + +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 + +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`. + +## Transport composition and crate boundaries + +`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_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_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_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. + +| Crate | Responsibility | +| --- | --- | +| `fetch` | Stable client, pipeline, portable requirements, transport construction contract, and typed config registry | +| `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 | +| `fetch_m365` | Oxidizer runtime connectors and execution services | +| `*_config` companion | Introduced only when a library demonstrably needs dependency-light configuration after erasure | + +## Stability boundaries + +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. + +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`. + +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 + +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 library that intentionally requires the mechanism +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. +Supported transports choose and validate defaults for HTTP flow control, kernel buffering, and +congestion behavior. + +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 +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`. + +## 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 +requirements remain on `HttpClientBuilder` because libraries may own them. + +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 +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. 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 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. + +## 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. + +## Dependency and feature selection + +`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. + +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 + +`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. + +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 +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..87b662e78 --- /dev/null +++ b/crates/fetch/docs/design/capability-matrix.md @@ -0,0 +1,161 @@ +# 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. + +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 + +| 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 | `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 | 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 +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 | +| --- | --- | --- | --- | +| 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 | +| 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 | + +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 | +| --- | --- | --- | --- | +| 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 | 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 | +| 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 | +| 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 | +| 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 and WinHTTP do not. It therefore +cannot become a portable capability. A TVS library that retains it must select a supporting +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 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 +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 +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..7c1912a79 --- /dev/null +++ b/crates/fetch/docs/design/transport-configuration.md @@ -0,0 +1,519 @@ +# 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; +- 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. + +### 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 configuration values +registered by the selected transport: + +```rust,ignore +if let Some(options) = builder.transport_config_mut::() { + options.use_integrated_proxy_discovery(true); +} +``` + +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. + +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_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 +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_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_common`. 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_common::build(connector, requirements, context) + } +} +``` + +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 + | + v +unbuilt fetch_hyper_rustls or fetch_hyper_native_tls transport + | + | HttpClientBuilder::build(final requirements) + | + v +TLS connector composition + | + v +fetch_hyper_common connection policy and HTTP engine + | + v +Hyper HTTP/1.1 and HTTP/2 +``` + +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 + +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 | 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 | + +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 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 +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. + +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 + +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; +- 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. +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_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, +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. + +## 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. + +| 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 application through its transport composition dependency. +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 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 + +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. 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 +the supporting composition crate and must reject other transports. + +## 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 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. diff --git a/crates/fetch_winhttp/Cargo.toml b/crates/fetch_winhttp/Cargo.toml index 3b49f2e20..4c2ae10d6 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, 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", "time"] } +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..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. @@ -168,18 +169,41 @@ 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 -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. + +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 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. +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 @@ -219,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), @@ -239,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 @@ -344,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 c4e6771ab..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 | @@ -778,10 +773,23 @@ 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 +- 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 @@ -819,8 +827,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 @@ -996,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 @@ -1057,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 @@ -1119,6 +1122,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/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() + } +} 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() + } +}