Skip to main content

decdn_client/
connection.rs

1//! Dialling connections, and connections kept warm across many requests.
2//!
3//! `dial` is the one dial every requester path uses, one-shot or warm. It runs
4//! the dial on a caller-chosen runtime when the caller asks for one, so the
5//! connection's QUIC driver lives there.
6//!
7//! [`WarmConnection`] dials once and stays open, so a caller fetching many hashes
8//! from one provider pays the dial + NAT-traversal cost a single time and opens a
9//! fresh bi-stream per hash instead. The wire is unchanged — one stream still
10//! carries exactly one hash (`open_progressive_pull_on` opens the
11//! bi-stream); only the client-side teardown differs, because a per-hash pull
12//! leaves the connection open for the next one rather than closing it.
13//!
14//! [`Connections`] keeps one [`WarmConnection`] per node on one ALPN, so every
15//! caller sharing the map (every lane and entry of a CLI command, or every
16//! outbound pull leg or DHT call of a node) dials each node once.
17
18use std::collections::HashMap;
19use std::sync::{Arc, Mutex, PoisonError};
20use std::time::Duration;
21
22use iroh::endpoint::Connection;
23use iroh::{Endpoint, EndpointAddr, PublicKey};
24use tokio::runtime::Handle;
25use tokio_util::task::AbortOnDropHandle;
26use tracing::Instrument as _;
27
28use decdn_protocol::ALPN_CLIENT;
29
30use crate::{LocalPullFault, PullTimeout, rate_limited};
31
32/// Dial `target` on `alpn`, as a task on `runtime` under `span`.
33///
34/// The dial spawns the connection's QUIC driver on whichever runtime runs the
35/// `connect`, and the connection makes progress — I/O, timers, its close — only
36/// while that driver runs. With `runtime` set, the dial runs on that runtime, so
37/// the driver outlives the caller's runtime. A caller that runs a pull on a
38/// runtime it later drops passes a long-lived one here: a driver dropped with its
39/// runtime before the connection reaches QUIC's draining state leaves the
40/// connection in the endpoint's active set for good, and the endpoint's close then
41/// waits for it forever. With `None`, the dial and the driver run as a task on
42/// the caller's runtime.
43///
44/// The driver inherits the span current inside the dial task, which is `span`
45/// when the subscriber enables it and no span when it does not: a spawned task
46/// starts outside every span of the caller's. A caller whose trace should carry
47/// the connection's events passes `Span::current()`; a pooled dial passes a root
48/// span of the connection's own, so a connection that outlives its first caller
49/// never holds that caller's span open, at any log level. Dropping the returned
50/// future mid-dial aborts the spawned dial.
51///
52/// # Errors
53///
54/// A connect or transport fault, as `stage: {err}` (a rate-limit shed keeps its
55/// typed sentinel). A spawned dial that panics, or that `runtime` cancels because
56/// it is shutting down, fails as a [`LocalPullFault`]: the dial runtime is this
57/// node's, so neither outcome says anything about the peer.
58pub(crate) async fn dial(
59    endpoint: &Endpoint,
60    target: EndpointAddr,
61    alpn: &'static [u8],
62    runtime: Option<&Handle>,
63    span: tracing::Span,
64    stage: &'static str,
65) -> anyhow::Result<Connection> {
66    let runtime = match runtime {
67        Some(runtime) => runtime.clone(),
68        None => Handle::try_current().map_err(|e| {
69            anyhow::anyhow!("{stage}: no runtime to dial on: {e}").context(LocalPullFault)
70        })?,
71    };
72    let endpoint = endpoint.clone();
73    let connect = async move { endpoint.connect(target, alpn).await }.instrument(span);
74    AbortOnDropHandle::new(runtime.spawn(connect))
75        .await
76        .map_err(|e| {
77            let outcome = if e.is_panic() {
78                "panicked"
79            } else {
80                "was cancelled by its runtime shutting down"
81            };
82            anyhow::anyhow!("{stage}: the dial task {outcome}").context(LocalPullFault)
83        })?
84        .map_err(|e| rate_limited::transport_error(stage, e))
85}
86
87/// A dialled connection reused across many requests on one ALPN: `cdn/client/v1`
88/// hash fetches, or `cdn/dht/v1` calls.
89///
90/// Each request opens a new bi-stream on the SAME connection (one stream = one
91/// request, no multiplexing), amortizing the dial over every request. The
92/// connection is closed exactly once, on this handle's own [`Drop`]; a request
93/// that borrows the connection (`open_progressive_pull_on`, a DHT call) tears
94/// down only its own stream and leaves the connection open for the next one.
95#[derive(Debug)]
96#[doc(hidden)]
97pub struct WarmConnection {
98    /// The live QUIC connection. Cloned into each pull, which leaves it open on
99    /// its own teardown; this handle owns the single close.
100    conn: Connection,
101}
102
103impl WarmConnection {
104    /// Dial `target` on the CDN client ALPN and keep the connection warm.
105    ///
106    /// Bounded as a whole by `open`, the same dial budget
107    /// [`crate::open_progressive_pull`] applies to its one-shot dial.
108    /// `dial_runtime` is the runtime the dial and the connection's driver run on;
109    /// `None` runs them on the caller's (see [`dial`]).
110    ///
111    /// # Errors
112    ///
113    /// A connect / transport fault, `open` elapsing before the connection is
114    /// established ([`PullTimeout`]), or a spawned dial that panicked or was
115    /// cancelled by `dial_runtime` shutting down ([`LocalPullFault`]).
116    pub async fn connect(
117        endpoint: &Endpoint,
118        target: EndpointAddr,
119        open: Duration,
120        dial_runtime: Option<&Handle>,
121    ) -> anyhow::Result<Self> {
122        let conn = tokio::time::timeout(
123            open,
124            dial(
125                endpoint,
126                target,
127                ALPN_CLIENT,
128                dial_runtime,
129                tracing::Span::current(),
130                "warm connect failed",
131            ),
132        )
133        .await
134        .map_err(|_| anyhow::Error::new(PullTimeout { after: open }))??;
135        Ok(Self { conn })
136    }
137
138    /// The live connection, for opening a per-request bi-stream.
139    #[must_use]
140    #[doc(hidden)]
141    pub const fn connection(&self) -> &Connection {
142        &self.conn
143    }
144
145    /// Whether the connection has closed: by either side, by an idle timeout,
146    /// or by a transport fault. A closed connection opens no more streams.
147    #[must_use]
148    #[doc(hidden)]
149    pub fn is_closed(&self) -> bool {
150        self.conn.close_reason().is_some()
151    }
152}
153
154/// One live connection per node on one ALPN, shared by every pull of one
155/// command (`cdn/client/v1`, from [`new`](Self::new)) or by every outbound
156/// call of one node ([`with_options`](Self::with_options)).
157///
158/// A pull opens its own bi-stream on the node's connection and dials only when
159/// the node has no live connection. Concurrent callers for one node share one
160/// dial. A pull that meets a transport fault (a connection reset, a closed
161/// connection, an idle timeout, an open or read that falls under its
162/// deadline) unpins its connection ([`invalidate`](Self::invalidate)), so the
163/// next [`get`](Self::get) dials again. A protocol refusal or a hash mismatch
164/// ends only its own stream, and the connection stays pinned. The connection
165/// carries no payment state: payment lanes stay per (signer, provider).
166///
167/// Cloning is cheap: every clone shares one map. Each pull holds its
168/// connection, so each connection closes when the map drops it and no pull
169/// still holds it.
170///
171/// **Where the drivers run (#1675).** A connection's QUIC driver lives on the
172/// runtime that dialled it, and the connection makes progress only while that
173/// driver runs. A map with a dial runtime dials every connection there, so
174/// callers on any runtime share it, including runtimes they throw away
175/// (`decdn-node`'s per-serve pull legs), as long as the dial runtime outlives
176/// the endpoint. A map without one dials on the caller's runtime: share it
177/// only between callers on one runtime that outlives the map, such as the
178/// `decdn` CLI's.
179#[derive(Debug, Clone)]
180pub struct Connections {
181    inner: Arc<ConnectionsInner>,
182}
183
184/// A node's connection slot. The async lock makes concurrent callers for one
185/// node wait on one dial.
186type Slot = Arc<tokio::sync::Mutex<Option<Arc<WarmConnection>>>>;
187
188/// How a caller came by a map's connection ([`Connections::get_or_dial`]).
189///
190/// The variant carries the retry rule. A caller that meets a closed connection
191/// redials once only when the connection was [`Pinned`](Self::Pinned) before
192/// the call: the peer closed it between calls (an idle reap, a spent budget),
193/// so a fresh dial is the first real attempt. A connection the call
194/// [`Dialled`](Self::Dialled) itself that fails is the peer's failure, and is
195/// not retried.
196#[derive(Debug)]
197#[doc(hidden)]
198pub enum Lease {
199    /// This call dialled the connection.
200    Dialled(Arc<WarmConnection>),
201    /// The connection was pinned before this call.
202    Pinned(Arc<WarmConnection>),
203}
204
205impl Lease {
206    /// The connection, however it was come by.
207    #[must_use]
208    pub fn into_warm(self) -> Arc<WarmConnection> {
209        match self {
210            Self::Dialled(warm) | Self::Pinned(warm) => warm,
211        }
212    }
213
214    /// Whether the connection was pinned before this call.
215    #[must_use]
216    pub const fn is_pinned(&self) -> bool {
217        matches!(self, Self::Pinned(_))
218    }
219}
220
221#[derive(Debug)]
222struct ConnectionsInner {
223    endpoint: Endpoint,
224    /// The ALPN every connection of this map dials.
225    alpn: &'static [u8],
226    /// The runtime every dial and connection driver runs on; `None` runs them
227    /// on the caller's.
228    dial_runtime: Option<Handle>,
229    slots: Mutex<HashMap<PublicKey, Slot>>,
230    /// How many dials this map has made.
231    #[cfg(any(test, feature = "test-util"))]
232    dials: std::sync::atomic::AtomicU64,
233}
234
235impl Connections {
236    /// An empty map that dials `cdn/client/v1` from `endpoint`, on the
237    /// caller's runtime.
238    #[must_use]
239    pub fn new(endpoint: Endpoint) -> Self {
240        Self::with_options(endpoint, ALPN_CLIENT, None)
241    }
242
243    /// An empty map that dials `alpn` from `endpoint`, running every dial
244    /// and connection driver on `dial_runtime` (the caller's runtime when
245    /// `None`).
246    #[must_use]
247    #[doc(hidden)]
248    pub fn with_options(
249        endpoint: Endpoint,
250        alpn: &'static [u8],
251        dial_runtime: Option<Handle>,
252    ) -> Self {
253        Self {
254            inner: Arc::new(ConnectionsInner {
255                endpoint,
256                alpn,
257                dial_runtime,
258                slots: Mutex::new(HashMap::new()),
259                #[cfg(any(test, feature = "test-util"))]
260                dials: std::sync::atomic::AtomicU64::new(0),
261            }),
262        }
263    }
264
265    /// The ALPN every connection of this map dials.
266    #[must_use]
267    #[doc(hidden)]
268    pub fn alpn(&self) -> &'static [u8] {
269        self.inner.alpn
270    }
271
272    /// `target`'s live connection, dialled on the map's ALPN and dial
273    /// runtime when the node has none. A caller for a node another caller
274    /// is dialling waits for that dial and shares its connection. The dial
275    /// has no deadline of its own: the caller bounds it.
276    ///
277    /// # Errors
278    ///
279    /// A connect or transport fault from the dial, as
280    /// `connect failed: {err}` (a rate-limit shed keeps its typed sentinel).
281    #[doc(hidden)]
282    pub async fn get(&self, target: &EndpointAddr) -> anyhow::Result<Arc<WarmConnection>> {
283        self.get_or_dial(target).await.map(Lease::into_warm)
284    }
285
286    /// [`get`](Self::get), also saying how the call came by the connection
287    /// ([`Lease`]): dialled by this call, or pinned before it. A caller that
288    /// retries on a closed connection retries only a pinned one.
289    ///
290    /// # Errors
291    ///
292    /// The same faults as [`get`](Self::get).
293    #[doc(hidden)]
294    pub async fn get_or_dial(&self, target: &EndpointAddr) -> anyhow::Result<Lease> {
295        let slot = Arc::clone(
296            self.inner
297                .slots
298                .lock()
299                .unwrap_or_else(PoisonError::into_inner)
300                .entry(target.id)
301                .or_default(),
302        );
303        let mut held = slot.lock().await;
304        if let Some(warm) = held.as_ref().filter(|warm| !warm.is_closed()) {
305            return Ok(Lease::Pinned(Arc::clone(warm)));
306        }
307        #[cfg(any(test, feature = "test-util"))]
308        self.inner
309            .dials
310            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
311        // The connection outlives this caller, and its driver keeps the span
312        // it was dialled under open for the connection's life. So the dial
313        // task runs under a root span of the connection's own, linked to the
314        // caller's: `dial` hands the span to the task rather than entering it
315        // here, because entering a span the subscriber has disabled (a debug
316        // span at the node's INFO level) changes nothing, and the task would
317        // then see the caller's span as current and hand it to the driver.
318        let span = tracing::debug_span!(
319            parent: None,
320            "pooled_connection",
321            peer = %target.id,
322            alpn = %String::from_utf8_lossy(self.inner.alpn),
323        );
324        span.follows_from(tracing::Span::current());
325        let conn = dial(
326            &self.inner.endpoint,
327            target.clone(),
328            self.inner.alpn,
329            self.inner.dial_runtime.as_ref(),
330            span,
331            "connect failed",
332        )
333        .await?;
334        let warm = Arc::new(WarmConnection { conn });
335        *held = Some(Arc::clone(&warm));
336        Ok(Lease::Dialled(warm))
337    }
338
339    /// Unpin `conn` from `node`'s slot, so the next [`get`](Self::get) dials
340    /// again. Only that instance is unpinned: when another caller has already
341    /// dialled a newer connection into the slot, the newer one stays. Pulls
342    /// that still hold `conn` keep it; it closes when the last of them ends.
343    #[doc(hidden)]
344    pub async fn invalidate(&self, node: PublicKey, conn: &Arc<WarmConnection>) {
345        let slot = self
346            .inner
347            .slots
348            .lock()
349            .unwrap_or_else(PoisonError::into_inner)
350            .get(&node)
351            .map(Arc::clone);
352        let Some(slot) = slot else {
353            return;
354        };
355        let mut held = slot.lock().await;
356        if held
357            .as_ref()
358            .is_some_and(|pinned| Arc::ptr_eq(pinned, conn))
359        {
360            *held = None;
361        }
362    }
363
364    /// How many dials this map has made.
365    #[cfg(any(test, feature = "test-util"))]
366    #[must_use]
367    pub fn dials(&self) -> u64 {
368        self.inner.dials.load(std::sync::atomic::Ordering::SeqCst)
369    }
370}
371
372/// Whether `err` on a pull over `warm` is a transport fault: the connection
373/// has closed (a connection reset, a close by either side, an idle timeout, a
374/// lost connection under a read or an `open_bi`), or the open or a read fell
375/// under its deadline ([`PullTimeout`], [`PullStalled`](crate::PullStalled)).
376/// A refusal, a verify or hash fault, and a stream-level reset (a rate-limit
377/// shed of one stream) are not.
378pub(crate) fn is_transport_fault(err: &anyhow::Error, warm: &WarmConnection) -> bool {
379    warm.is_closed()
380        || err
381            .chain()
382            .any(|e| e.is::<PullTimeout>() || e.is::<crate::PullStalled>())
383}
384
385/// What a pull owns of its connection, which sets its teardown.
386#[derive(Debug, Clone)]
387pub(crate) enum ConnOwner {
388    /// A one-shot dial: the pull closes the connection on teardown.
389    Owned,
390    /// A caller-owned [`WarmConnection`]: the pull tears down only its own
391    /// stream, and the warm connection closes once, on its own `Drop`.
392    #[cfg(any(test, feature = "test-util"))]
393    Borrowed,
394    /// A connection from a [`Connections`] map: the pull tears down only its
395    /// own stream, holds the connection until it ends, and unpins it from
396    /// `map` on a transport fault.
397    Shared {
398        /// The connection, held for the pull's life.
399        warm: Arc<WarmConnection>,
400        /// The map it is pinned in.
401        map: Connections,
402    },
403}
404
405impl ConnOwner {
406    /// Whether the pull closes the connection on teardown.
407    pub(crate) const fn owns(&self) -> bool {
408        matches!(self, Self::Owned)
409    }
410
411    /// Unpin a shared connection from its map when `err` is a transport
412    /// fault ([`is_transport_fault`]). Any other owner, and any other error,
413    /// leaves the map as it is.
414    pub(crate) async fn unpin_on_fault(&self, err: &anyhow::Error) {
415        if let Self::Shared { warm, map } = self
416            && is_transport_fault(err, warm)
417        {
418            map.invalidate(warm.connection().remote_id(), warm).await;
419        }
420    }
421}
422
423impl Drop for WarmConnection {
424    /// Close the warm connection once the caller is done with it. `Connection::close`
425    /// is first-wins and idempotent, and every per-hash pull leaves the connection
426    /// open, so this is the single teardown for the whole warm connection.
427    fn drop(&mut self) {
428        self.conn.close(0u32.into(), b"warm-connection-dropped");
429    }
430}
431
432#[cfg(test)]
433#[allow(clippy::unwrap_used, clippy::expect_used)] // tests
434mod tests;