hclient_core/unversioned/hooks.rs
1//! The observability seam: what a transport did, told to whoever asked.
2//!
3//! Today the answer to *"why was that request slow"* is "read the source".
4//! A caller can see the response and its version and nothing else — not
5//! whether a connection was made or reused, not what DNS cost, not why a
6//! connection went away underneath them.
7//!
8//! # It reports; it does not steer
9//!
10//! [`Hooks::on`] returns `()`, and that is the whole of the contract.
11//! There is no verdict a hook can hand back, so there is nothing for the
12//! request path to branch on: this cannot grow into a second
13//! [`Capabilities`](crate::Capabilities), where a caller's declaration
14//! changes what the transport does. A hook that wants to change the
15//! request has the request — that is `Client`'s business, one layer up,
16//! and a return value here would move the decision to a place where
17//! nobody could see it happen.
18//!
19//! # Zero cost when nobody is watching, and it is [`Hooks::WATCHING`]
20//!
21//! A hook is a type parameter, not a `Box<dyn Hooks>`, and the type a
22//! caller who wants nothing gets is [`NoHooks`] — a zero-sized struct
23//! whose `WATCHING` is `false`. Backends read that const **before** they
24//! measure anything, so a build with no hook does not read a clock, does
25//! not take an id from a counter, and has no branch left that a
26//! monomorphised `NoHooks` cannot delete. The const is what makes that
27//! structural: a runtime `if self.hooks.is_some()` would still cost a
28//! branch, and would already have read the clock to have something to put
29//! in it. `crates/hclient-native/tests/hooks_cost.rs` measures it from
30//! outside — a runtime whose clock counts its own reads.
31//!
32//! It is deliberately all-or-nothing rather than one const per event. A
33//! hook that wanted only [`Closed`] would then skip the connect timings,
34//! and the seam would gain four booleans that every backend has to read
35//! correctly for the timings to stay honest. One const, one rule.
36//!
37//! # A panicking hook
38//!
39//! A panic in [`Hooks::on`] propagates to whoever polled the request or
40//! the response body. It is deliberately not caught:
41//! `std::panic::catch_unwind` needs `UnwindSafe`, which would become a
42//! bound on the caller's own type, and it does nothing at all under
43//! `panic = "abort"` — so catching would be a promise that holds in some
44//! builds and not others. What backends owe instead is that a panic can
45//! only unwind *out*, never leave a lock poisoned or a process aborted:
46//!
47//! - **No hook is called with a lock held.** `hclient-native` emits from
48//! `Transport::execute` and from its response body, never from inside
49//! the connection pool's mutex. A hook that panics therefore cannot
50//! poison it, and a hook that blocks cannot stall a request that is not
51//! its own.
52//! - **No hook is called from a `Drop` impl.** A panic there, during an
53//! unwind already in progress, aborts the process — and an
54//! observability seam that can abort a program is worse than no seam.
55//! The cost is written down where it lands: a connection dropped rather
56//! than finished (a cancelled request; one the pool evicts for age) gets
57//! no [`Closed`] event. That is a hole, and it is the one this rule
58//! buys.
59//!
60//! # Why `unversioned`
61//!
62//! The event set is derived from what a connection-owning backend can
63//! observe, and backends still to come will have facts this vocabulary has
64//! no word for — a QUIC connection migrating, a transfer a background
65//! session finished after the process died. Freezing it would freeze one
66//! backend's view. See this module's parent for what `unversioned`
67//! promises.
68use crate::Error;
69use core::time::Duration;
70use std::fmt::Display;
71use std::net::SocketAddr;
72use std::sync::Arc;
73use std::sync::atomic::{AtomicU64, Ordering};
74
75/// Somewhere to send what a transport did.
76///
77/// Implemented by the application rather than by a backend — which makes
78/// it the one trait in `unversioned` pointing the other way. A backend
79/// *calls* it, and what it owes is written on [`Event`]'s variants.
80///
81/// **No `Send` bound, declared or implied** (P13, settled by construction
82/// in `crates/hclient-core/tests/shape.rs`). A hook is stored in a
83/// transport and called from inside a response body's `poll_frame`, which
84/// is not the shape any other seam here has: the body outlives
85/// `Transport::execute`, so it holds the hook rather than borrowing it,
86/// and a `Send` declared anywhere on that path would shut every
87/// single-threaded runtime out of observability. It is inferred instead —
88/// a transport with a `Send` hook stays `Send`, one with an `Rc` inside
89/// its hook does not, and both implement `Transport`.
90pub trait Hooks {
91 /// Whether anything reads these events.
92 ///
93 /// Backends must check this before doing work whose only purpose is
94 /// an event — reading a clock, taking a [`ConnectionId`]. `false` on
95 /// [`NoHooks`] is what makes the whole seam vanish from a build that
96 /// does not use it; see the module doc.
97 ///
98 /// Defaulted to `true`, so a hook that forgets the line gets events
99 /// rather than silence. The costly default is the safe one here: the
100 /// other way round, a caller's hook would compile and never fire.
101 const WATCHING: bool = true;
102
103 /// Something happened. Called synchronously, on the task driving the
104 /// request.
105 fn on(&self, event: Event<'_>);
106}
107
108/// The hook a caller who asked for nothing gets: `WATCHING` is `false`,
109/// the type is zero-sized, and `on` has no body.
110///
111/// The default type parameter of `hclient_native::Native`, so the
112/// no-hooks build is the one that needs no words to ask for.
113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
114pub struct NoHooks;
115
116impl Hooks for NoHooks {
117 const WATCHING: bool = false;
118 fn on(&self, _event: Event<'_>) {}
119}
120
121/// So that a hook can be shared without the caller writing the
122/// delegation. `WATCHING` is forwarded rather than defaulted, or an
123/// `Arc<NoHooks>` would start watching by being wrapped.
124impl<H: Hooks + ?Sized> Hooks for Arc<H> {
125 const WATCHING: bool = H::WATCHING;
126 fn on(&self, event: Event<'_>) {
127 (**self).on(event);
128 }
129}
130
131/// The single-threaded half of the impl above, and not decoration: it is
132/// the shape P13 asks about. A hook behind an `Rc` makes the transport
133/// holding it `!Send`, and everything still compiles — see
134/// `crates/hclient-core/tests/shape.rs`.
135impl<H: Hooks + ?Sized> Hooks for std::rc::Rc<H> {
136 const WATCHING: bool = H::WATCHING;
137 fn on(&self, event: Event<'_>) {
138 (**self).on(event);
139 }
140}
141
142/// What a transport reports.
143///
144/// Not `#[non_exhaustive]`, for `Message`'s reason (see
145/// [`Message`](crate::unversioned::Message)): nothing here is published,
146/// so a new variant costs a rebase inside this workspace, and a compile
147/// error is what an implementer of [`Hooks`] should get when the
148/// vocabulary grows — rather than a silently ignored fact.
149///
150/// **There is deliberately no "request queued" variant.** The original
151/// list for this work had one, and `hclient-native` has nothing to put in
152/// it: a request that finds no live pooled connection dials a fresh one,
153/// there is no per-origin connection limit to wait behind, and an h2
154/// connection is checked out of the pool exclusively — one stream at a
155/// time — so `SendRequest::poll_ready` never waits for a stream of ours.
156/// A variant no code can emit is a capability that lies; this one belongs
157/// here once a backend has a queue.
158#[derive(Debug)]
159pub enum Event<'a> {
160 /// A connection was made. See [`Connected`] for what each duration
161 /// means and, more to the point, what it does not.
162 Connected(Connected<'a>),
163 /// A connection somebody else already made is being used again.
164 Reused(Reused<'a>),
165 /// The response head arrived.
166 Head(Head<'a>),
167 /// A connection ended, and why.
168 Closed(Closed<'a>),
169 /// A `1xx` arrived ahead of the response — `100 Continue`,
170 /// `103 Early Hints`, or anything else a server sends before the one
171 /// answer the caller is waiting for.
172 ///
173 /// An event rather than a response, because it is not one: a `1xx` is
174 /// not the end of the exchange and `Transport::execute` resolves
175 /// exactly once. A caller who wants `103`'s preload hints reads them
176 /// here, before the head that follows.
177 Informational(Informational<'a>),
178}
179
180/// A `1xx` that arrived before the response.
181///
182/// # Why there is no `version` here, when [`Head`] has one
183///
184/// A `1xx` travels on a connection, and the connection's protocol was
185/// already reported by the [`Connected`] or [`Reused`] that opened this
186/// exchange — both of which carry a plain `Version` rather than an
187/// `Option`, because only a transport that owns a connection emits either
188/// and owning one means having negotiated its protocol. Repeating it here
189/// would be a third place to be wrong about the same fact.
190///
191/// `Head::version` is an `Option` for the opposite reason: the two
192/// backends that own no connection emit `Head` and nothing else, so for
193/// them there is no `Connected` to have carried it. Neither of them can
194/// emit this event at all.
195#[derive(Debug)]
196pub struct Informational<'a> {
197 pub id: ConnectionId,
198 /// `1xx`. Which one is the whole of what distinguishes a `100` from a
199 /// `103`, so it is not narrowed to an enum: a status this crate has
200 /// never heard of is still a status the server sent.
201 pub status: http::StatusCode,
202 pub headers: &'a http::HeaderMap,
203}
204
205/// Which connection an event is about.
206///
207/// Process-wide and monotonic, so a [`Closed`] can be matched to the
208/// [`Connected`] that opened the same socket — which is the only reason
209/// it exists. Without it a close event says "a connection ended" and a
210/// caller holding several has no way to learn which.
211///
212/// [`ConnectionId::UNWATCHED`] is what an event carries when there was no
213/// id to mint for it — because nobody is watching, or because there is no
214/// connection. See that constant.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
216pub struct ConnectionId(u64);
217
218static NEXT_CONNECTION_ID: AtomicU64 = AtomicU64::new(1);
219
220impl ConnectionId {
221 /// The id an event carries when no id was minted for it.
222 ///
223 /// Two things produce it, and **a reader can only ever meet the
224 /// second**:
225 ///
226 /// - **Nobody is watching.** [`Hooks::WATCHING`] is `false`, so a
227 /// transport that owns connections leaves the counter alone rather
228 /// than paying an atomic per connection for nobody. That const's
229 /// own question is *whether anything reads these events*, so a
230 /// build producing this value for this reason has, by its own
231 /// declaration, no reader for the event carrying it.
232 /// - **There is no connection to name.** `hclient-fetch` and
233 /// `hclient-wasi` own none — the Fetch Standard exposes no
234 /// connection object, and there is no connection resource anywhere
235 /// in `wasi:http@0.3.0` — so their one event, [`Head`], carries
236 /// this.
237 ///
238 /// To a hook that reads events it therefore means exactly one thing,
239 /// *this event names no connection*, and that is something a portable
240 /// hook can act on rather than a gap it has to guess at: it is the
241 /// only id [`ConnectionId::next`] never returns, so looking it up in
242 /// a table of live connections cannot hit one.
243 ///
244 /// # The name is a producer, not the meaning
245 ///
246 /// There is deliberately no second constant meaning *this event names
247 /// no connection*, distinct from *nobody is watching*: the two would
248 /// differ only in a build whose events nobody reads, so no caller
249 /// decision turns on the difference — this workspace's test for
250 /// whether a distinction earns a name of its own. `UNWATCHED` names
251 /// one of the two producers, and any spelling would name one or the
252 /// other; read it as the ambient *this event names no connection*.
253 pub const UNWATCHED: ConnectionId = ConnectionId(0);
254
255 /// The next id. `Relaxed`: this counter orders nothing, it only has
256 /// to hand out distinct numbers.
257 ///
258 /// It starts at `1`, and that is load bearing rather than tidy: it is
259 /// what makes [`UNWATCHED`](Self::UNWATCHED) mean *this event names no
260 /// connection* rather than *this event names connection zero*.
261 /// `crates/hclient-core/tests/shape.rs` pins it.
262 pub fn next() -> Self {
263 Self(NEXT_CONNECTION_ID.fetch_add(1, Ordering::Relaxed))
264 }
265
266 /// The number itself, for a log line.
267 pub fn get(self) -> u64 {
268 self.0
269 }
270}
271
272impl Display for ConnectionId {
273 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274 write!(f, "{}", self.0)
275 }
276}
277
278/// A connection was established.
279#[derive(Debug)]
280pub struct Connected<'a> {
281 pub id: ConnectionId,
282 /// The URI whose request paid for this connection, as the transport
283 /// received it — absolute, before any protocol rewrote it.
284 pub uri: &'a http::Uri,
285 /// The address that answered. One of possibly several tried:
286 /// RFC 8305 races address families, and this is the winner, not the
287 /// first candidate.
288 ///
289 /// **`None` where the connection has no IP address**, which today
290 /// means a Unix-domain socket
291 /// (`hclient_native::Native::unix_socket`): there was no name, no
292 /// family to race and no port. It is an `Option` rather than a
293 /// fabricated `0.0.0.0:0` for `Head::version`'s reason one event over
294 /// — a sentinel that is also an ordinary value gives a hook a *wrong*
295 /// answer where the absence gives it a missing one, and only the
296 /// second can be handled.
297 ///
298 /// The alternative was to emit no `Connected` at all for such a
299 /// connection, and it is worse: the `Closed` that follows would
300 /// announce the end of a connection whose beginning was never
301 /// announced, which is exactly the defect recorded for building a
302 /// `Closed::Failed` out of `wasi:http`'s error codes.
303 pub remote: Option<SocketAddr>,
304 /// What will be spoken on it, as negotiated — not as offered.
305 pub version: http::Version,
306 pub timing: ConnectTiming,
307}
308
309/// What each phase of a connect cost.
310///
311/// **The three phases are three measurements, not a decomposition.**
312/// `dns + tcp + tls <= total` always holds — they are disjoint intervals
313/// inside the whole — but the remainder is real time belonging to none of
314/// them: RFC 8305 staggers connection attempts, so a winner that started
315/// 250 ms into the race spent that stagger in no phase at all, and a
316/// first attempt made through an HTTPS record's hints and failed is time
317/// the second attempt's phases do not contain either.
318///
319/// Every duration is measured on the transport's own `Timer` — the same
320/// clock its timeouts and its pool deadlines use, so a test under
321/// `tokio::time::pause()` sees one consistent story rather than two.
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub struct ConnectTiming {
324 /// From the start of the connect to the moment the first address
325 /// could be tried.
326 ///
327 /// A DNS figure in the honest sense — everything waited for is a DNS
328 /// answer — but not only an A/AAAA one: an HTTPS record (RFC 9460) is
329 /// looked up beside the addresses and its hints are tried first, so a
330 /// slow record delays the first attempt and shows up here.
331 pub dns: Duration,
332 /// The winning TCP attempt, from the moment it was launched to the
333 /// moment it connected. Not the race: an attempt the scheduler
334 /// started earlier and that lost is not in this number.
335 pub tcp: Duration,
336 /// The TLS handshake, or `None` for a connection that has none —
337 /// which is the honest answer for `http://`, where a zero would read
338 /// as an instant handshake.
339 pub tls: Option<Duration>,
340 /// The whole connect, from the transport asking for a connection to
341 /// having one: DNS, every attempt including those that failed, and
342 /// TLS.
343 pub total: Duration,
344}
345
346/// A connection was taken from the pool instead of being made.
347///
348/// The counterpart of [`Connected`], and the fact a caller cannot
349/// otherwise get at: two requests to one origin either cost one
350/// connection or two, and only the transport knows which happened.
351#[derive(Debug)]
352pub struct Reused<'a> {
353 /// The id this connection was given when it was made — so the
354 /// [`Connected`] it belongs to is findable.
355 pub id: ConnectionId,
356 pub uri: &'a http::Uri,
357 /// What is spoken on it. A pooled connection is keyed on its
358 /// protocol, so this is what the connection negotiated when it was
359 /// made, not a guess.
360 pub version: http::Version,
361}
362
363/// The response head arrived.
364#[derive(Debug)]
365pub struct Head<'a> {
366 pub id: ConnectionId,
367 pub uri: &'a http::Uri,
368 pub status: http::StatusCode,
369 /// What was spoken — or `None` where the transport could not observe
370 /// it.
371 ///
372 /// `Some` exactly when the transport reports
373 /// [`version_reported`](crate::Capabilities::version_reported).
374 /// `hclient-native` reads it off the status line, and off ALPN with
375 /// the `http2` feature; `hclient-h3` speaks HTTP/3 and nothing else;
376 /// both say `true`. `hclient-fetch` and `hclient-wasi` say `false` and
377 /// report `None` here: the Fetch Standard's `Response` has no protocol
378 /// member, and `wasi:http@0.3.0` has no version concept at all.
379 ///
380 /// # Why an `Option`, and not `http`'s builder default
381 ///
382 /// Because `HTTP/1.1` is an ordinary value. Nothing distinguishes it
383 /// from an HTTP/1.1 exchange that really happened, so a hook counting
384 /// protocol mix records a browser's h2 and h3 traffic as HTTP/1.1 — a
385 /// **wrong** answer rather than a missing one. That is
386 /// [`ConnectTiming::tls`]'s rule one field over: *a zero would read as
387 /// an instant handshake*. `http::Version` has no variant meaning "not
388 /// observed" and is not ours to give one, so the `Option` is where the
389 /// distinction can live.
390 ///
391 /// The capability asks the same question and does not answer it in the
392 /// same place. [`Capabilities`](crate::Capabilities) is reachable from
393 /// whoever built the transport; a [`Hooks`] impl is handed an
394 /// [`Event`] and nothing else, and the same hook is written once and
395 /// installed on whichever backend the target got. A hook that had to
396 /// know which transport it was inside in order not to record a
397 /// falsehood is exactly the `#[cfg]` this workspace exists to not
398 /// need.
399 ///
400 /// [`Connected::version`] and [`Reused::version`] stay plain, and that
401 /// is structural rather than an oversight: only a transport that owns
402 /// a connection emits either of those, and owning one means having
403 /// negotiated its protocol.
404 pub version: Option<http::Version>,
405 /// From the transport receiving the request to the head being read.
406 ///
407 /// It contains the connect when there was one, which is the point:
408 /// the pair (`Head::elapsed`, `ConnectTiming::total`) is what answers
409 /// "was it the connection or was it the server".
410 pub elapsed: Duration,
411}
412
413/// A connection ended.
414#[derive(Debug)]
415pub struct Closed<'a> {
416 pub id: ConnectionId,
417 pub reason: CloseReason<'a>,
418}
419
420/// Why a connection ended.
421///
422/// Three, because three is what the code can tell apart. It deliberately
423/// does not include "the caller dropped it": that would have to be
424/// reported from a `Drop` impl, and the module doc says why no hook is
425/// ever called from one.
426#[derive(Debug)]
427pub enum CloseReason<'a> {
428 /// The exchange finished it: the peer closed the connection after the
429 /// response, or the response said `Connection: close`. Nothing went
430 /// wrong — there is simply no second request to be had on it.
431 Ended,
432 /// It was taken from the pool for a new request and turned out to
433 /// have been closed by the peer while it sat idle.
434 ///
435 /// The reason a request that did nothing wrong sometimes pays for a
436 /// connect: this is the event that explains the [`Connected`]
437 /// following it.
438 Stale,
439 /// It failed, and here is the failure.
440 Failed(&'a Error),
441}