Skip to main content

chia_query/peer/
frames.rs

1//! Per-subscriber fan-out of the frames arriving on a pooled peer's session.
2//!
3//! # Why the pool needs this at all
4//!
5//! A pooled session's inbound `mpsc::Receiver<Message>` is consumed by ONE task. Before this
6//! module that task folded `NewPeakWallet` into an atomic and discarded everything else, so a
7//! consumer that needs the frames themselves — a wallet replica following `CoinStateUpdate` —
8//! could not be served from a pooled session and had to dial its own. That is the reason the node
9//! ran several independent peer stacks (dig_ecosystem#2761).
10//!
11//! # Every frame names the session it came from
12//!
13//! The pool holds many peers at once and fans all of their frames into one subscription, so a
14//! frame that does not say who sent it is a claim from *the pool*, which no peer in it is entitled
15//! to make. `CoinStateUpdate` is an UNSOLICITED push carrying no request id, so an unattributed
16//! fan-out lets any held peer inject fabricated coin states that a subscriber cannot tell from the
17//! peer it deliberately followed — and cannot eject, because nothing knows who sent them.
18//!
19//! Attribution lives on [`SourcedFrame`], the envelope, rather than on the individual
20//! [`PoolFrame`] variants. A variant added later cannot forget to carry it, and a subscriber
21//! cannot read a frame without having its source in hand.
22//!
23//! # Sessions, not a pool-wide generation
24//!
25//! A [`SessionId`] identifies ONE peer connection for the life of the pool. It is allocated when
26//! that connection is admitted and never changes, so the identity a frame carries stays true
27//! whatever else the pool does afterwards.
28//!
29//! This is deliberately not a pool-wide counter. A pool of N independent sessions has no single
30//! "current" generation to be in: a counter bumped by every reconnect makes every OTHER session's
31//! frames look stale, so a consumer honouring it discards N-1 peers' frames on every ordinary
32//! refill. Staleness is a property of one peer's stream, and it is signalled on that stream.
33//!
34//! [`PoolFrame::Reset`] opens a session, [`PoolFrame::SessionEnded`] closes it, and between them
35//! everything a subscriber sees from that source belongs to one continuous connection.
36//!
37//! # Overflow terminates the subscriber; it never skips a frame
38//!
39//! Each subscriber gets a BOUNDED channel, because an unbounded one turns a slow consumer into
40//! unbounded memory. When that channel is full the subscription is DROPPED and its receiver
41//! observes the stream end.
42//!
43//! The tempting alternative — drop the frame, keep the subscription — is the failure this ordering
44//! exists to prevent. A missed `CoinStateUpdate` is a coin whose spend the replica never learns
45//! about, so the replica goes on reporting `Synced` while reading spent money as present. A
46//! terminated stream is a fact the consumer can act on; a gap is indistinguishable from quiet.
47
48use std::net::SocketAddr;
49use std::sync::atomic::{AtomicU64, Ordering};
50use std::sync::Arc;
51
52use chia_protocol::{Bytes32, CoinState};
53use tokio::sync::{mpsc, Mutex};
54
55/// One peer connection, for the life of the pool.
56///
57/// A newtype rather than a bare `u64` so a session cannot be confused with a height, which is the
58/// other monotonically increasing number every frame carries.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
60pub struct SessionId(pub u64);
61
62/// WHO a frame came from.
63///
64/// Both halves are load-bearing and neither substitutes for the other. The `address` is what a
65/// subscriber matches against the peer it chose to follow, and what it hands to
66/// [`PeerPool::eject_peer`](super::pool::PeerPool::eject_peer) to remove a peer whose frames it
67/// rejected. The `session` distinguishes two connections to the same address across a reconnect,
68/// so a late frame from a replaced session cannot be mistaken for a frame of its replacement.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub struct FrameSource {
71    pub address: SocketAddr,
72    pub session: SessionId,
73}
74
75/// Why a session stopped producing frames.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum SessionEndReason {
78    /// The transport closed: the peer went away, or the connection dropped.
79    Disconnected,
80    /// The peer sent a message whose type this crate recognises but whose body it could not
81    /// decode.
82    ///
83    /// The session ends rather than the frame being skipped. What a malformed message CONTAINED is
84    /// exactly what cannot be known, so continuing would leave the subscriber's state missing an
85    /// update it would never learn it missed — the gap this module exists to prevent — and a peer
86    /// able to induce that at will chooses when the replica goes quietly wrong.
87    UndecodableFrame,
88}
89
90/// One frame from a pooled peer session, as a subscriber sees it.
91///
92/// Carried inside a [`SourcedFrame`], which names the session it belongs to.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum PoolFrame {
95    /// This session has BEGUN. Anything derived from an earlier session at the same address is
96    /// stale.
97    ///
98    /// Delivered before any other frame of the session, never after one.
99    Reset,
100    /// A peer announced a new peak.
101    Peak { height: u32, header_hash: Bytes32 },
102    /// A peer reported coin states changing at `height`.
103    CoinStates {
104        height: u32,
105        fork_height: u32,
106        /// The header hash of the peak this update was observed against.
107        ///
108        /// Carried because `CoinStateUpdate` carries it and a subscriber tracking the peak needs
109        /// the height and the hash to arrive TOGETHER. Dropping it forces a subscriber to pair the
110        /// new height with whatever hash it already had, which names a block that never existed at
111        /// that height — and does so most often during a reorg, exactly when the pairing is what a
112        /// consumer is relying on.
113        peak_hash: Bytes32,
114        items: Vec<CoinState>,
115    },
116    /// This session has ENDED and will produce no further frames.
117    ///
118    /// A subscriber following this source learns that its stream stopped, rather than being left
119    /// with a silence it cannot distinguish from a quiet chain.
120    SessionEnded { reason: SessionEndReason },
121}
122
123/// A frame together with the session that produced it.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct SourcedFrame {
126    pub source: FrameSource,
127    pub frame: PoolFrame,
128}
129
130/// The receiving half of a subscription.
131///
132/// [`recv`](Self::recv) returning `None` means the subscription ENDED — either the pool was
133/// dropped or this subscriber fell behind and was terminated rather than silently skipped. A
134/// consumer that treats `None` as "nothing more to do" is reading a desync as quiet; treat it as
135/// "resubscribe and rebuild".
136pub struct FrameSubscription {
137    receiver: mpsc::Receiver<SourcedFrame>,
138}
139
140impl FrameSubscription {
141    /// The next frame, or `None` once the subscription has ended.
142    pub async fn recv(&mut self) -> Option<SourcedFrame> {
143        self.receiver.recv().await
144    }
145
146    /// The next frame if one is already queued.
147    pub fn try_recv(&mut self) -> Result<SourcedFrame, mpsc::error::TryRecvError> {
148        self.receiver.try_recv()
149    }
150}
151
152/// The pool's fan-out: many subscribers, each with its own bounded queue.
153pub struct FrameFanout {
154    subscribers: Mutex<Vec<mpsc::Sender<SourcedFrame>>>,
155    next_session: AtomicU64,
156}
157
158impl Default for FrameFanout {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164impl FrameFanout {
165    pub fn new() -> Self {
166        Self {
167            subscribers: Mutex::new(Vec::new()),
168            next_session: AtomicU64::new(0),
169        }
170    }
171
172    /// Open a subscription with room for `capacity` unread frames.
173    ///
174    /// `capacity` is the consumer's own promise about how far behind it may fall: beyond it the
175    /// subscription is terminated rather than thinned.
176    pub async fn subscribe(self: &Arc<Self>, capacity: usize) -> FrameSubscription {
177        let (sender, receiver) = mpsc::channel(capacity.max(1));
178        self.subscribers.lock().await.push(sender);
179        FrameSubscription { receiver }
180    }
181
182    /// How many subscriptions are still live.
183    pub async fn subscriber_count(&self) -> usize {
184        self.subscribers.lock().await.len()
185    }
186
187    /// Allocate the identity of a new session at `address`.
188    ///
189    /// Allocation publishes nothing: a connection is identified before the pool decides whether to
190    /// admit it, so a rejected duplicate cannot announce a session that never ran.
191    /// [`open_session`](Self::open_session) announces an admitted one.
192    pub fn allocate_session(&self, address: SocketAddr) -> FrameSource {
193        FrameSource {
194            address,
195            session: SessionId(self.next_session.fetch_add(1, Ordering::Relaxed)),
196        }
197    }
198
199    /// Announce that `source` has begun, before it can publish anything else.
200    ///
201    /// The [`PoolFrame::Reset`] is delivered from INSIDE this call, so it is queued before any
202    /// frame the session's own task publishes. Publishing it from that task instead would make the
203    /// ordering a race.
204    pub async fn open_session(&self, source: FrameSource) {
205        self.publish(source, PoolFrame::Reset).await;
206    }
207
208    /// Deliver `frame` from `source` to every live subscriber, terminating any that has fallen
209    /// behind.
210    ///
211    /// A subscriber whose queue is FULL is removed, which drops the sender and ends its stream. A
212    /// subscriber whose receiver is already gone is removed too; that one is ordinary tidying.
213    pub async fn publish(&self, source: FrameSource, frame: PoolFrame) {
214        let sourced = SourcedFrame { source, frame };
215        let mut subscribers = self.subscribers.lock().await;
216        subscribers.retain(|sender| match sender.try_send(sourced.clone()) {
217            Ok(()) => true,
218            Err(mpsc::error::TrySendError::Full(_)) => {
219                log::warn!(
220                    "frame subscriber fell behind; terminating its subscription rather than \
221                     dropping a frame it would never learn it missed"
222                );
223                false
224            }
225            Err(mpsc::error::TrySendError::Closed(_)) => false,
226        });
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use std::net::{IpAddr, Ipv4Addr};
234
235    fn addr(last: u8) -> SocketAddr {
236        SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, last)), 8444)
237    }
238
239    fn source(fanout: &FrameFanout, last: u8) -> FrameSource {
240        fanout.allocate_session(addr(last))
241    }
242
243    fn peak(height: u32) -> PoolFrame {
244        PoolFrame::Peak {
245            height,
246            header_hash: Bytes32::new([height as u8; 32]),
247        }
248    }
249
250    /// **Every frame names the peer that sent it.**
251    ///
252    /// Two DIFFERENT peers publish the same kind of frame, which is the only fixture that can
253    /// distinguish attribution from a constant: a source hard-coded to anything at all, or an
254    /// envelope naming the pool rather than the peer, would give both frames one identity and fail
255    /// here. A single-peer fixture cannot see that.
256    ///
257    /// This is the property whose absence let any held peer inject `CoinStateUpdate`s
258    /// indistinguishable from the followed peer's.
259    #[tokio::test]
260    async fn a_frame_names_the_peer_that_sent_it() {
261        let fanout = Arc::new(FrameFanout::new());
262        let mut subscription = fanout.subscribe(8).await;
263
264        let honest = source(&fanout, 1);
265        let liar = source(&fanout, 2);
266
267        fanout.publish(honest, peak(100)).await;
268        fanout.publish(liar, peak(999)).await;
269
270        let first = subscription.try_recv().expect("the honest frame");
271        let second = subscription.try_recv().expect("the injected frame");
272
273        assert_eq!(first.source.address, addr(1));
274        assert_eq!(second.source.address, addr(2));
275        assert_ne!(
276            first.source, second.source,
277            "two peers must not share one frame identity, or an injected frame is \
278             indistinguishable from the followed peer's"
279        );
280        assert_eq!(
281            second.frame,
282            peak(999),
283            "the injected frame is still delivered — attribution is what lets a subscriber \
284             reject and eject its sender, not a filter here"
285        );
286    }
287
288    /// **A session's identity does not move when ANOTHER session starts.**
289    ///
290    /// The pool-wide generation this replaces was captured per handler at spawn, so a second
291    /// session made the first's frames report a generation that was no longer current: a consumer
292    /// honouring the contract discarded every earlier peer's frames on every ordinary refill.
293    ///
294    /// Peer 1 publishes, peer 2 opens, peer 1 publishes again, and both of peer 1's frames must
295    /// carry the SAME source. A fixture with one peer cannot express this at all.
296    #[tokio::test]
297    async fn a_second_session_does_not_change_the_identity_of_the_first() {
298        let fanout = Arc::new(FrameFanout::new());
299        let mut subscription = fanout.subscribe(8).await;
300
301        let first = source(&fanout, 1);
302        fanout.publish(first, peak(100)).await;
303
304        let second = source(&fanout, 2);
305        fanout.open_session(second).await;
306        fanout.publish(second, peak(101)).await;
307
308        fanout.publish(first, peak(102)).await;
309
310        let mut from_first = Vec::new();
311        while let Ok(sourced) = subscription.try_recv() {
312            if sourced.source == first {
313                from_first.push(sourced.frame);
314            }
315        }
316
317        assert_eq!(
318            from_first,
319            vec![peak(100), peak(102)],
320            "both of peer 1's frames must arrive under peer 1's own identity, before and after \
321             peer 2 connected"
322        );
323        assert_ne!(first.session, second.session, "sessions must be distinct");
324    }
325
326    /// **`Reset` opens exactly its OWN session and does not disturb another.**
327    ///
328    /// The nearest wrong implementation is a pool-wide reset: it would emit a `Reset` that a
329    /// subscriber following peer 1 must honour, invalidating state peer 1 never invalidated. Here
330    /// the only `Reset` peer 1 sees is its own.
331    #[tokio::test]
332    async fn opening_a_session_resets_only_that_session() {
333        let fanout = Arc::new(FrameFanout::new());
334        let mut subscription = fanout.subscribe(8).await;
335
336        let followed = source(&fanout, 1);
337        fanout.open_session(followed).await;
338        fanout.publish(followed, peak(100)).await;
339
340        let other = source(&fanout, 2);
341        fanout.open_session(other).await;
342
343        let mut resets_for_followed = 0usize;
344        let mut resets_for_other = 0usize;
345        while let Ok(sourced) = subscription.try_recv() {
346            if sourced.frame == PoolFrame::Reset {
347                if sourced.source == followed {
348                    resets_for_followed += 1;
349                } else if sourced.source == other {
350                    resets_for_other += 1;
351                }
352            }
353        }
354
355        assert_eq!(
356            resets_for_followed, 1,
357            "the followed session must be reset exactly once — when it opened, and never because \
358             another peer connected"
359        );
360        assert_eq!(resets_for_other, 1);
361    }
362
363    /// **`Reset` precedes the first frame of its session.**
364    ///
365    /// The assertion is on the ORDER — a `Reset` emitted after the session's first frame, or not
366    /// emitted at all, both fail here, and neither is visible from the frames' contents alone.
367    #[tokio::test]
368    async fn reset_is_delivered_before_the_first_frame_of_its_session() {
369        let fanout = Arc::new(FrameFanout::new());
370        let mut subscription = fanout.subscribe(8).await;
371
372        let session = source(&fanout, 1);
373        fanout.open_session(session).await;
374        fanout.publish(session, peak(101)).await;
375
376        let mut seen = Vec::new();
377        while let Ok(sourced) = subscription.try_recv() {
378            seen.push(sourced.frame);
379        }
380
381        let reset_at = seen
382            .iter()
383            .position(|f| *f == PoolFrame::Reset)
384            .expect("a session must announce itself with a Reset");
385        let first_frame_at = seen
386            .iter()
387            .position(|f| matches!(f, PoolFrame::Peak { height: 101, .. }))
388            .expect("the session's frame must be delivered");
389
390        assert!(
391            reset_at < first_frame_at,
392            "Reset must precede the first frame of its session: {seen:?}"
393        );
394    }
395
396    /// **A subscriber that falls behind is TERMINATED, never silently thinned.**
397    ///
398    /// Capacity is 2 and four frames are published without a single `recv`, so the third
399    /// overflows. The assertions are built so the nearest wrong implementation — drop the frame,
400    /// keep the subscription — cannot pass: it would deliver frames 1, 2 and then 4 (once room
401    /// appeared), leaving the stream OPEN. Here the stream must END after the two it accepted, and
402    /// the heights that overflowed must never appear.
403    #[tokio::test]
404    async fn a_subscriber_that_overflows_is_terminated_rather_than_missing_a_frame() {
405        let fanout = Arc::new(FrameFanout::new());
406        let mut subscription = fanout.subscribe(2).await;
407        let session = source(&fanout, 1);
408
409        for height in 1..=4u32 {
410            fanout.publish(session, peak(height)).await;
411        }
412
413        // Drained without ever awaiting, so a subscription that was WRONGLY kept alive fails an
414        // assertion here instead of hanging this test on a `recv` that never resolves.
415        let mut delivered = Vec::new();
416        let ended = loop {
417            match subscription.try_recv() {
418                Ok(SourcedFrame {
419                    frame: PoolFrame::Peak { height, .. },
420                    ..
421                }) => delivered.push(height),
422                Ok(_) => {}
423                Err(err) => break err,
424            }
425        };
426
427        assert_eq!(
428            delivered,
429            vec![1, 2],
430            "only the frames that fit may be delivered, and the stream must then end"
431        );
432        assert_eq!(
433            ended,
434            mpsc::error::TryRecvError::Disconnected,
435            "the subscription must be TERMINATED, not merely empty with frames silently skipped"
436        );
437        assert_eq!(
438            fanout.subscriber_count().await,
439            0,
440            "the overflowing subscription must be dropped, not retained and thinned"
441        );
442    }
443
444    /// The control: a subscriber that keeps up is not terminated.
445    ///
446    /// Without it, a `publish` that terminated EVERY subscriber would satisfy the overflow test.
447    #[tokio::test]
448    async fn a_subscriber_that_keeps_up_stays_subscribed() {
449        let fanout = Arc::new(FrameFanout::new());
450        let mut subscription = fanout.subscribe(2).await;
451        let session = source(&fanout, 1);
452
453        for height in 1..=4u32 {
454            fanout.publish(session, peak(height)).await;
455            assert!(matches!(
456                subscription.try_recv(),
457                Ok(SourcedFrame {
458                    frame: PoolFrame::Peak { .. },
459                    ..
460                })
461            ));
462        }
463
464        assert_eq!(fanout.subscriber_count().await, 1);
465    }
466}