hclient-core 0.1.0-alpha.1

Plugin contract for hclient: Transport, Capabilities, RequestBody, Error, Timer
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! The observability seam: what a transport did, told to whoever asked.
//!
//! Today the answer to *"why was that request slow"* is "read the source".
//! A caller can see the response and its version and nothing else — not
//! whether a connection was made or reused, not what DNS cost, not why a
//! connection went away underneath them.
//!
//! # It reports; it does not steer
//!
//! [`Hooks::on`] returns `()`, and that is the whole of the contract.
//! There is no verdict a hook can hand back, so there is nothing for the
//! request path to branch on: this cannot grow into a second
//! [`Capabilities`](crate::Capabilities), where a caller's declaration
//! changes what the transport does. A hook that wants to change the
//! request has the request — that is `Client`'s business, one layer up,
//! and a return value here would move the decision to a place where
//! nobody could see it happen.
//!
//! # Zero cost when nobody is watching, and it is [`Hooks::WATCHING`]
//!
//! A hook is a type parameter, not a `Box<dyn Hooks>`, and the type a
//! caller who wants nothing gets is [`NoHooks`] — a zero-sized struct
//! whose `WATCHING` is `false`. Backends read that const **before** they
//! measure anything, so a build with no hook does not read a clock, does
//! not take an id from a counter, and has no branch left that a
//! monomorphised `NoHooks` cannot delete. The const is what makes that
//! structural: a runtime `if self.hooks.is_some()` would still cost a
//! branch, and would already have read the clock to have something to put
//! in it. `crates/hclient-native/tests/hooks_cost.rs` measures it from
//! outside — a runtime whose clock counts its own reads.
//!
//! It is deliberately all-or-nothing rather than one const per event. A
//! hook that wanted only [`Closed`] would then skip the connect timings,
//! and the seam would gain four booleans that every backend has to read
//! correctly for the timings to stay honest. One const, one rule.
//!
//! # A panicking hook
//!
//! A panic in [`Hooks::on`] propagates to whoever polled the request or
//! the response body. It is deliberately not caught:
//! `std::panic::catch_unwind` needs `UnwindSafe`, which would become a
//! bound on the caller's own type, and it does nothing at all under
//! `panic = "abort"` — so catching would be a promise that holds in some
//! builds and not others. What backends owe instead is that a panic can
//! only unwind *out*, never leave a lock poisoned or a process aborted:
//!
//! - **No hook is called with a lock held.** `hclient-native` emits from
//!   `Transport::execute` and from its response body, never from inside
//!   the connection pool's mutex. A hook that panics therefore cannot
//!   poison it, and a hook that blocks cannot stall a request that is not
//!   its own.
//! - **No hook is called from a `Drop` impl.** A panic there, during an
//!   unwind already in progress, aborts the process — and an
//!   observability seam that can abort a program is worse than no seam.
//!   The cost is written down where it lands: a connection dropped rather
//!   than finished (a cancelled request; one the pool evicts for age) gets
//!   no [`Closed`] event. That is a hole, and it is the one this rule
//!   buys.
//!
//! # Why `unversioned`
//!
//! The event set is derived from what a connection-owning backend can
//! observe, and backends still to come will have facts this vocabulary has
//! no word for — a QUIC connection migrating, a transfer a background
//! session finished after the process died. Freezing it would freeze one
//! backend's view. See this module's parent for what `unversioned`
//! promises.
use crate::Error;
use core::time::Duration;
use std::fmt::Display;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};

/// Somewhere to send what a transport did.
///
/// Implemented by the application rather than by a backend — which makes
/// it the one trait in `unversioned` pointing the other way. A backend
/// *calls* it, and what it owes is written on [`Event`]'s variants.
///
/// **No `Send` bound, declared or implied** (P13, settled by construction
/// in `crates/hclient-core/tests/shape.rs`). A hook is stored in a
/// transport and called from inside a response body's `poll_frame`, which
/// is not the shape any other seam here has: the body outlives
/// `Transport::execute`, so it holds the hook rather than borrowing it,
/// and a `Send` declared anywhere on that path would shut every
/// single-threaded runtime out of observability. It is inferred instead —
/// a transport with a `Send` hook stays `Send`, one with an `Rc` inside
/// its hook does not, and both implement `Transport`.
pub trait Hooks {
    /// Whether anything reads these events.
    ///
    /// Backends must check this before doing work whose only purpose is
    /// an event — reading a clock, taking a [`ConnectionId`]. `false` on
    /// [`NoHooks`] is what makes the whole seam vanish from a build that
    /// does not use it; see the module doc.
    ///
    /// Defaulted to `true`, so a hook that forgets the line gets events
    /// rather than silence. The costly default is the safe one here: the
    /// other way round, a caller's hook would compile and never fire.
    const WATCHING: bool = true;

