diff --git a/Cargo.lock b/Cargo.lock index 0ca36ed3f..fa92ec477 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1930,6 +1930,7 @@ version = "0.2.1" dependencies = [ "all_the_time", "alloc_tracker", + "anyhow", "benchmarking", "bytes", "bytesbuf", @@ -1942,11 +1943,17 @@ dependencies = [ "http-body", "http-body-util", "http_extensions", + "hyper", + "hyper-util", "observed", "ohno 0.5.2", + "rcgen", + "rustls", "testing_aids", "tick", "tokio", + "tokio-rustls", + "windows-sys 0.61.2", ] [[package]] @@ -2494,6 +2501,7 @@ dependencies = [ "http", "http-body", "pin-project-lite", + "tokio", ] [[package]] diff --git a/crates/fetch/docs/design/README.md b/crates/fetch/docs/design/README.md new file mode 100644 index 000000000..7d289545d --- /dev/null +++ b/crates/fetch/docs/design/README.md @@ -0,0 +1,353 @@ +# `fetch` design + +Status: proposed stabilization contract. Existing transports may require implementation work before +this contract becomes the supported API. + +`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. + +During migration, existing transports may temporarily lag a newly defined baseline requirement. +That gap is tracked as implementation work and is not represented as a permanent partial capability +profile. + +The application configures transport-specific behavior before handing the transport to `fetch`: + +```rust,ignore +let transport = fetch_winhttp::WinHttpTransport::builder() + .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 + .tls_client_credential(fetch::ClientCredentialId::new("tvs-client")) + .tls_server_name( + fetch::Origin::https("localhost", service_port), + fetch::ServerName::new("tvs.prod.example")?, + ) + .connection_lifetime(Duration::from_mins(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. Building a client first +validates and resolves the final requirements, then produces a transport factory. The factory +materializes handlers lazily for the runtime partitions and dispatch pools selected by `fetch`. + +Validation receives the resolved portable requirements and shared assembly configuration. It +rejects unsupported values, missing credential bindings, and known host-version incompatibilities +before a client is returned. The resulting factory declares whether handlers are shared or isolated. + +`HttpClientBuilder` remains cloneable. The erased transport configuration therefore supports +internal cloning or shared ownership of immutable unbuilt configuration. That storage choice is +not exposed by the transport contract. Cloning a builder also clones its typed registry. Native +sessions and pooled connections are never captured in the builder, and every `build` creates an +independent validated factory. + +Materialization receives per-instance services such as response-body infrastructure, telemetry, +runtime-thread affinity, and pool identity. It remains fallible because acquiring OS resources can +fail later, especially when an isolated client first reaches another runtime thread. Such a failure +creates an explicitly failed handler for that partition; it is not converted into a success-shaped +default or silently ignored. + +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 validates TLS, SNI, ALPN, and +credential configuration and produces a factory. Each factory materialization 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, generic validation/factory 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 validation and +factory 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 qualification remains required before claiming the invariant for that +WinHTTP code 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 response returned while upload continues exposes an +`UploadCompletion` future. Callers using duplex or streaming request bodies await it to observe +late body/trailer failures and successful half-close. Convenience APIs that consume an entire +response await both response completion and upload completion. Dropping either side cancels the +shared request according to the normal cancellation contract. + +Response decompression is owned by a `fetch` layer immediately above every transport, including +minimal and custom pipelines. Transports return wire-encoded bodies and do not enable native +automatic decompression. When `DecompressionOptions` enables formats, `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..6851e0217 --- /dev/null +++ b/crates/fetch/docs/design/capability-matrix.md @@ -0,0 +1,164 @@ +# Transport capability matrix + +Status: target capability contract. “Supported” describes available backend/native capability; +the merged implementations may still need wiring identified by this design. + +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 target contract grounded in available native mechanisms, the +merged implementation, and the executable capability probes linked from this design. + +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 | Session-generation rollover retires the pool no later than the bound | 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; calibrated HTTP/1.1 measurements match `TCP_NODELAY`, HTTP/2 still needs qualification | 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 | Native upload/download paths implemented | Required `Transport` invariant | +| Full-duplex HTTP/2 | Supported | Native behavior demonstrated on Windows 11 build 26100; directional implementation required | 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 | Transport can preserve encoded responses | Native support differs by encoding | Owned by configured fetch-level decompression; 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..f1dfe54b2 --- /dev/null +++ b/crates/fetch/docs/design/transport-configuration.md @@ -0,0 +1,578 @@ +# Transport configuration + +Status: proposed stabilization contract. Examples describe the target API rather than the current +implementation. + +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` is dyn-compatible, validates the complete library-facing baseline, and produces a +factory: + +```rust,ignore +pub trait Transport: Send + Sync + 'static { + fn register_config(&self, registry: &mut TransportConfigRegistry); + + fn validate( + &self, + requirements: TransportRequirements, + registry: TransportConfigRegistry, + context: TransportContext, + ) -> Result; +} + +pub struct TransportFactory { + // Type-erased, cloneable materialization closure and isolation policy. +} + +impl HttpClient { + pub fn builder(transport: T) -> HttpClientBuilder { + Self::builder_erased(Box::new(transport)) + } + + pub fn builder_erased(transport: Box) -> HttpClientBuilder; +} +``` + +The application passes any complete transport to the same constructor: + +```rust,ignore +let builder = HttpClient::builder(transport); +``` + +`validate` performs value and known-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. + +`HttpClient::builder` boxes the concrete transport internally and asks it to populate the typed +registry. Applications that already select a transport dynamically can call `builder_erased`. +`Transport` itself is dyn-compatible and does not prescribe `Box`, `Arc`, or cloning in its method +receivers. + +The builder may convert the box to shared internal storage or use another cloneable erasure +mechanism. Each `build` calls `validate` by shared reference. Validation creates an independently +owned factory from the immutable transport configuration plus the consumed requirements, registry, +and context. Built clients therefore do not share sessions or pools unless the returned factory +explicitly defines a shared isolation policy. + +This separation is intentional: `&self` models reusable composition configuration, while +`TransportFactory` owns validated per-client state. A concrete transport chooses how to clone or +share its own configuration when constructing that factory. + +The factory receives no generic TLS or connection-options bag. Each implementation captures its +validated configuration and translates semantic requirements directly into each materialized +handler. + +## 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) + .tls_client_credential(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 validate( + &self, + requirements: TransportRequirements, + registry: TransportConfigRegistry, + context: TransportContext, + ) -> Result { + let validated = self.validated_config(&requirements, ®istry)?; + Ok(TransportFactory::new(validated.isolation(), move |instance| { + let connector = validated.build_tls_connector(&requirements, &instance)?; + fetch_hyper_common::build( + connector, + &requirements, + &context, + instance, + ) + })) + } +} +``` + +Both composition crates materialize the same `fetch_hyper_common` handler; neither owns a second +pool or HTTP implementation. Validation is deferred until libraries have added strict HTTP/2, +TLS-name mappings, or credential requirements. Handler materialization remains later and +repeatable, matching shared or per-runtime-thread transports and multiple dispatch pools. + +```text +raw runtime connector + | + v +unbuilt fetch_hyper_rustls or fetch_hyper_native_tls transport + | + | HttpClientBuilder::build(final requirements): validate + | + v +validated transport factory + | + | materialize per runtime partition and pool + v +TLS connector composition and OS resource acquisition + | + 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.tls_client_credential(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 rotates session generations at the configured bound: new requests use the new generation, +active requests drain on the old one, and the drained session closes its pool. Both satisfy the +same upper-bound contract, though WinHTTP may retire younger connections early. + +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 +is returned by the response's `UploadCompletion` future. + +```rust,ignore +let body = body.with_trailers(async { + Ok(HeaderMap::from_iter([("digest", computed_digest()?)])) +}); + +let response = client.execute(request).await?; +let upload = response.upload_completion(); +// The response body and upload may be driven concurrently. +upload.await?; +``` + +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 +`fetch` normalization layer applies configured `DecompressionOptions`: it advertises enabled +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. + +The current `SocketOptions`/`TokioTransportOptions` setters are migration residue and are removed +from the stabilized surface. Socket-owning implementations still use the same low-level controls +internally where required by these policies. + +## 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 079ae4907..e529a0981 100644 --- a/crates/fetch_winhttp/Cargo.toml +++ b/crates/fetch_winhttp/Cargo.toml @@ -59,6 +59,7 @@ benchmarking = { path = "../benchmarking" } criterion = { workspace = true } [target.'cfg(windows)'.dev-dependencies] +anyhow = { workspace = true, features = ["std"] } bytes = { workspace = true } bytesbuf = { path = "../bytesbuf", default-features = false } ctor = { workspace = true } @@ -70,15 +71,21 @@ fetch_winhttp_impl = { path = "../fetch_winhttp_impl", features = ["private-test futures = { workspace = true, features = ["executor", "std"] } http = { workspace = true } http-body = { workspace = true } -http-body-util = { workspace = true } +http-body-util = { workspace = true, features = ["channel"] } http_extensions = { path = "../http_extensions" } +hyper = { workspace = true, features = ["http2", "server"] } +hyper-util = { workspace = true, features = ["tokio"] } observed = { path = "../observed" } ohno = { path = "../ohno", features = ["app-err"] } +rcgen = { workspace = true, features = ["aws_lc_rs"] } +rustls = { workspace = true, features = ["aws-lc-rs", "std"] } testing_aids = { path = "../testing_aids" } # `test-util` freezes time in tests and benches; `tokio` drives a real clock in # the standalone examples. tick = { path = "../tick", features = ["test-util", "tokio"] } -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["macros", "net", "rt", "rt-multi-thread", "time"] } +tokio-rustls = { workspace = true, features = ["aws_lc_rs"] } +windows-sys = { workspace = true, features = ["Win32_Networking_WinHttp"] } [[test]] name = "non_windows" diff --git a/crates/fetch_winhttp/docs/design.md b/crates/fetch_winhttp/docs/design.md index 8d5cc1906..d17de1226 100644 --- a/crates/fetch_winhttp/docs/design.md +++ b/crates/fetch_winhttp/docs/design.md @@ -1,6 +1,9 @@ # `fetch_winhttp` design -This document describes the user-visible behavior and design tenets of the +Status: target contract for the `fetch` stabilization work. The merged implementation +provides the native foundation but does not yet implement every behavior in this document. + +This document describes the intended user-visible behavior and design tenets of the `fetch_winhttp` crate. The implementation strategy - threading, FFI ownership, pooling, body-streaming mechanics, and the testing strategy - is documented separately in [implementation.md](implementation.md). Runnable demonstrations of each feature @@ -11,18 +14,17 @@ area live in `crates/fetch_winhttp/examples/`. `fetch_winhttp` is a Windows-only custom transport for the [`fetch`] HTTP client. It services `fetch` requests by driving the operating system's [WinHTTP] client API in asynchronous WinHTTP I/O mode, as an alternative to the bundled -`fetch_hyper` (hyper + rustls/native-tls) transport. +Hyper composition transports. Why a WinHTTP transport: - **OS-managed TLS/trust.** WinHTTP terminates TLS through Schannel and uses the Windows certificate stores and system trust policy. Applications that must honor enterprise trust configuration or CTLs get that without bundling a userland - TLS stack. (Client certificates are a Schannel capability but are not exposed in - v1; see §4.1.) + TLS stack. - **OS-managed protocol stack.** HTTP/1.1, HTTP/2 and HTTP/3 negotiation, - connection pooling, keep-alive and automatic gzip/deflate decompression are - handled by the OS. + connection pooling, and keep-alive are handled by the OS. Response decompression + remains in `fetch` so content behavior is transport-independent. - **Smaller dependency surface.** No rustls/aws-lc-rs/native-tls/hyper on the request path. @@ -32,78 +34,46 @@ WebSocket upgrades; proxies (§2.3). ### 1.1 Constructing a client -A caller builds a WinHTTP-backed client the same way as the bundled Tokio transport, -except the constructors arrive through an extension trait this crate implements on -`fetch::HttpClient` (imported into scope): +The application configures an unbuilt WinHTTP transport and passes it to `fetch`: ```rust,ignore use fetch::HttpClient; -use fetch_winhttp::{HttpClientWinHttpExt, WinHttpDeps, WinHttpTlsConfig}; - -// Clock, memory pool, and telemetry sink come from the application's environment. -// TLS configuration defaults when omitted. -let deps = WinHttpDeps::builder(clock, global_pool, sink) - .tls(WinHttpTlsConfig::builder() - .accept_invalid_certs(true) // Schannel knobs, §4 - .build()) +use fetch_winhttp::WinHttpTransport; + +let transport = WinHttpTransport::builder() + .prefer_http3(true) + .client_certificates(certificate_catalog) .build(); -let client = HttpClient::builder_winhttp(deps) - .build(); // a `fetch::HttpClientBuilder`, so the pipeline can be tuned first +let client = HttpClient::builder(transport) + .build()?; ``` -The result is an ordinary `fetch` `HttpClient`; no other caller code changes. -`WinHttpDeps` carries the mandatory environment dependencies needed by this transport: -the timer-capable `tick::Clock`, `bytesbuf::mem::GlobalPool`, and `observed::Sink`. -These values cannot be invented by the crate and therefore have no defaults. Its TLS -configuration is user configuration and does default. `WinHttpDeps` and its -component config types are `#[non_exhaustive]` and constructed through builders so new -fields can be added compatibly: - -```rust,ignore -/// WinHTTP-specific dependencies. Construct with [`WinHttpDeps::builder`]. -#[derive(thread_aware::ThreadAware)] -#[non_exhaustive] -pub struct WinHttpDeps { /* clock, global pool, sink, TLS - private */ } - -impl WinHttpDeps { - /// Starts building a `WinHttpDeps`. - pub fn builder( - clock: tick::Clock, - global_pool: bytesbuf::mem::GlobalPool, - sink: observed::Sink, - ) -> WinHttpDepsBuilder; -} - -/// Adds WinHTTP-transport constructors to `fetch::HttpClient`. -pub trait HttpClientWinHttpExt { - /// Returns a builder for an `HttpClient` on the WinHTTP transport. - fn builder_winhttp(deps: impl Into) -> HttpClientBuilder; -} -``` +The result is an ordinary `fetch::HttpClient`. `fetch` supplies per-instance clocks, +memory pools, telemetry, runtime affinity, and dispatch-pool identity through its +transport factory context. The WinHTTP builder contains only composition configuration. -`WinHttpTlsConfig` (§4) follows the same builder + `#[non_exhaustive]` pattern. +The transport validates the final portable requirements when `HttpClientBuilder::build` +runs, then produces an isolated factory. The factory creates one WinHTTP session for +each runtime-thread and dispatch-pool partition. Session acquisition can still fail when +a partition materializes later; that partition becomes an explicit failed handler. -### 1.2 TLS is configured on the transport, not through `fetch`'s `TlsOptions` +### 1.2 Transport-specific configuration -`fetch`'s generic `TlsOptions`/`TlsBackend` carries rustls/native-tls material -(crypto providers, verifiers, client-cert resolvers) that is meaningless to -Schannel. WinHTTP does TLS itself and accepts only a small set of knobs, so -`fetch_winhttp` therefore ignores `fetch`'s TLS configuration entirely and takes its -own `WinHttpTlsConfig` instead (§4). Different transports inherently support different TLS -configuration models, so trying to configure TLS uniformly at the transport-abstract -`fetch` level is over-abstraction on `fetch`'s part; see the fetch API stabilization -feedback (../../fetch/docs/stabilization.md). +Schannel mechanisms, HTTP/3 preference, and certificate provisioning belong to the +WinHTTP composition builder. A dependency-light companion configuration crate is added +only when libraries demonstrate a need to modify a WinHTTP setting after transport +erasure. Rustls/native-tls objects are never accepted or ignored by this transport. ### 1.3 Platform support -The transport requires Windows 11 version 21H2 (build 22000) or later, or -Windows Server 2025 (build 26100) or later. Windows Server 2022 (build 20348) -is not supported: the WinHTTP response-header query capabilities the transport -relies on are documented as introduced in build 22000, which Windows Server 2022 -predates. The crate does not probe the OS build at session construction; on a -below-floor host the client still builds and failures surface later as ordinary -`request_winhttp` errors on the first request that needs the missing capability. +The target contract requires an explicitly qualified Windows SKU/build. Full-duplex +send/receive behavior is empirically verified on Windows 11 version 24H2 build 26100, +while Microsoft documents it only as available on "some versions of Windows." A build +number alone is not proof: every supported client and server baseline must pass the +executable duplex probe before release. Unqualified builds are rejected during transport +validation. Resource failures that occur while materializing a later isolated partition +remain per-partition failures. ## 2. Connection management @@ -116,37 +86,19 @@ each other's connections. This is a security boundary: a strict client and one b with `accept_invalid_certs` (§4) cannot share an established TLS connection. The contract does not specify how connections are organized or reused within one client. -### 2.1 Mapping generic transport options onto WinHTTP - -The generic `TransportOptions`, `Http2Options`, and `TlsOptions` accepted by `fetch` -do not map one-to-one onto WinHTTP behavior, so some values are exact, some -approximate, and some ignored: - -| `fetch` option | Contract | -|----------------|----------| -| `connect_timeout` | Honored as a total connection-establishment deadline (§6.2). | -| `request_filter` | Honored. | -| `supported_http_versions` | Honored for HTTP/1.1, HTTP/2, and HTTP/3; other versions are rejected. | -| `multiple_pools` | Accepted; its behavior remains defined by the generic `fetch` client contract. | -| `max_connections = usize::MAX` (default) | Honored as no caller-imposed limit. | -| finite `max_connections` | Ignored. | -| `connection_idle_timeout` | Honored, raised to a minimum of 5 seconds and approximated above roughly 49 days. Bounds how long an unused connection stays eligible for reuse; it does not promise prompt socket release. | -| `connection_lifetime = Unlimited` (default) | Honored. | -| `connection_lifetime = Fixed(_)` / `PerConnection(_)` | Ignored (§2.2). | -| `ConnectionKeepAlive::Disabled` (default) | Uses Windows defaults. | -| `ConnectionKeepAlive::ActiveConnections { interval, timeout }` | The interval is honored on HTTP/2, raised to a minimum of 5 seconds, and on HTTP/3, raised to a minimum of 1 millisecond. It does not apply to HTTP/1.1, which has no keep-alive probe to send. The generic `timeout` is ignored. | -| `ConnectionKeepAlive::ActiveAndIdleConnections { interval, timeout }` | Behaves like `ActiveConnections`; Windows does not distinguish these modes. | -| `Http2Options::initial_max_send_streams` | Ignored; Windows owns HTTP/2 stream concurrency. | -| `Http2Options::adaptive_window` | Ignored; Windows owns HTTP/2 flow control. | -| `TransportOptions::extra` | Ignored; no v1 WinHTTP extension types are defined in the generic extension map. | -| generic TLS `supported_http_versions` | Ignored; protocol selection comes from `TransportOptions::supported_http_versions`. | -| generic TLS `client_identity` | Ignored; client certificates are out of scope (§4.1). | -| generic TLS automatic/backend selection | Ignored; Schannel/WinHTTP is always the backend. | -| preconfigured rustls/native-tls backend | Ignored; those backend objects cannot configure WinHTTP. | -| rustls crypto provider or certificate verifier | Ignored; Schannel owns cryptography and certificate verification. | -| rustls client-certificate resolver | Ignored; client certificates are out of scope (§4.1). | - -(The option mapping and the reasoning behind each floor are implementation.md §10.3.) +### 2.1 Portable connection requirements + +The transport honors the complete portable connection contract: + +- total connect deadline; +- maximum idle age, accepting values whose WinHTTP representation still implies the bound; +- total concurrent connections per origin through `WINHTTP_OPTION_MAX_CONNS_PER_SERVER`; +- maximum connection lifetime through session-generation rollover; +- dispatch-pool isolation supplied by `fetch`. + +A value WinHTTP cannot honor is rejected during validation. HTTP/2 flow-control, +socket-buffer, keep-alive-probe, and pool-internal knobs are not portable requirements and +are not received by this transport. `ConnectionInfo` (age, `is_expired`, poisoning) that `fetch_hyper` attaches to responses is not reproduced: this transport does not track the identity, age, or @@ -162,24 +114,11 @@ to bound how long any single TCP/TLS connection stays in service so long-lived clients periodically re-establish connections (load-balancer rebalancing, cert rotation, routing changes). -**v1 ignores `connection_lifetime` for `Fixed` and `PerConnection`.** The transport -does not track the identity or age of individual connections (§2.1), so a bounded -connection age is not part of its contract. - -`connection_idle_timeout` is honored and bounds how long an unused connection stays -eligible for reuse. It does not bound the age of a continuously busy connection, -which is the gap a caller setting `connection_lifetime` should expect to remain -open. - -Windows expresses this window as an unsigned millisecond count with no "never evict" -encoding, so `ConnectionIdleTimeout::Unlimited` and any window longer than roughly -49 days both become the longest window Windows can express. A caller asking for -indefinite retention gets a window long enough that no practical deployment reaches -it, but not a guarantee that idle eviction is disabled. - -Unsupported generic connection options are ignored without runtime diagnostics. Their -fidelity is documented here so callers can select transport-specific configuration -knowingly. +WinHTTP does not expose physical connection age, so the transport enforces maximum +lifetime with session generations. Once a generation reaches the configured age, new +requests use a fresh session while active requests drain on the old generation. Closing +the drained session retires all remaining pooled connections. Younger connections may be +retired early, which still satisfies the upper-bound contract. ### 2.3 Proxy support @@ -195,18 +134,39 @@ outright is both simpler and faster than configuring it away. A caller who needs a proxy is not served by this transport. Supporting one would be a feature in its own right, with its own configuration surface, and is not planned. +### 2.4 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 newer protocols. A removed preference is not a conflict. -- 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 a closed protocol list. There is no +WinHTTP-specific `require_http3`; HTTP/3 becomes a portable requirement only after every +supported transport implements it. 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 @@ -251,43 +211,34 @@ bundle and configures only a small set of `WinHttpTlsConfig` knobs (§1.2): (How these knobs reach Schannel is implementation.md §10.2.) -### 4.1 Client certificates (mTLS) are out of scope for v1 +### 4.1 Named client certificates -`fetch` does not require a transport to support client certificates: its mTLS surface -(`fetch::tls::ClientIdentity`) travels inside the generic `TlsOptions` that `fetch_winhttp` -deliberately ignores (§1.2), and a transport that offers no client identity is a -conforming `fetch` transport. Client certificates are an uncommon feature the large -majority of callers never use, and supporting them is a self-contained chunk of future -work with its own lifetime and ownership concerns. +Named client-certificate authentication is part of the portable baseline. Libraries +select a logical credential role; the WinHTTP composition binds that role to a +Windows-store selector or imported certificate/private-key material. -v1 therefore does not implement client certificates; `WinHttpTlsConfig` exposes no -client-identity field. A later iteration can add a WinHTTP-specific client-identity type -if a concrete need appears. +When a server requests a certificate, the transport queries its acceptable issuer list, +selects a compatible binding, attaches the resulting `PCCERT_CONTEXT` through +`WINHTTP_OPTION_CLIENT_CERT_CONTEXT`, and retries without exposing the provisioning +modality to the library. Missing or ambiguous bindings fail transport validation or the +authentication attempt explicitly. ## 5. WinHTTP-managed HTTP behavior 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 returned, 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.** The encoded body and original headers + reach the fetch-level streaming decompression layer. - **Request-body compression.** Not performed automatically; a caller that pre-encodes its body and sets `Content-Encoding` has it sent as-is. -- **Request-response sequencing.** The request body is fully sent before response - reception begins. -- **Trailers.** Response trailers exposed by WinHTTP are returned as `HttpBody` trailer - frames rather than discarded. HTTP/1.1 supports trailer fields after a chunked body, - but WinHTTP does not expose them; response trailers are therefore available only for - HTTP/2 and HTTP/3. Outgoing trailer frames are unsupported and fail the request rather - than being silently dropped. A trailer frame is reached only once the body yields it, - so that failure arrives after the headers and every preceding data frame have been - sent (§7). +- **Full duplex.** For HTTP/2, response headers and body data may arrive while a known- or + unknown-length upload continues. The send and receive lanes have independent operation + slots and share one cancellation lifetime. +- **Trailers.** Response trailers are queried after EOF and returned as terminal body + frames for every protocol on which WinHTTP exposes them, including HTTP/1.1 on the + supported platform. A request declaring trailers is rejected during preflight before + its body is polled or request bytes are sent, because WinHTTP has no send API for them. - **`Transfer-Encoding` is rejected in request headers.** The transport derives request framing from the body itself, so a caller-supplied transfer coding fails the request with `invalid_request` (§7) before anything is sent. Removing the header does not change @@ -437,13 +388,14 @@ mapping below reflects the transport's current judgement and may change. 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. ## 8. Telemetry -The transport reports through the `observed::Sink` supplied in `WinHttpDeps` (§1.1). +The transport reports through the `observed::Sink` supplied by the per-instance transport +context (§1.1). The event, counter, and field names below are a stable surface that dashboards and alerts bind to; they are part of the contract, not incidental diagnostics. 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..5430f2dc7 --- /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.95.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.95.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 d4091e538..45fe21d1b 100644 --- a/crates/fetch_winhttp/docs/implementation.md +++ b/crates/fetch_winhttp/docs/implementation.md @@ -1,5 +1,8 @@ # `fetch_winhttp` implementation +Status: target implementation after `fetch` stabilization. Existing production code is the +foundation and may lag this document until the redesign is implemented. + This document describes the implementation strategy of the `fetch_winhttp` crate: the OS bindings facade, the WinHTTP asynchronous model, the threading and cancellation/FFI-ownership machinery, object pooling, body-streaming mechanics, @@ -131,7 +134,7 @@ crates/fetch_winhttp/ // published facade crates/fetch_winhttp_impl/src/ // implementation lib.rs // module declarations + re-exports for the facade - builder.rs // WinHttpDeps/WinHttpDepsBuilder and client-builder integration + builder.rs // unbuilt transport configuration and fetch::Transport integration transport.rs // WinHttpTransport: per-(thread × pool-slot) RequestHandler (§3.2) session.rs // WinHttpSession: per-(thread × pool-slot) session handle (§3.2) request.rs // RequestDriver: drives one request/response lifecycle (§6.3) @@ -235,7 +238,7 @@ one binary owns one contract area: ```text crates/fetch_winhttp/tests/ protocols.rs // negotiated-version reporting and per-protocol round trips - // (HTTP/1.1, HTTP/2, HTTP/3), including required-h3 failure + // (HTTP/1.1 and HTTP/2, plus WinHTTP HTTP/3 preference) tls.rs // the certificate-validation relaxation matrix transport_policy.rs // request framing, trailer rejection, decoding, redirects, // cookies, authentication challenges @@ -259,7 +262,7 @@ endpoint or on real-time waiting: ```text crates/fetch_winhttp/examples/ - quick_start.rs // builder_winhttp and the mandatory WinHttpDeps environment + quick_start.rs // WinHttpTransport builder and HttpClient::builder composition streaming_upload.rs // unknown-length uploads and request-trailer rejection streaming_download.rs // frame-by-frame bodies, response trailers, mid-stream drop tls_validation.rs // the strict default and the two independent relaxations @@ -431,7 +434,7 @@ is no per-request or per-handle fixed-thread placement: successive completions f request can land on different workers, so no callback may assume it runs on the thread that submitted the operation or on the same worker as the previous completion. Soundness rests on documented properties: "exactly one completion per async -operation", "one operation outstanding per handle", and "`HANDLE_CLOSING` is the final +operation", "one operation outstanding per directional lane", and "`HANDLE_CLOSING` is the final notification for a handle and does not overlap another callback for that handle" (§4.5). The remaining completion-versus-synchronous-failure race is closed by an atomic (§4.5). @@ -463,12 +466,13 @@ across threads.) execution is the default because minimizing sharing is more efficient. That the `!Sync` `plurality` context pool (§5) can stay instance-local follows from the choice rather than motivating it. The handler must still be `Sync`, so that pool -sits behind a coarse `Mutex` (§5). `WinHttpDeps` derives `ThreadAware` so `fetch` -can clone and relocate the configuration per thread. +sits behind a coarse `Mutex` (§5). The validated factory configuration is +relocatable so `fetch` can clone it per thread. The OS session - which owns session-scoped state, most importantly the connection (keep-alive) pool - is opened by the factory when `fetch` materializes a per-thread -transport instance, from the finalized `CustomContext`, not eagerly in `builder_winhttp`. +transport instance, from the finalized `TransportInstanceContext`, not eagerly in the +composition builder. This is deliberate: `HttpClientBuilder` is `Clone`, so a session opened up front and captured in the (clone-shared) factory closure would be shared by every client built from that builder or any clone of it, letting two independently built clients reuse each @@ -486,15 +490,11 @@ cross-thread pool - one pool per thread in the default single-slot case. That is an acceptable, even preferable, trade: thread-local pools stay warm and uncontended (see Future exploration below). A single session shared across a client's threads *and* isolated between independently built clients is not -expressible with today's custom-transport API - it exposes only builder-scoped -state (shared across clones) or per-thread/per-slot state (not shared across -threads), with no per-built-client scope - so it is noted as `fetch` API -feedback (../../fetch/docs/stabilization.md, connection-management item). Each -instance's session is immutable after setup, so a plain `Arc` cloned into that -instance's in-flight requests suffices. The instance-local context pool is the -only mutable shared state (`Mutex`-guarded, §5), while the read-buffer -`GlobalPool` is already thread-safe. All are normally uncontended under -thread-isolated use. +selected by the factory's isolation policy. Each instance's session is immutable +after setup, so a plain `Arc` cloned into that instance's in-flight requests +suffices. The instance-local context pool is the only mutable shared state +(`Mutex`-guarded, §5), while the read-buffer `GlobalPool` is already thread-safe. +All are normally uncontended under thread-isolated use. **Contrast with `fetch_hyper`.** `fetch_hyper` uses `Isolation::Shared`: one hyper client, already fully thread-safe, shared across threads, so its pool is @@ -571,7 +571,7 @@ the driver's own thread instead. `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 handle use Raw WinHTTP handles are `*mut c_void` and thus neither `Send` nor `Sync`. The explicit unsafe markers live on exactly one type, the `RawHandle` newtype in `handle.rs`, justified @@ -581,13 +581,11 @@ token, so no wrapper repeats the assertion; a wrapper instead *withdraws* what i offer, by holding a `PhantomData>` that removes `Sync`. The tiers differ 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 the `not_sync` marker on `ConnectHandle` and - `RequestHandle` is what holds them to it. +- **Request handles are `Send + Sync` behind shared request state.** One send-only and + one receive-only operation may overlap. The wrapper exposes only operations that + preserve this directional rule, and the full-duplex integration probe covers it. +- **Connect handles are `Send` but not `Sync`.** Each belongs to one request and is + retained for lifetime only after setup; it is never used concurrently. - **The session handle is `Send + Sync`.** A session `Arc` is cloned into every in-flight request on its thread and is touched by WinHTTP's process-global callback threads (§3.1), so it is shared by reference across threads. @@ -616,22 +614,19 @@ 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 - -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` contains an operation slot plus the parent handles whose -lifetime must extend through the request handle's final callback. The operation slot -is reused across the request's sequence of sequential operations (send, each request -write, receive, then each response 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. - -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 -the driver uses the handle to issue the next call and, once, to close. The connect -handle and session owner move into the context before the context pointer is handed -to WinHTTP, so closing the +### 4.1 Per-direction operation slots + +WinHTTP allows one send-only and one receive-only operation to overlap on the supported +platform, while permitting only one outstanding operation within each direction. It +delivers every completion for a handle to the same callback context pointer. +`RequestContext` contains independent send and receive slots plus the parent handles +whose lifetime must extend through the request handle's final callback. Each slot is +reused by sequential operations in its direction. + +The request handle lives in shared request state (§4.4), not in this context: the +callback only recovers the context, selects the send or receive slot, takes that +sender and buffer, and signals (§2.1). The connect handle and session owner move into +the context before the context pointer is handed to WinHTTP, so closing the request cannot invalidate its parents while WinHTTP is still tearing it down. WinHTTP specifies that closing a handle invalidates its children, so a connect or session handle must outlive every request opened from it @@ -639,10 +634,8 @@ session handle must outlive every request opened from it ```rust,ignore struct RequestContext { - // Reused storage through which the callback hands completions back to the - // driver. The request handle itself is not here: OperationFuture owns it - // until its receiver endpoint is destroyed. - operation: CallbackOperationSlot, + send: CallbackOperationSlot, + receive: CallbackOperationSlot, // Parent handles, retained until HANDLE_CLOSING drops the context. They must // outlive the request because Microsoft documents that closing a parent // invalidates its children and that pending child operations cannot then be @@ -701,15 +694,12 @@ distinguishable from "no callback arrived". `ColdConnectDiagnostics` wraps an `AtomicU8` holding a `ColdConnectState` discriminant and exposes typed transitions and a typed read. Both keep `RequestContext` free of bit arithmetic. -The active operation makes the field relationships explicit: it always carries a -completion sender and at most one borrowed buffer (a handle never has a read and a write -outstanding at once); the idle state carries neither. Sequential submission is -guaranteed by construction: `OperationFuture` exclusively borrows `RequestGuard` and -moves the request handle out of it. Safe code therefore has no request handle -with which to arm another operation until the current receiver is destroyed and -completion restores the handle. Forgetting the future leaves the handle leaked -inside it rather than making the guard reusable. A debug assertion checks that -invariant when the slot is armed. +Each active slot carries one completion sender and at most one borrowed buffer. The +send and receive slots may be active simultaneously and borrow disjoint buffers. +Sequential submission within a direction is guaranteed by its directional driver, +which cannot arm that slot again until the previous event reaches its terminal state. +The shared request handle exposes only send-lane and receive-lane operations, so safe +code cannot create two operations in one lane. The atomic state has a separate production responsibility. It publishes the initialized payload to callback threads and lets exactly one of a completion @@ -883,8 +873,8 @@ self-scheduled request-phase timer. Response timeout is already wrapped around t pipeline by `fetch`, and body idle timeout is applied by `HttpBodyBuilder`. No native WinHTTP timer bounds any phase; all four are programmed unlimited (§10.4). The `RequestDriver` races the connect/send phase against a single -`tick::Clock::delay(connect_timeout)`, using the clock already threaded in from -`CustomContext` (no new dependency). Whichever finishes first wins. If the timer +`tick::Clock::delay(connect_timeout)`, using the clock supplied in +`TransportInstanceContext` (no new dependency). Whichever finishes first wins. If the timer fires, the operation future snapshots cold-connect attribution while it still owns the live request, then drops its receiver and closes the handle; this cancels the in-flight connect (§4.3) without dereferencing the context after an @@ -914,12 +904,9 @@ has been destroyed. `HANDLE_CLOSING` reconstructs it with `plurality::Box::from_raw` and drops it, and that `Drop` returns the slot to the pool on its own. -Read buffers come from the separate shared memory pool. `WinHttpDeps` retains a clone -of its mandatory `bytesbuf::mem::GlobalPool` in the transport extras while also -supplying that pool to `fetch::custom::CustomDeps` for the response -`HttpBodyBuilder`. Each materialized -transport receives the retained clone through `CustomContext::extras`; the body reader -clones it and rents buffers with no lock. +Read buffers come from the memory pool supplied in `TransportInstanceContext`. The body +reader clones it and rents buffers with no lock; the application does not provide a +second transport-specific pool. ## 6. Request/response body streaming @@ -1170,16 +1157,16 @@ narrows that reserve to what the peer actually owes. ```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 - -> poll HttpBody frame -> WinHttpWriteData ->async WRITE_COMPLETE [repeat through end-of-stream] - -> unknown length only: zero-length WinHttpWriteData ->async WRITE_COMPLETE - -> WinHttpReceiveResponse ->async HEADERS_AVAILABLE + -> start independent directional drivers: + send: poll data -> WinHttpWriteData ->async WRITE_COMPLETE [repeat] + unknown length: null-buffer terminal write + receive: WinHttpReceiveResponse ->async HEADERS_AVAILABLE -> WinHttpQueryHeaders/Option (status, negotiated version, header block) [sync] - -> move RequestGuard into WinHttpBodyReader - -> build HttpResponse { parts, lazy body } through HttpResponseBuilder::body - -> return Ok(response) + -> build HttpResponse { parts, shared request lifetime, lazy body } + -> return Ok(response) while upload may still be active -> on body poll: ReadDataEx ->async READ_COMPLETE [repeat until zero-length completion] -> query and emit response trailers, if present -> close request; HANDLE_CLOSING later reclaims context and parents @@ -1210,19 +1197,20 @@ the numeric status query uses a `DWORD` buffer and the legacy `WINHTTP_QUERY_VER string query a UTF-16 buffer. The response lifecycle constructs a lazy `WinHttpResponseBody` through -`HttpBodyBuilder::body`, attaches no `ConnectionInfo`, and moves `RequestGuard` into -`WinHttpBodyReader` after all response metadata has been queried. No response-body call -is made before the caller polls the body. EOF, a body error, timeout, or body drop closes -the request handle. The context retains the connect handle, session owner, and any -active operation buffer until the resulting `HANDLE_CLOSING` callback reclaims it. - -The upload lifecycle polls every outgoing body frame lazily after -`SENDREQUEST_COMPLETE`. Empty data frames are inert. Each nonempty data frame is -written one contiguous `BytesView` span at a time, further split at `u32::MAX`, and the -next frame is not polled until every write for the current frame completes. Body-stream -errors propagate directly, and a trailer frame fails with `invalid_request` because -WinHTTP cannot submit request trailers. Only after end-of-stream does the driver issue -`WinHttpReceiveResponse`, so request upload and response reception are never concurrent. +`HttpBodyBuilder::body`, attaches no `ConnectionInfo`, and shares one request lifetime +with the upload driver. No response-body read is made before the caller polls the body. +The request closes after both directions finish, or when an error, timeout, or drop +cancels the shared operation. The context retains the connect handle, session owner, +and active directional buffers until `HANDLE_CLOSING` reclaims it. + +The upload lifecycle polls every outgoing data frame lazily after +`SENDREQUEST_COMPLETE`. Empty data frames are inert. Each nonempty frame is written one +contiguous `BytesView` span at a time, further split at `u32::MAX`, and the next frame is +not polled until every write for the current frame completes. The receive lane progresses +independently. Request trailer capability is detected before execution; because WinHTTP +cannot submit request trailers, such a request fails before body polling or network +disclosure. A send failure before headers fails `execute`; a later failure remains +observable through the shared response/request completion state. Before driver execution, the transport captures `HttpBody::try_clone()` when the outgoing body is replayable and tracks whether `poll_frame` has been attempted. An @@ -1276,12 +1264,12 @@ 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 instance-owned pools | all setup calls run inline on the caller's thread | +| Threading (§3) | completions fired from foreign OS threads reach directional drivers; request/session handles are `Send + Sync`, connect handles are `Send`+`!Sync`, and the handler is `Send + Sync` | send and receive callbacks overlap without sharing one operation slot | | Error handling (design.md §7) | table-driven Win32/`WINHTTP_*` code -> `ErrorLabel` + `RecoveryInfo`, including an unrecognized code mapping to `request_winhttp` with unknown recovery; `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 requested version (`HTTP/1.0`, `HTTP/0.9`) rejected as `invalid_request` | -| TLS (design.md §4) | `WINHTTP_FLAG_SECURE` iff `https`; security-flags bitmask per `accept_invalid_*`, each flag setting only its own `SECURITY_FLAGS` bit (they are independent, not coupled); WinHTTP secure error code -> `tls` label, with deterministic validation failures non-retryable and revocation-server unavailability retryable; `SECURE_FAILURE` flags are optional diagnostics | 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 | -| Connection management (design.md §2) | connect handle opened per request and retained until the request's final close callback; finite `max_connections` causes no max-conns option call; `ConnectionKeepAlive` maps to `HTTP2/3_KEEPALIVE`, with the 5000 ms floor applied to HTTP/2 (§10.3); `connection_idle_timeout` maps to `CONNECTION_IDLE_TIMEOUT` on the session, with the same 5000 ms floor and `Unlimited` encoded as the largest `DWORD`; `DISABLE_GLOBAL_POOLING` on the session | generic lifetime settings are accepted and ignored without diagnostics | +| Protocol negotiation (design.md §3) | portable HTTP/1.1/2 constraints filter WinHTTP defaults and optional HTTP/3 preference; response `Version` comes from the negotiated-protocol query | exact HTTP/2 suppresses HTTP/3 and sets `HTTP_PROTOCOL_REQUIRED` | +| TLS (design.md §4) | strict platform trust, hostname validation, revocation, exact TLS-name mapping, and named client credentials | missing credential bindings or unavailable identities fail explicitly | +| Encoded responses / redirects / statelessness (design.md §5) | native decompression disabled; `REDIRECT_POLICY_NEVER`, `DISABLE_COOKIES`, and `DISABLE_AUTHENTICATION` set | encoded bytes and headers reach fetch-level decompression unchanged | +| Connection management (design.md §2) | per-origin limit, idle-age bound, dispatch isolation, and session-generation lifetime enforcement | values WinHTTP cannot faithfully honor fail validation | | Timeouts (design.md §6) | native timers initialize to unlimited and stay there; frozen-clock connect deadline (design.md §6.2); `ResponseTimeout` remains owned by `fetch`; `BodyTimeout` is passed to `HttpBodyBuilder` | a connect completing first drops its timer unfired; request body options override client body defaults through the existing merge rules | - **Inline / reentrant completion.** `MockBindings` is configured so an async call @@ -1345,13 +1333,13 @@ owns which of the behaviors below. They validate the real OS path end to end: - GET/POST with small and large bodies; response body correctness and size. -- Unknown-length streaming uploads over HTTP/1.1, HTTP/2, and HTTP/3, followed by - `WinHttpReceiveResponse` only after the final write; streaming downloads. Mock - tests assert incremental frame submission and completion ordering, while localhost - tests assert the final bytes, negotiated protocol, and HTTP/1.1 chunked framing. -- Request trailer frames fail explicitly. Response trailers are preserved for HTTP/2 - and HTTP/3; WinHTTP does not expose them for HTTP/1.1. -- Real gzip/deflate responses are transparently decoded. +- Full-duplex known- and unknown-length HTTP/2 uploads follow the executable + [full-duplex streaming experiment](full-duplex-streaming-experiment.md). Response + data arrives before the final request chunk and upload continues afterward. +- Request trailer capability is rejected before sending. Response trailers are queried + after EOF and preserved for HTTP/1.1, HTTP/2, and HTTP/3. +- Encoded gzip/deflate/Brotli/zstd bodies and original headers reach `fetch`; fetch-level + tests cover streaming decompression. - Redirects are never followed (`REDIRECT_POLICY_NEVER`, design.md §5 and implementation.md §10.3): a request to a localhost endpoint returning a 302 whose `Location` points at a sentinel endpoint @@ -1369,9 +1357,10 @@ path end to end: A self-signed certificate with a valid localhost name proves certificate relaxation; a self-signed certificate with a hostname mismatch proves that only enabling both flags accepts both faults. Exact security-flag unit tests cover every individual bit. - (Client-certificate/mTLS is out of scope for v1, design.md §4.1.) + Named client-certificate tests cover store selection, issuer filtering, and imported + material. - Pool isolation across clients: two `HttpClient`s built independently - including two - builds of a *cloned* `builder_winhttp` builder - issue requests to the same authority; + builds of a cloned `WinHttpTransport` composition - issue requests to the same authority; server-side connection counting shows that they establish *separate* connections and never reuse each other's, proving the per-built-client session/pool boundary (§3.2, design.md §2). @@ -1383,10 +1372,8 @@ path end to end: it by asserting that `WinHttpOpen` runs once per slot (§3.2). - HTTP/1.1 vs HTTP/2 negotiation against `TestServer`, whose connection builder accepts both; the reported response `Version` names the negotiated protocol. -- HTTP/3: `TestServer` speaks no HTTP/3, so h3 is tested against `Http3Server` using its - self-signed certificate and `accept_invalid_certs`. The negotiated `Version` is - HTTP/3, and the "h3 required but QUIC unreachable" path yields the - expected failure (`0x2EFE`/`0x2EFD`). +- HTTP/3: `prefer_http3` negotiates HTTP/3 against `Http3Server` and falls back against + a TCP-only server. Exact portable HTTP/2 suppresses the preference. - Connection reuse: two sequential requests to the same authority reuse the connection (observable via server-side connection counting). - Timeout configuration is validated only structurally (unit, §7.4). Integration @@ -1403,7 +1390,7 @@ path end to end: asserted by mock unit tests under Miri. The full `fetch` pipeline (retry/breaker/telemetry) is validated by building an -`HttpClient` via `HttpClient::builder_winhttp(...)` and asserting a real request round-trips, +`HttpClient` via `HttpClient::builder(transport)` and asserting a real request round-trips, mirroring `fetch`'s existing `requests` integration test structure. ### 7.4 Timeout testing @@ -1430,64 +1417,33 @@ connect deadline and response/body timeout behavior are covered with controlled ## 8. Client construction -`HttpClientWinHttpExt::builder_winhttp` (design.md §1.1) does not reimplement any -pipeline wiring; it delegates to `fetch`'s custom-transport entry point, calling -`fetch::custom::create_builder("winhttp", "winhttp", factory, Isolation::Isolated, deps)`. -There is no `new_winhttp`: the timer-capable `Clock`, `GlobalPool`, and `Sink` are -mandatory environment dependencies and have no runtime-neutral defaults. They are passed -to `WinHttpDeps::builder(clock, global_pool, sink)`. TLS and WinHTTP-specific user -configuration default when omitted. The `create_builder` signature this targets is: +`WinHttpTransport::builder` creates relocatable composition configuration and performs +no I/O. `HttpClient::builder(transport)` erases its concrete type while preserving its +typed companion configuration registry. Final client construction calls +`Transport::validate`, then retains the returned isolated factory. ```rust,ignore -pub fn create_builder( - runtime: impl Into>, // telemetry "fetch.runtime" - transport: impl Into>, // telemetry "fetch.transport" - factory: F, // Fn(CustomContext) -> R - isolation: Isolation, - deps: impl Into>, -) -> HttpClientBuilder -where - F: Fn(CustomContext) -> R + Send + Sync + 'static, - R: RequestHandler + 'static, - Extras: ThreadAware + Send + Sync + Clone + 'static; +let transport = WinHttpTransport::builder() + .prefer_http3(true) + .client_certificates(catalog) + .build(); +let client = HttpClient::builder(transport).build()?; ``` -`CustomContext` hands the factory a `HttpBodyBuilder` (carrying the clock and -read-buffer pool), a `PoolIndex`, the generic `TransportOptions`/`TlsOptions`, a -`Meter`, and the caller's `Extras`. `fetch_winhttp` ignores `PoolIndex` (per-thread -placement comes from `Isolation::Isolated`, §3.2) and ignores `CustomContext::tls` (it -takes its own `WinHttpTlsConfig` instead; see design.md §1.2). This generic TLS -configuration is ignored without a runtime warning; the limitation is part of the -documented transport contract. Ignoring the `PoolIndex` -*value* does not collapse `fetch`'s `multiple_pools`: `fetch` invokes the factory once per -pool slot (`0..pool_count` in `client_builder.rs`), so each slot opens its own WinHTTP -session (§3.2), and because pooling is per-session (`DISABLE_GLOBAL_POOLING`, §9.3) those -sessions already hold distinct pools. Distinct `PoolIndex` slots therefore land in distinct -sessions/pools structurally, without the transport keying anything on the index. The real -v1 resource profile is one session/pool per (thread × pool slot). Whether connection-pool -ownership belongs on `fetch` at all or entirely on the transport is unresolved and may -retire the `PoolIndex` surface in its current shape (../../fetch/docs/stabilization.md, -connection-management item). - -`builder_winhttp` does **not** open the session; it just calls `create_builder` with the -factory. Each materialized (thread × pool-slot) transport instance opens its own session -inside the factory when -`fetch` materializes it (§3.2), so the session is scoped to the built client and never -captured in the clone-shared builder closure. The session is deliberately not a -`WinHttpDeps` field either - `WinHttpDeps` stays plain, relocatable configuration. The -clock comes from `CustomContext`. Because `CustomContext` exposes only the derived -`HttpBodyBuilder`, not the underlying `GlobalPool`, `WinHttpDeps` also retains a pool -clone in `Extras` for WinHTTP read buffers. The `observed::Sink` rides in the same extras -and relocates per thread with the rest of the config; the transport emits its telemetry -through it (§13). There is no +Validation resolves every portable requirement and rejects unsupported values before a client is +returned. `TransportInstanceContext` supplies the clock, body builder, memory pool, +telemetry, runtime-thread affinity, and dispatch-pool identity. Each materialized +thread × pool-slot instance opens its own session, so independently built clients never +share native pooling state. A later materialization failure creates an explicitly failed +handler for that partition rather than delaying a known configuration mismatch until a request. +No application-supplied transport dependency duplicates these services. There is no `anyspawn::Spawner`: no WinHTTP call the transport makes can block (§2.1). Session creation opens a direct-connection session and applies every required session -option without an old-Windows capability-probing or degradation path. Because the -custom-transport factory is infallible, a session that cannot be opened or configured -produces a permanently failed handler. Every request to that handler returns a fresh -initialization `HttpError` without opening request/connect handles or issuing network -I/O, which is the contract design.md §7 states. +option without an old-Windows degradation path. A session that cannot be opened or +configured produces a permanently failed handler for that materialized partition. Every +request to that handler returns a fresh initialization `HttpError` without opening +request/connect handles or issuing network I/O. Error construction follows one shape for native failures: a Win32/`WINHTTP_*` code becomes `HttpError::other(WinHttpError { code, operation, secure_failure_flags }, recovery, @@ -1544,11 +1500,9 @@ shared state limited to the read-only session (§3.2). ### 9.2 HTTP/1.1 serialization and concurrency For HTTP/1.1 there is no multiplexing: concurrent requests to the same authority -are serviced by separate pooled connections under WinHTTP's own limits. The transport -does not set `WINHTTP_OPTION_MAX_CONNS_PER_SERVER`: `fetch`'s finite -`max_connections` value limits idle retained connections, while the WinHTTP option -limits all physical connections and could throttle active requests. Finite values are -therefore ignored without a runtime warning, as documented in design.md §2.1. +are serviced by separate pooled connections. The portable per-origin total-connection +limit maps to `WINHTTP_OPTION_MAX_CONNS_PER_SERVER`; Hyper-specific idle-pool sizing is +not present in the portable requirement set. HTTP/2 and HTTP/3 multiplex many requests over a single connection, also handled by WinHTTP. @@ -1611,37 +1565,24 @@ request. 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 creates 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 preference eliminated by a +portable requirement is narrowed without error. ### 10.2 TLS flags @@ -1677,20 +1618,17 @@ applied with `WinHttpSetOption` on the request handle before `WinHttpSendRequest - **Server certificate inspection / pinning.** Not offered in v1. If needed later it hooks the `SECURE_FAILURE` callback and a post-handshake `WINHTTP_OPTION_SERVER_CERT_CONTEXT` query. -- **Client certificates (mTLS).** Out of scope for v1 (design.md §4.1). Wiring them - into Schannel means importing a DER chain plus PKCS#8 key into an in-memory store, - producing a `PCCERT_CONTEXT`, attaching it with - `WINHTTP_OPTION_CLIENT_CERT_CONTEXT`, and managing hardware-backed identities. +- **Named client certificates.** Resolve a logical credential binding to a + `PCCERT_CONTEXT`, attach it with `WINHTTP_OPTION_CLIENT_CERT_CONTEXT`, and use the + server issuer list to select among rotating or hardware-backed identities. ### 10.3 WinHTTP-managed behavior flags 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` or synthesize `Accept-Encoding`; encoded bodies and + headers reach the fetch-level decompression layer unchanged. - **Redirects.** `WINHTTP_OPTION_REDIRECT_POLICY = WINHTTP_OPTION_REDIRECT_POLICY_NEVER`, so redirect responses (3xx) are surfaced to the caller unchanged rather than @@ -1816,28 +1754,23 @@ reintroduces the feature also reintroduces the obligation. | SSL 2.0 is not used unless enabled | The transport never sets `WINHTTP_OPTION_SECURE_PROTOCOLS`, so the OS default protocol set applies and SSL 2.0 stays off. Protocol version policy is deliberately left to OS and administrator configuration. | | Revocation checking must be requested | Secure requests set `WINHTTP_OPTION_ENABLE_FEATURE` to `WINHTTP_ENABLE_SSL_REVOCATION` (§10.2). **Deliberate deviation:** `accept_invalid_certs` withdraws the request, because that configuration targets certificates with no reachable revocation endpoint and WinHTTP cannot forgive a check it could not complete. | | A session maps to a single identity | Sessions are anonymous and carry no identity: credentials are never set, automatic authentication is disabled, and cookies are disabled through `WINHTTP_DISABLE_COOKIES` (§10.3), so no state links one caller's request to another's. | -| Operations on a request handle must be synchronized | A request handle carries at most one outstanding operation at a time, enforced by the single operation slot in the request context (§4). `RequestGuard` is the sole close authority and closes exactly once; cancellation closes the handle and waits for the final `HANDLE_CLOSING` callback rather than racing an in-flight operation. | +| Operations on a request handle must be synchronized | The request context permits at most one operation per directional lane. Send-only and receive-only operations may overlap, as allowed by the supported Windows baseline and covered by the full-duplex probe. Shared request state remains the sole close authority. | | Trace files contain sensitive information | Not applicable. The transport never enables WinHTTP tracing. | | Avoid passing sensitive data through `WinHttpSetOption` | No credential is ever passed to `WinHttpSetOption`; every option this transport sets is a `DWORD` or a context value (§10). | | Automatic redirection is a risk | Redirects are disabled with `WINHTTP_OPTION_REDIRECT_POLICY_NEVER` (§10.3), so a redirect is surfaced to the caller as a response rather than followed with the original body. | | User-defined headers cross redirects unchanged | Not applicable, because redirects are never followed. | | WinHTTP is not reentrant in synchronous mode | Not applicable. Sessions open with `WINHTTP_FLAG_ASYNC` and the transport issues no synchronous request (§2). | -## 12. Handling generic options the transport cannot honor - -`fetch`'s options arrive through its generic configuration surface, and callers set -them transport-agnostically, so the transport routinely receives settings it cannot -faithfully honor on WinHTTP - a `connection_lifetime` of `Fixed`/`PerConnection` -(design.md §2.2), a finite `max_connections` (design.md §2.1), and so on. +## 12. Requirement validation -Unsupported generic options are ignored without warnings, counters, or build failures. -This includes generic `TlsOptions`, finite `max_connections`, connection lifetime -settings, and unrepresentable keep-alive semantics. Their behavior is documented in -design.md so callers can choose configuration appropriate to this transport. +The unbuilt transport validates every portable requirement before returning its factory. +A requirement is either implemented faithfully or rejected with a construction error; +there is no warning or silent-ignore path. -These gaps are a symptom of `fetch`-level over-abstraction; the proper fix is -transport-level configuration (see the fetch API stabilization feedback, -../../fetch/docs/stabilization.md). +Mechanisms without portable semantics do not arrive in the requirement set. Optional +WinHTTP configuration is consumed from the transport builder or a registered, +dependency-light companion configuration type. Per-instance OS resource failures remain +materialization errors for the affected runtime-thread and dispatch-pool partition. ## 13. Telemetry @@ -1876,56 +1809,31 @@ metric cardinality. ## 14. Future opportunities -Design points deliberately deferred in v1, recorded here so they are revisited when -the `fetch` API or profiling data makes them actionable: - -- **Consolidate per-thread sessions into one session per client.** v1 opens one WinHTTP - session (and therefore one connection pool) per thread - and per `multiple_pools` slot - within a thread (§3.2) - because that is the only shape the - current `fetch` custom-transport API expresses while still isolating independently built - clients (§3.2). This diverges from a single-session-per-client model: it trades - cross-thread connection reuse and session-granularity connection recycling (the - connection-lifetime control the transport does not offer today, design.md §2.2) for warmer, - uncontended thread-local pools. Once `fetch` grows a per-built-client shared-state hook - - or once profiling justifies `Isolation::Shared` despite thread-local object pools - - revisit whether one shared session per client is the better default. Tracked as `fetch` - API feedback (../../fetch/docs/stabilization.md, connection-management item) so the - divergence is not forgotten when that API becomes more expressive. +These mechanisms remain internal candidates and do not justify public options without +representative measurements: + +- **Shared versus isolated sessions.** The factory currently chooses isolated + thread × pool-slot sessions. A shared factory could improve cross-thread connection reuse + at the cost of contention and loss of thread-local pools. Profiling should choose the + default; it is not a library-facing setting. - **Per-thread vs shared instancing as a knob.** Whether per-thread instancing is the right default at all could become configurable: a low-traffic client gains nothing from per-thread instances and might prefer a single shared instance (§3.2). Left unconfigurable in v1 - a knob earns its place only with demonstrated value - but a candidate if profiling shows it matters. -- **Session-keyed connection pools (`PoolIndex`).** v1 does not key anything on `fetch`'s - `PoolIndex` value. It does not need to: `fetch` invokes the factory once per pool slot, so - each slot already opens its own session/pool (§8), giving a resource profile of one - session/pool per (thread × pool slot) rather than a single collapsed OS pool. If pool - ownership stays a transport concern after the v2 sessions/pools discussion, revisit - whether to interpret `PoolIndex` explicitly (../../fetch/docs/stabilization.md, - connection-management item). - **Per-connection identity through connection GUIDs.** WinHTTP exposes a pair of public request-scoped options, `WINHTTP_OPTION_CONNECTION_GUID` and `WINHTTP_OPTION_MATCH_CONNECTION_GUID`, that together give a connection an identity the transport currently assumes it cannot have: the first tags the connection serving a request with a caller-supplied GUID and reads back the GUID of the connection that served it, and the second steers a request onto a connection bearing a given tag, - optionally forcing a new connection when no tagged match exists. Deferred design points - become reachable with them, and each is a separate decision: - - **`connection_lifetime` for `Fixed`/`PerConnection`** (design.md §2.2). Tagging - connections with a generation GUID and rotating that generation on a schedule - retires connections by age: requests demand the current generation, so a rotation - forces fresh connections and the previous generation ages out through the idle - timeout. A single rotating generation retires the whole pool at once, which is the - synchronized-reconnect behavior `PerConnection` exists to prevent, so a faithful - implementation needs several staggered generations rather than one. + optionally forcing a new connection when no tagged match exists. They may support: - **Cold-connect attribution on success.** §13 records connection establishment only on the failure path, so a client that silently reconnects on every request - the pathology an idle window that is too short produces - looks healthy in telemetry. Counting distinct connection GUIDs across requests measures it directly. - - **`ConnectionInfo` on responses** (design.md §2.1). Recording when each GUID was - first seen yields connection age, which is the input `fetch_hyper`'s - `ConnectionInfo` reports and which this transport currently declares it does not - track. + - **Transport-specific diagnostics.** Recording when a GUID was first seen can expose + connection age without making it part of the portable response contract. - **Distinguishing a near-zero idle window from "do not pool".** The idle window's 5000 ms floor means a caller asking for a very short window gets five seconds, which is the mapping's largest semantic stretch: such a caller most plausibly means "do not pool 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..2df7b1ddc --- /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.95.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..66f4268c6 --- /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.95.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..ac2d7a26e --- /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::aws_lc_rs::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::aws_lc_rs::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..5d742fc61 --- /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::aws_lc_rs::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::aws_lc_rs::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() + } +}