decdn_client/connection.rs
1//! Dialling `cdn/client/v1` connections, and a caller-owned connection kept warm
2//! across many hashes.
3//!
4//! `dial` is the one dial every requester path uses, one-shot or warm. It runs
5//! the dial on a caller-chosen runtime when the caller asks for one, so the
6//! connection's QUIC driver lives there.
7//!
8//! [`WarmConnection`] dials once and stays open, so a caller fetching many hashes
9//! from one provider pays the dial + NAT-traversal cost a single time and opens a
10//! fresh bi-stream per hash instead. The wire is unchanged — one stream still
11//! carries exactly one hash (`open_progressive_pull_on` opens the
12//! bi-stream); only the client-side teardown differs, because a per-hash pull
13//! leaves the connection open for the next one rather than closing it.
14//!
15//! [`Connections`] keeps one [`WarmConnection`] per node for a whole command,
16//! so every lane and every entry of that command 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 the CDN client ALPN.
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 inline on the
42/// caller's runtime.
43///
44/// The spawned dial carries the caller's current span, so its connect events and
45/// the driver's stay in the caller's trace. Dropping the returned future mid-dial
46/// aborts the spawned dial.
47///
48/// # Errors
49///
50/// A connect or transport fault, as `stage: {err}` (a rate-limit shed keeps its
51/// typed sentinel). A spawned dial that panics, or that `runtime` cancels because
52/// it is shutting down, fails as a [`LocalPullFault`]: the dial runtime is this
53/// node's, so neither outcome says anything about the peer.
54pub(crate) async fn dial(
55 endpoint: &Endpoint,
56 target: EndpointAddr,
57 runtime: Option<&Handle>,
58 stage: &'static str,
59) -> anyhow::Result<Connection> {
60 let Some(runtime) = runtime else {
61 return endpoint
62 .connect(target, ALPN_CLIENT)
63 .await
64 .map_err(|e| rate_limited::transport_error(stage, e));
65 };
66 let endpoint = endpoint.clone();
67 let connect = async move { endpoint.connect(target, ALPN_CLIENT).await }
68 .instrument(tracing::Span::current());
69 AbortOnDropHandle::new(runtime.spawn(connect))
70 .await
71 .map_err(|e| {
72 let outcome = if e.is_panic() {
73 "panicked"
74 } else {
75 "was cancelled by its runtime shutting down"
76 };
77 anyhow::anyhow!("{stage}: the dial task {outcome}").context(LocalPullFault)
78 })?
79 .map_err(|e| rate_limited::transport_error(stage, e))
80}
81
82/// A dialed `cdn/client/v1` connection reused across many hash fetches.
83///
84/// Each fetch opens a new bi-stream on the SAME connection (one stream = one
85/// hash, no multiplexing), amortizing the dial over every hash. The connection is
86/// closed exactly once, on this handle's own [`Drop`]; a per-hash pull that
87/// borrows the connection (`open_progressive_pull_on`) tears down only
88/// its own stream and leaves the connection open for the next hash.
89#[derive(Debug)]
90#[doc(hidden)]
91pub struct WarmConnection {
92 /// The live QUIC connection. Cloned into each pull, which leaves it open on
93 /// its own teardown; this handle owns the single close.
94 conn: Connection,
95}
96
97impl WarmConnection {
98 /// Dial `target` on the CDN client ALPN and keep the connection warm.
99 ///
100 /// Bounded as a whole by `open`, the same dial budget
101 /// [`crate::open_progressive_pull`] applies to its one-shot dial.
102 /// `dial_runtime` is the runtime the dial and the connection's driver run on;
103 /// `None` runs them on the caller's (see
104 /// [`PeerSource::with_dial_runtime`](crate::source::PeerSource::with_dial_runtime)).
105 ///
106 /// # Errors
107 ///
108 /// A connect / transport fault, `open` elapsing before the connection is
109 /// established ([`PullTimeout`]), or a spawned dial that panicked or was
110 /// cancelled by `dial_runtime` shutting down ([`LocalPullFault`]).
111 pub async fn connect(
112 endpoint: &Endpoint,
113 target: EndpointAddr,
114 open: Duration,
115 dial_runtime: Option<&Handle>,
116 ) -> anyhow::Result<Self> {
117 let conn = tokio::time::timeout(
118 open,
119 dial(endpoint, target, dial_runtime, "warm connect failed"),
120 )
121 .await
122 .map_err(|_| anyhow::Error::new(PullTimeout { after: open }))??;
123 Ok(Self { conn })
124 }
125
126 /// The live connection, for opening a per-hash bi-stream.
127 pub(crate) const fn connection(&self) -> &Connection {
128 &self.conn
129 }
130
131 /// Whether the connection has closed: by either side, by an idle timeout,
132 /// or by a transport fault. A closed connection opens no more streams.
133 pub(crate) fn is_closed(&self) -> bool {
134 self.conn.close_reason().is_some()
135 }
136}
137
138/// One live `cdn/client/v1` connection per node, shared by every pull of one
139/// command.
140///
141/// A pull opens its own bi-stream on the node's connection and dials only when
142/// the node has no live connection. Concurrent callers for one node share one
143/// dial. A pull that meets a transport fault (a connection reset, a closed
144/// connection, an idle timeout, an open or read that falls under its
145/// deadline) unpins its connection ([`invalidate`](Self::invalidate)), so the
146/// next [`get`](Self::get) dials again. A protocol refusal or a hash mismatch
147/// ends only its own stream, and the connection stays pinned. The connection
148/// carries no payment state: payment lanes stay per (signer, provider).
149///
150/// Cloning is cheap: every clone shares one map. Each pull holds its
151/// connection, so each connection closes when the map drops it and no pull
152/// still holds it.
153///
154/// **One long-lived runtime only (#1675).** A connection's QUIC driver lives on
155/// the runtime that dialled it, and the connection makes progress only while
156/// that driver runs. Share a map only between pulls on one runtime that
157/// outlives the map, such as the `decdn` CLI's. A caller that pulls on
158/// runtimes it throws away (`decdn-node`'s per-serve pull legs) dials per leg
159/// instead, on a long-lived dial runtime
160/// ([`PeerSource::with_dial_runtime`](crate::source::PeerSource::with_dial_runtime)).
161#[derive(Debug, Clone)]
162pub struct Connections {
163 inner: Arc<ConnectionsInner>,
164}
165
166/// A node's connection slot. The async lock makes concurrent callers for one
167/// node wait on one dial.
168type Slot = Arc<tokio::sync::Mutex<Option<Arc<WarmConnection>>>>;
169
170#[derive(Debug)]
171struct ConnectionsInner {
172 endpoint: Endpoint,
173 slots: Mutex<HashMap<PublicKey, Slot>>,
174 /// How many dials this map has made.
175 #[cfg(any(test, feature = "test-util"))]
176 dials: std::sync::atomic::AtomicU64,
177}
178
179impl Connections {
180 /// An empty map that dials from `endpoint`.
181 #[must_use]
182 pub fn new(endpoint: Endpoint) -> Self {
183 Self {
184 inner: Arc::new(ConnectionsInner {
185 endpoint,
186 slots: Mutex::new(HashMap::new()),
187 #[cfg(any(test, feature = "test-util"))]
188 dials: std::sync::atomic::AtomicU64::new(0),
189 }),
190 }
191 }
192
193 /// `target`'s live connection, dialled on the caller's runtime when the
194 /// node has none. A caller for a node another caller is dialling waits
195 /// for that dial and shares its connection. The dial has no deadline of
196 /// its own: the caller bounds it.
197 ///
198 /// # Errors
199 ///
200 /// A connect or transport fault from the dial, as
201 /// `connect failed: {err}` (a rate-limit shed keeps its typed sentinel).
202 #[doc(hidden)]
203 pub async fn get(&self, target: &EndpointAddr) -> anyhow::Result<Arc<WarmConnection>> {
204 let slot = Arc::clone(
205 self.inner
206 .slots
207 .lock()
208 .unwrap_or_else(PoisonError::into_inner)
209 .entry(target.id)
210 .or_default(),
211 );
212 let mut held = slot.lock().await;
213 if let Some(warm) = held.as_ref().filter(|warm| !warm.is_closed()) {
214 return Ok(Arc::clone(warm));
215 }
216 #[cfg(any(test, feature = "test-util"))]
217 self.inner
218 .dials
219 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
220 let conn = dial(&self.inner.endpoint, target.clone(), None, "connect failed").await?;
221 let warm = Arc::new(WarmConnection { conn });
222 *held = Some(Arc::clone(&warm));
223 Ok(warm)
224 }
225
226 /// Unpin `conn` from `node`'s slot, so the next [`get`](Self::get) dials
227 /// again. Only that instance is unpinned: when another caller has already
228 /// dialled a newer connection into the slot, the newer one stays. Pulls
229 /// that still hold `conn` keep it; it closes when the last of them ends.
230 #[doc(hidden)]
231 pub async fn invalidate(&self, node: PublicKey, conn: &Arc<WarmConnection>) {
232 let slot = self
233 .inner
234 .slots
235 .lock()
236 .unwrap_or_else(PoisonError::into_inner)
237 .get(&node)
238 .map(Arc::clone);
239 let Some(slot) = slot else {
240 return;
241 };
242 let mut held = slot.lock().await;
243 if held
244 .as_ref()
245 .is_some_and(|pinned| Arc::ptr_eq(pinned, conn))
246 {
247 *held = None;
248 }
249 }
250
251 /// How many dials this map has made.
252 #[cfg(any(test, feature = "test-util"))]
253 #[must_use]
254 pub fn dials(&self) -> u64 {
255 self.inner.dials.load(std::sync::atomic::Ordering::SeqCst)
256 }
257}
258
259/// Whether `err` on a pull over `warm` is a transport fault: the connection
260/// has closed (a connection reset, a close by either side, an idle timeout, a
261/// lost connection under a read or an `open_bi`), or the open or a read fell
262/// under its deadline ([`PullTimeout`], [`PullStalled`](crate::PullStalled)).
263/// A refusal, a verify or hash fault, and a stream-level reset (a rate-limit
264/// shed of one stream) are not.
265pub(crate) fn is_transport_fault(err: &anyhow::Error, warm: &WarmConnection) -> bool {
266 warm.is_closed()
267 || err
268 .chain()
269 .any(|e| e.is::<PullTimeout>() || e.is::<crate::PullStalled>())
270}
271
272/// What a pull owns of its connection, which sets its teardown.
273#[derive(Debug, Clone)]
274pub(crate) enum ConnOwner {
275 /// A one-shot dial: the pull closes the connection on teardown.
276 Owned,
277 /// A caller-owned [`WarmConnection`]: the pull tears down only its own
278 /// stream, and the warm connection closes once, on its own `Drop`.
279 #[cfg(any(test, feature = "test-util"))]
280 Borrowed,
281 /// A connection from a [`Connections`] map: the pull tears down only its
282 /// own stream, holds the connection until it ends, and unpins it from
283 /// `map` on a transport fault.
284 Shared {
285 /// The connection, held for the pull's life.
286 warm: Arc<WarmConnection>,
287 /// The map it is pinned in.
288 map: Connections,
289 },
290}
291
292impl ConnOwner {
293 /// Whether the pull closes the connection on teardown.
294 pub(crate) const fn owns(&self) -> bool {
295 matches!(self, Self::Owned)
296 }
297
298 /// Unpin a shared connection from its map when `err` is a transport
299 /// fault ([`is_transport_fault`]). Any other owner, and any other error,
300 /// leaves the map as it is.
301 pub(crate) async fn unpin_on_fault(&self, err: &anyhow::Error) {
302 if let Self::Shared { warm, map } = self
303 && is_transport_fault(err, warm)
304 {
305 map.invalidate(warm.connection().remote_id(), warm).await;
306 }
307 }
308}
309
310impl Drop for WarmConnection {
311 /// Close the warm connection once the caller is done with it. `Connection::close`
312 /// is first-wins and idempotent, and every per-hash pull leaves the connection
313 /// open, so this is the single teardown for the whole warm connection.
314 fn drop(&mut self) {
315 self.conn.close(0u32.into(), b"warm-connection-dropped");
316 }
317}
318
319#[cfg(test)]
320#[allow(clippy::unwrap_used, clippy::expect_used)] // tests
321mod tests;