    /// Something happened. Called synchronously, on the task driving the
    /// request.
    fn on(&self, event: Event<'_>);
}

/// The hook a caller who asked for nothing gets: `WATCHING` is `false`,
/// the type is zero-sized, and `on` has no body.
///
/// The default type parameter of `hclient_native::Native`, so the
/// no-hooks build is the one that needs no words to ask for.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct NoHooks;

impl Hooks for NoHooks {
    const WATCHING: bool = false;
    fn on(&self, _event: Event<'_>) {}
}

/// So that a hook can be shared without the caller writing the
/// delegation. `WATCHING` is forwarded rather than defaulted, or an
/// `Arc<NoHooks>` would start watching by being wrapped.
impl<H: Hooks + ?Sized> Hooks for Arc<H> {
    const WATCHING: bool = H::WATCHING;
    fn on(&self, event: Event<'_>) {
        (**self).on(event);
    }
}

/// The single-threaded half of the impl above, and not decoration: it is
/// the shape P13 asks about. A hook behind an `Rc` makes the transport
/// holding it `!Send`, and everything still compiles — see
/// `crates/hclient-core/tests/shape.rs`.
impl<H: Hooks + ?Sized> Hooks for std::rc::Rc<H> {
    const WATCHING: bool = H::WATCHING;
    fn on(&self, event: Event<'_>) {
        (**self).on(event);
    }
}

/// What a transport reports.
///
/// Not `#[non_exhaustive]`, for `Message`'s reason (see
/// [`Message`](crate::unversioned::Message)): nothing here is published,
/// so a new variant costs a rebase inside this workspace, and a compile
/// error is what an implementer of [`Hooks`] should get when the
/// vocabulary grows — rather than a silently ignored fact.
///
/// **There is deliberately no "request queued" variant.** The original
/// list for this work had one, and `hclient-native` has nothing to put in
/// it: a request that finds no live pooled connection dials a fresh one,
/// there is no per-origin connection limit to wait behind, and an h2
/// connection is checked out of the pool exclusively — one stream at a
/// time — so `SendRequest::poll_ready` never waits for a stream of ours.
/// A variant no code can emit is a capability that lies; this one belongs
/// here once a backend has a queue.
#[derive(Debug)]
pub enum Event<'a> {
    /// A connection was made. See [`Connected`] for what each duration
    /// means and, more to the point, what it does not.
    Connected(Connected<'a>),
    /// A connection somebody else already made is being used again.
    Reused(Reused<'a>),
    /// The response head arrived.
    Head(Head<'a>),
    /// A connection ended, and why.
    Closed(Closed<'a>),
    /// A `1xx` arrived ahead of the response — `100 Continue`,
    /// `103 Early Hints`, or anything else a server sends before the one
    /// answer the caller is waiting for.
    ///
    /// An event rather than a response, because it is not one: a `1xx` is
    /// not the end of the exchange and `Transport::execute` resolves
    /// exactly once. A caller who wants `103`'s preload hints reads them
    /// here, before the head that follows.
    Informational(Informational<'a>),
}

/// A `1xx` that arrived before the response.
///
/// # Why there is no `version` here, when [`Head`] has one
///
/// A `1xx` travels on a connection, and the connection's protocol was
/// already reported by the [`Connected`] or [`Reused`] that opened this
/// exchange — both of which carry a plain `Version` rather than an
/// `Option`, because only a transport that owns a connection emits either
/// and owning one means having negotiated its protocol. Repeating it here
/// would be a third place to be wrong about the same fact.
///
/// `Head::version` is an `Option` for the opposite reason: the two
/// backends that own no connection emit `Head` and nothing else, so for
/// them there is no `Connected` to have carried it. Neither of them can
/// emit this event at all.
#[derive(Debug)]
pub struct Informational<'a> {
    pub id: ConnectionId,
    /// `1xx`. Which one is the whole of what distinguishes a `100` from a
    /// `103`, so it is not narrowed to an enum: a status this crate has
    /// never heard of is still a status the server sent.
    pub status: http::StatusCode,
    pub headers: &'a http::HeaderMap,
}

/// Which connection an event is about.
///
/// Process-wide and monotonic, so a [`Closed`] can be matched to the
/// [`Connected`] that opened the same socket — which is the only reason
/// it exists. Without it a close event says "a connection ended" and a
/// caller holding several has no way to learn which.
///
/// [`ConnectionId::UNWATCHED`] is what an event carries when there was no
/// id to mint for it — because nobody is watching, or because there is no
/// connection. See that constant.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ConnectionId(u64);

static NEXT_CONNECTION_ID: AtomicU64 = AtomicU64::new(1);

impl ConnectionId {
    /// The id an event carries when no id was minted for it.
    ///
    /// Two things produce it, and **a reader can only ever meet the
    /// second**:
    ///
    /// - **Nobody is watching.** [`Hooks::WATCHING`] is `false`, so a
    ///   transport that owns connections leaves the counter alone rather
    ///   than paying an atomic per connection for nobody. That const's
    ///   own question is *whether anything reads these events*, so a
    ///   build producing this value for this reason has, by its own
    ///   declaration, no reader for the event carrying it.
    /// - **There is no connection to name.** `hclient-fetch` and
    ///   `hclient-wasi` own none — the Fetch Standard exposes no
    ///   connection object, and there is no connection resource anywhere
    ///   in `wasi:http@0.3.0` — so their one event, [`Head`], carries
    ///   this.
    ///
    /// To a hook that reads events it therefore means exactly one thing,
    /// *this event names no connection*, and that is something a portable
    /// hook can act on rather than a gap it has to guess at: it is the
    /// only id [`ConnectionId::next`] never returns, so looking it up in
    /// a table of live connections cannot hit one.
    ///
    /// # The name is a producer, not the meaning
    ///
    /// There is deliberately no second constant meaning *this event names
    /// no connection*, distinct from *nobody is watching*: the two would
    /// differ only in a build whose events nobody reads, so no caller
    /// decision turns on the difference — this workspace's test for
    /// whether a distinction earns a name of its own. `UNWATCHED` names
    /// one of the two producers, and any spelling would name one or the
    /// other; read it as the ambient *this event names no connection*.
    pub const UNWATCHED: ConnectionId = ConnectionId(0);

    /// The next id. `Relaxed`: this counter orders nothing, it only has
    /// to hand out distinct numbers.
    ///
    /// It starts at `1`, and that is load bearing rather than tidy: it is
    /// what makes [`UNWATCHED`](Self::UNWATCHED) mean *this event names no
    /// connection* rather than *this event names connection zero*.
    /// `crates/hclient-core/tests/shape.rs` pins it.
    pub fn next() -> Self {
        Self(NEXT_CONNECTION_ID.fetch_add(1, Ordering::Relaxed))
    }

    /// The number itself, for a log line.
    pub fn get(self) -> u64 {
        self.0
    }
}

impl Display for ConnectionId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

/// A connection was established.
#[derive(Debug)]
pub struct Connected<'a> {
    pub id: ConnectionId,
    /// The URI whose request paid for this connection, as the transport
    /// received it — absolute, before any protocol rewrote it.
    pub uri: &'a http::Uri,
    /// The address that answered. One of possibly several tried:
    /// RFC 8305 races address families, and this is the winner, not the
    /// first candidate.
    ///
    /// **`None` where the connection has no IP address**, which today
    /// means a Unix-domain socket
    /// (`hclient_native::Native::unix_socket`): there was no name, no
    /// family to race and no port. It is an `Option` rather than a
    /// fabricated `0.0.0.0:0` for `Head::version`'s reason one event over
    /// — a sentinel that is also an ordinary value gives a hook a *wrong*
    /// answer where the absence gives it a missing one, and only the
    /// second can be handled.
    ///
    /// The alternative was to emit no `Connected` at all for such a
    /// connection, and it is worse: the `Closed` that follows would
    /// announce the end of a connection whose beginning was never
    /// announced, which is exactly the defect recorded for building a
    /// `Closed::Failed` out of `wasi:http`'s error codes.
    pub remote: Option<SocketAddr>,
    /// What will be spoken on it, as negotiated — not as offered.
    pub version: http::Version,
    pub timing: ConnectTiming,
}

/// What each phase of a connect cost.
///
/// **The three phases are three measurements, not a decomposition.**
/// `dns + tcp + tls <= total` always holds — they are disjoint intervals
/// inside the whole — but the remainder is real time belonging to none of
/// them: RFC 8305 staggers connection attempts, so a winner that started
/// 250 ms into the race spent that stagger in no phase at all, and a
/// first attempt made through an HTTPS record's hints and failed is time
/// the second attempt's phases do not contain either.
///
/// Every duration is measured on the transport's own `Timer` — the same
/// clock its timeouts and its pool deadlines use, so a test under
/// `tokio::time::pause()` sees one consistent story rather than two.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ConnectTiming {
    /// From the start of the connect to the moment the first address
    /// could be tried.
    ///
    /// A DNS figure in the honest sense — everything waited for is a DNS
    /// answer — but not only an A/AAAA one: an HTTPS record (RFC 9460) is
    /// looked up beside the addresses and its hints are tried first, so a
    /// slow record delays the first attempt and shows up here.
    pub dns: Duration,
    /// The winning TCP attempt, from the moment it was launched to the
    /// moment it connected. Not the race: an attempt the scheduler
    /// started earlier and that lost is not in this number.
    pub tcp: Duration,
    /// The TLS handshake, or `None` for a connection that has none —
    /// which is the honest answer for `http://`, where a zero would read
    /// as an instant handshake.
    pub tls: Option<Duration>,
    /// The whole connect, from the transport asking for a connection to
    /// having one: DNS, every attempt including those that failed, and
    /// TLS.
    pub total: Duration,
}

/// A connection was taken from the pool instead of being made.
///
/// The counterpart of [`Connected`], and the fact a caller cannot
/// otherwise get at: two requests to one origin either cost one
/// connection or two, and only the transport knows which happened.
#[derive(Debug)]
pub struct Reused<'a> {
    /// The id this connection was given when it was made — so the
    /// [`Connected`] it belongs to is findable.
    pub id: ConnectionId,
    pub uri: &'a http::Uri,
    /// What is spoken on it. A pooled connection is keyed on its
    /// protocol, so this is what the connection negotiated when it was
    /// made, not a guess.
    pub version: http::Version,
}

/// The response head arrived.
#[derive(Debug)]
pub struct Head<'a> {
    pub id: ConnectionId,
    pub uri: &'a http::Uri,
    pub status: http::StatusCode,
    /// What was spoken — or `None` where the transport could not observe
    /// it.
    ///
    /// `Some` exactly when the transport reports
    /// [`version_reported`](crate::Capabilities::version_reported).
    /// `hclient-native` reads it off the status line, and off ALPN with
    /// the `http2` feature; `hclient-h3` speaks HTTP/3 and nothing else;
    /// both say `true`. `hclient-fetch` and `hclient-wasi` say `false` and
    /// report `None` here: the Fetch Standard's `Response` has no protocol
    /// member, and `wasi:http@0.3.0` has no version concept at all.
    ///
    /// # Why an `Option`, and not `http`'s builder default
    ///
    /// Because `HTTP/1.1` is an ordinary value. Nothing distinguishes it
    /// from an HTTP/1.1 exchange that really happened, so a hook counting
    /// protocol mix records a browser's h2 and h3 traffic as HTTP/1.1 — a
    /// **wrong** answer rather than a missing one. That is
    /// [`ConnectTiming::tls`]'s rule one field over: *a zero would read as
    /// an instant handshake*. `http::Version` has no variant meaning "not
    /// observed" and is not ours to give one, so the `Option` is where the
    /// distinction can live.
    ///
    /// The capability asks the same question and does not answer it in the
    /// same place. [`Capabilities`](crate::Capabilities) is reachable from
    /// whoever built the transport; a [`Hooks`] impl is handed an
    /// [`Event`] and nothing else, and the same hook is written once and
    /// installed on whichever backend the target got. A hook that had to
    /// know which transport it was inside in order not to record a
    /// falsehood is exactly the `#[cfg]` this workspace exists to not
    /// need.
    ///
    /// [`Connected::version`] and [`Reused::version`] stay plain, and that
    /// is structural rather than an oversight: only a transport that owns
    /// a connection emits either of those, and owning one means having
    /// negotiated its protocol.
    pub version: Option<http::Version>,
    /// From the transport receiving the request to the head being read.
    ///
    /// It contains the connect when there was one, which is the point:
    /// the pair (`Head::elapsed`, `ConnectTiming::total`) is what answers
    /// "was it the connection or was it the server".
    pub elapsed: Duration,
}

/// A connection ended.
#[derive(Debug)]
pub struct Closed<'a> {
    pub id: ConnectionId,
    pub reason: CloseReason<'a>,
}

/// Why a connection ended.
///
/// Three, because three is what the code can tell apart. It deliberately
/// does not include "the caller dropped it": that would have to be
/// reported from a `Drop` impl, and the module doc says why no hook is
/// ever called from one.
#[derive(Debug)]
pub enum CloseReason<'a> {
    /// The exchange finished it: the peer closed the connection after the
    /// response, or the response said `Connection: close`. Nothing went
    /// wrong — there is simply no second request to be had on it.
    Ended,
    /// It was taken from the pool for a new request and turned out to
    /// have been closed by the peer while it sat idle.
    ///
    /// The reason a request that did nothing wrong sometimes pays for a
    /// connect: this is the event that explains the [`Connected`]
    /// following it.
    Stale,
    /// It failed, and here is the failure.
    Failed(&'a Error),
}