chia_query/peer/pool.rs
1use std::net::SocketAddr;
2use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
3use std::sync::Arc;
4use std::time::Duration;
5
6use chia_protocol::{Message, NewPeakWallet, ProtocolMessageTypes};
7use chia_traits::Streamable;
8use futures_util::stream::{FuturesUnordered, StreamExt};
9use tokio::sync::{mpsc, RwLock};
10
11use chia_wallet_sdk::client::Peer;
12use tokio_tungstenite::Connector;
13
14use crate::types::ChiaQueryError;
15use crate::NetworkType;
16
17use super::connect;
18
19// ---------------------------------------------------------------------------
20// Pool entry
21// ---------------------------------------------------------------------------
22
23struct PeerEntry {
24 peer: Peer,
25 address: SocketAddr,
26 /// How this peer was reached. Held so a caller counting independent opinions can tell a
27 /// preferred local node from a discovered one — see [`connect::PeerOrigin`].
28 origin: connect::PeerOrigin,
29}
30
31// ---------------------------------------------------------------------------
32// PeerRequirement
33// ---------------------------------------------------------------------------
34
35/// Whether at least one peer must connect for the pool to be considered usable.
36///
37/// A client that can fall back to the coinset HTTP tier is still useful with zero
38/// peers, so failing construction on peer discovery would deny a keyless reader over a
39/// peer-tier problem it does not need (dig_ecosystem#2210).
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum PeerRequirement {
42 /// Peer discovery failing is fatal.
43 Required,
44 /// An empty pool is acceptable; it refills in the background.
45 Optional,
46}
47
48// ---------------------------------------------------------------------------
49// PeerPool
50// ---------------------------------------------------------------------------
51
52pub struct PeerPool {
53 entries: RwLock<Vec<PeerEntry>>,
54 next_idx: AtomicUsize,
55 max_peers: usize,
56 tls: Connector,
57 network: NetworkType,
58 connect_timeout: Duration,
59 /// Latest peak height observed from any connected peer's NewPeakWallet
60 /// messages. Updated in the background by receiver handler tasks.
61 peak_height: Arc<AtomicU32>,
62}
63
64impl PeerPool {
65 /// Spin up the pool by connecting to `max_peers` random full-node peers
66 /// concurrently. Under [`PeerRequirement::Required`] at least one peer must
67 /// succeed, otherwise we return [`ChiaQueryError::PeerDiscoveryFailed`]; under
68 /// [`PeerRequirement::Optional`] an empty pool is returned and refills later.
69 pub async fn new(
70 network: NetworkType,
71 tls: Connector,
72 max_peers: usize,
73 requirement: PeerRequirement,
74 connect_timeout: Duration,
75 ) -> Result<Self, ChiaQueryError> {
76 let peak_height = Arc::new(AtomicU32::new(0));
77
78 // Connect to peers concurrently.
79 let mut futures = FuturesUnordered::new();
80 for _ in 0..max_peers {
81 let t = tls.clone();
82 futures.push(async move {
83 connect::connect_random_peer_excluding(network, &t, connect_timeout, &[]).await
84 });
85 }
86
87 let mut connected = Vec::new();
88 while let Some(result) = futures.next().await {
89 match result {
90 Ok(connection) => connected.push(connection),
91 Err(e) => log::debug!("initial peer connect failed: {e}"),
92 }
93 }
94
95 let pool = Self {
96 entries: RwLock::new(Vec::new()),
97 next_idx: AtomicUsize::new(0),
98 max_peers,
99 tls,
100 network,
101 connect_timeout,
102 peak_height,
103 };
104
105 // Every connection enters through `admit`, including these, so the distinctness invariant
106 // has exactly ONE enforcement site. The initial fill is where duplicates were most likely:
107 // `max_peers` dials race concurrently with no knowledge of each other, so each one may
108 // return the same priority address. A receiver handler is spawned only for a connection
109 // that was actually admitted — spawning one for a discarded duplicate would keep feeding
110 // peak heights from a connection nothing else can see, and this must happen after pool
111 // construction so the `peak_height` Arc exists.
112 for (peer, addr, receiver, origin) in connected {
113 if pool.admit(peer, addr, origin).await {
114 pool.spawn_receiver_handler(receiver);
115 }
116 }
117
118 if !pool.has_peers().await {
119 if requirement == PeerRequirement::Required {
120 return Err(ChiaQueryError::PeerDiscoveryFailed);
121 }
122 log::warn!("no peers connected; serving from the coinset fallback until one does");
123 }
124
125 Ok(pool)
126 }
127
128 /// Latest peak height observed across all connected peers.
129 /// Returns 0 if no peak has been received yet.
130 pub fn peak_height(&self) -> u32 {
131 self.peak_height.load(Ordering::Relaxed)
132 }
133
134 /// Round-robin select a peer from the pool.
135 /// Returns `None` when the pool is empty.
136 pub async fn select_peer(&self) -> Option<(Peer, SocketAddr)> {
137 let entries = self.entries.read().await;
138 if entries.is_empty() {
139 return None;
140 }
141 let idx = self.next_idx.fetch_add(1, Ordering::Relaxed) % entries.len();
142 let entry = &entries[idx];
143 Some((entry.peer.clone(), entry.address))
144 }
145
146 /// Select a peer that could CORROBORATE an answer already given by the peer at `asked`.
147 ///
148 /// A corroborating peer must be two things at once, and neither alone is enough:
149 ///
150 /// - **A different address than `asked`.** Asking the same connection twice returns the same
151 /// opinion twice, which reads as agreement while being one voice.
152 /// - **[`PeerOrigin::Discovered`](connect::PeerOrigin).** A peer reached from a preferred
153 /// address — an operator's node, or one on this machine — is an excellent peer to READ from
154 /// and is not evidence about the chain independent of this host, exactly as
155 /// [`independent_peer_count`](Self::independent_peer_count) records.
156 ///
157 /// Returns `None` when the pool holds no such peer, which is the honest answer that there is
158 /// nobody to corroborate with — never a substitute peer that would manufacture agreement.
159 pub async fn select_corroborating_peer(&self, asked: SocketAddr) -> Option<(Peer, SocketAddr)> {
160 let entries = self.entries.read().await;
161 let candidates: Vec<&PeerEntry> = entries
162 .iter()
163 .filter(|e| e.address != asked && e.origin == connect::PeerOrigin::Discovered)
164 .collect();
165 if candidates.is_empty() {
166 return None;
167 }
168 let idx = self.next_idx.fetch_add(1, Ordering::Relaxed) % candidates.len();
169 let entry = candidates[idx];
170 Some((entry.peer.clone(), entry.address))
171 }
172
173 /// Remove a peer from the pool and asynchronously connect a replacement.
174 pub async fn eject_peer(&self, addr: SocketAddr) {
175 {
176 let mut entries = self.entries.write().await;
177 entries.retain(|e| e.address != addr);
178 }
179 log::debug!(
180 "peer ejected from pool; will refill on next request (network={:?})",
181 self.network,
182 );
183 }
184
185 /// Whether the pool has at least one usable peer.
186 pub async fn has_peers(&self) -> bool {
187 !self.entries.read().await.is_empty()
188 }
189
190 /// How many peers the pool HOLDS right now.
191 ///
192 /// This is a live count of the connections currently in the pool, not
193 /// [`max_peers`](Self::new)'s target: a pool that is still filling reports what it has, and
194 /// reports the target only once it has reached it. A caller showing this number to a user is
195 /// stating a fact about the machine, so a configured intention must never stand in for it.
196 ///
197 /// A peer is removed by [`eject_peer`](Self::eject_peer), which runs when a request to it
198 /// FAILS. So the count is of peers held and believed usable; a connection that has died
199 /// silently is still counted until something tries to use it. That is the same liveness
200 /// standard [`has_peers`](Self::has_peers) has always answered by, made countable.
201 pub async fn peer_count(&self) -> usize {
202 self.entries.read().await.len()
203 }
204
205 /// How many peers the pool holds that are INDEPENDENT opinions.
206 ///
207 /// [`peer_count`](Self::peer_count) answers "how many connections do I have"; this answers
208 /// "how many of them could corroborate each other". They differ by the peers reached from a
209 /// preferred address — an operator's trusted node or one on this machine — which are excellent
210 /// peers to READ from and are not evidence about the chain independent of this host. A caller
211 /// deciding whether enough separate sources agree MUST use this number, because counting a
212 /// co-resident node as an independent voice is the thing that made a single local process able
213 /// to look like a full peer set (dig_ecosystem#2648).
214 pub async fn independent_peer_count(&self) -> usize {
215 self.entries
216 .read()
217 .await
218 .iter()
219 .filter(|e| e.origin == connect::PeerOrigin::Discovered)
220 .count()
221 }
222
223 /// Admit a connection, or reject it, deciding under the WRITE lock.
224 ///
225 /// Returns whether it was admitted. Rejected because the pool is full, or because its address
226 /// is already held — a pool of N connections to one address reports itself healthy while being
227 /// a single point of both failure and deceit (dig_ecosystem#2648).
228 ///
229 /// **Both checks are made while HOLDING the write lock, and that placement is the whole
230 /// correctness of this.** Dials run concurrently, so any check made before acquiring the lock —
231 /// under the read lock, or by the caller — is a time-of-check/time-of-use gap: two fills of the
232 /// same address each observe it absent, then each pushes, and the duplicate is admitted by
233 /// exactly the code written to prevent it. The check and the push must be one critical section.
234 async fn admit(&self, peer: Peer, address: SocketAddr, origin: connect::PeerOrigin) -> bool {
235 let mut entries = self.entries.write().await;
236
237 if entries.len() >= self.max_peers {
238 log::debug!("peer {address} not admitted: pool is at capacity");
239 return false;
240 }
241 if entries.iter().any(|e| e.address == address) {
242 log::debug!("peer {address} not admitted: already held");
243 return false;
244 }
245
246 entries.push(PeerEntry {
247 peer,
248 address,
249 origin,
250 });
251 log::debug!("peer admitted: {address} ({origin:?})");
252 true
253 }
254
255 /// If the pool is under capacity, try to connect one new peer.
256 /// Also spawns a background task to handle its inbound `NewPeakWallet`
257 /// messages.
258 pub async fn try_refill(&self) {
259 let held: Vec<SocketAddr> = {
260 let entries = self.entries.read().await;
261 if entries.len() >= self.max_peers {
262 return;
263 }
264 entries.iter().map(|e| e.address).collect()
265 };
266
267 // `held` is a hint to the dial, not the guard: it saves dialling an address already in the
268 // pool (the local one is offered on every call), and it may be stale the moment it is read.
269 // `admit` re-decides under the write lock, which is where the invariant actually holds.
270 match connect::connect_random_peer_excluding(
271 self.network,
272 &self.tls,
273 self.connect_timeout,
274 &held,
275 )
276 .await
277 {
278 Ok((peer, addr, receiver, origin)) => {
279 if self.admit(peer, addr, origin).await {
280 self.spawn_receiver_handler(receiver);
281 log::debug!("replacement peer connected: {addr}");
282 }
283 }
284 Err(e) => log::warn!("replacement peer connect failed: {e}"),
285 }
286 }
287
288 // -----------------------------------------------------------------------
289 // Receiver helpers (handle NewPeakWallet from peers)
290 // -----------------------------------------------------------------------
291
292 /// Spawn a background task that reads inbound messages from a peer's
293 /// receiver channel and updates the shared peak height. This mirrors
294 /// the pattern used by chia-block-listener.
295 pub fn spawn_receiver_handler(&self, mut receiver: mpsc::Receiver<Message>) {
296 let peak = Arc::clone(&self.peak_height);
297 tokio::spawn(async move {
298 while let Some(msg) = receiver.recv().await {
299 if msg.msg_type == ProtocolMessageTypes::NewPeakWallet {
300 if let Ok(new_peak) = NewPeakWallet::from_bytes(&msg.data) {
301 let prev = peak.fetch_max(new_peak.height, Ordering::Relaxed);
302 if new_peak.height > prev {
303 log::debug!("new peak from peer: {}", new_peak.height);
304 }
305 }
306 }
307 }
308 });
309 }
310}
311
312/// Construction and admission reachable from OTHER modules' tests.
313///
314/// [`PeerPool::new`] dials the network, so a test of anything built ON the pool — the backend's
315/// absence corroboration, for one — cannot use it. These wrap the private internals rather than
316/// widening them, so production code keeps exactly one admission path.
317#[cfg(test)]
318impl PeerPool {
319 pub(crate) fn for_tests(max_peers: usize) -> Self {
320 Self {
321 entries: RwLock::new(Vec::new()),
322 next_idx: AtomicUsize::new(0),
323 max_peers,
324 tls: connect::create_generated_tls().expect("generate a TLS identity"),
325 network: NetworkType::Mainnet,
326 connect_timeout: Duration::from_millis(1),
327 peak_height: Arc::new(AtomicU32::new(0)),
328 }
329 }
330
331 pub(crate) async fn admit_for_tests(
332 &self,
333 peer: Peer,
334 address: SocketAddr,
335 origin: connect::PeerOrigin,
336 ) -> bool {
337 self.admit(peer, address, origin).await
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::peer::connect::{create_generated_tls, PeerOrigin};
345 use crate::peer::test_support::{address, loopback_peer};
346
347 use super::PeerPool as _Pool;
348 fn empty_pool(max_peers: usize) -> PeerPool {
349 _Pool::for_tests(max_peers)
350 }
351
352 /// **The defect, and the one shape that separates a locked re-check from a TOCTOU dedupe.**
353 ///
354 /// Eight fills of the SAME address are admitted CONCURRENTLY, which is how the pool fills in
355 /// production: `PeerPool::new` races `max_peers` dials with no knowledge of each other, and each
356 /// may return the same priority address. A dedupe that reads the entry list before taking the
357 /// write lock passes a sequential test and fails this one — every task observes the address
358 /// absent, then every task pushes.
359 ///
360 /// `max_peers` is 8, not 1, deliberately: a capacity of one would make the pool reject the
361 /// duplicates for being FULL rather than for being duplicates, and would stay green with the
362 /// distinctness check deleted entirely.
363 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
364 async fn one_address_cannot_fill_the_pool_however_many_fills_race() {
365 let pool = Arc::new(empty_pool(8));
366 let peer = loopback_peer().await;
367 let occupied = address(1);
368
369 let mut fills = Vec::new();
370 for _ in 0..8 {
371 let pool = Arc::clone(&pool);
372 let peer = peer.clone();
373 fills.push(tokio::spawn(async move {
374 pool.admit(peer, occupied, PeerOrigin::Priority).await
375 }));
376 }
377
378 let admitted = futures_util::future::join_all(fills)
379 .await
380 .into_iter()
381 .filter(|r| *r.as_ref().expect("the admission task must not panic"))
382 .count();
383
384 assert_eq!(
385 admitted, 1,
386 "exactly one fill of an address may be admitted"
387 );
388 assert_eq!(
389 pool.peer_count().await,
390 1,
391 "eight concurrent fills of one address must leave one connection, not eight"
392 );
393 }
394
395 /// The control that keeps the test above honest: concurrency itself must not cost admissions.
396 ///
397 /// Without this, an `admit` that rejected everything after the first — or that lost racing
398 /// pushes — would satisfy the distinctness test while breaking the pool.
399 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
400 async fn distinct_addresses_all_fill_concurrently() {
401 let pool = Arc::new(empty_pool(8));
402 let peer = loopback_peer().await;
403
404 let mut fills = Vec::new();
405 for octet in 1..=8u8 {
406 let pool = Arc::clone(&pool);
407 let peer = peer.clone();
408 fills.push(tokio::spawn(async move {
409 pool.admit(peer, address(octet), PeerOrigin::Discovered)
410 .await
411 }));
412 }
413 futures_util::future::join_all(fills).await;
414
415 assert_eq!(
416 pool.peer_count().await,
417 8,
418 "eight distinct addresses must all be admitted"
419 );
420 }
421
422 /// Capacity is enforced in the same critical section, so racing fills cannot overshoot it.
423 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
424 async fn concurrent_fills_never_exceed_max_peers() {
425 let pool = Arc::new(empty_pool(3));
426 let peer = loopback_peer().await;
427
428 let mut fills = Vec::new();
429 for octet in 1..=10u8 {
430 let pool = Arc::clone(&pool);
431 let peer = peer.clone();
432 fills.push(tokio::spawn(async move {
433 pool.admit(peer, address(octet), PeerOrigin::Discovered)
434 .await
435 }));
436 }
437 futures_util::future::join_all(fills).await;
438
439 assert_eq!(pool.peer_count().await, 3, "max_peers is a hard ceiling");
440 }
441
442 /// **A preferred peer is not a corroborating one.**
443 ///
444 /// Two `Discovered` peers sit beside one `Priority` peer, so the two counts differ by exactly
445 /// the priority entry. A single-origin fixture cannot show that: all-priority or all-discovered
446 /// both make the two counts move together, which an implementation returning `peer_count` for
447 /// both would satisfy.
448 #[tokio::test]
449 async fn a_preferred_peer_is_held_but_not_counted_as_an_independent_opinion() {
450 let pool = empty_pool(5);
451 let peer = loopback_peer().await;
452
453 assert!(
454 pool.admit(peer.clone(), address(1), PeerOrigin::Priority)
455 .await
456 );
457 assert!(
458 pool.admit(peer.clone(), address(2), PeerOrigin::Discovered)
459 .await
460 );
461 assert!(pool.admit(peer, address(3), PeerOrigin::Discovered).await);
462
463 assert_eq!(pool.peer_count().await, 3, "three connections are held");
464 assert_eq!(
465 pool.independent_peer_count().await,
466 2,
467 "the co-resident peer is held and read from, but is not an independent voice"
468 );
469 }
470
471 /// An ejected address is admissible again — distinctness must not become a permanent ban.
472 #[tokio::test]
473 async fn an_ejected_address_can_be_admitted_again() {
474 let pool = empty_pool(5);
475 let peer = loopback_peer().await;
476 let addr = address(1);
477
478 assert!(pool.admit(peer.clone(), addr, PeerOrigin::Discovered).await);
479 assert!(
480 !pool.admit(peer.clone(), addr, PeerOrigin::Discovered).await,
481 "still held, so still a duplicate"
482 );
483
484 pool.eject_peer(addr).await;
485
486 assert!(
487 pool.admit(peer, addr, PeerOrigin::Discovered).await,
488 "a re-dialled peer must be admissible after ejection"
489 );
490 assert_eq!(pool.peer_count().await, 1);
491 }
492
493 /// `max_peers: 0` attempts no connection at all, so the pool is deterministically
494 /// empty offline — an exact, network-free fixture for the empty-pool branch.
495 async fn pool_with_no_connection_attempts(
496 requirement: PeerRequirement,
497 ) -> Result<PeerPool, ChiaQueryError> {
498 PeerPool::new(
499 NetworkType::Mainnet,
500 create_generated_tls().expect("generate a TLS identity"),
501 0,
502 requirement,
503 Duration::from_millis(1),
504 )
505 .await
506 }
507
508 /// The control: an empty pool is still fatal when nothing can serve in its place.
509 #[tokio::test]
510 async fn empty_pool_is_fatal_when_peers_are_required() {
511 assert!(matches!(
512 pool_with_no_connection_attempts(PeerRequirement::Required).await,
513 Err(ChiaQueryError::PeerDiscoveryFailed)
514 ));
515 }
516
517 /// The fix: with a fallback able to serve, an empty pool must not deny the client.
518 #[tokio::test]
519 async fn empty_pool_is_tolerated_when_peers_are_optional() {
520 let pool = pool_with_no_connection_attempts(PeerRequirement::Optional)
521 .await
522 .expect("an optional peer pool must construct with zero peers");
523 assert!(!pool.has_peers().await);
524 }
525
526 /// **The count is what is HELD, never what was asked for.**
527 ///
528 /// Built by hand rather than through [`PeerPool::new`] so `max_peers` can be a realistic 5
529 /// while the pool provably holds nothing — the one shape that separates a measurement from a
530 /// configured intention. A `peer_count` that returned `max_peers` would satisfy every
531 /// assertion reachable through the offline constructor, whose `max_peers` is necessarily 0,
532 /// and would then report "5 peers" on a machine holding none.
533 #[tokio::test]
534 async fn an_unfilled_pool_counts_what_it_holds_not_the_target_it_was_given() {
535 let pool = empty_pool(5);
536
537 assert_eq!(
538 pool.peer_count().await,
539 0,
540 "held is 0 while the target is 5"
541 );
542 assert!(!pool.has_peers().await);
543 }
544}