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        items: Vec<CoinState>,
107    },
108    /// This session has ENDED and will produce no further frames.
109    ///
110    /// A subscriber following this source learns that its stream stopped, rather than being left
111    /// with a silence it cannot distinguish from a quiet chain.
112    SessionEnded { reason: SessionEndReason },
113}
114
115/// A frame together with the session that produced it.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct SourcedFrame {
118    pub source: FrameSource,
119    pub frame: PoolFrame,
120}
121
122/// The receiving half of a subscription.
123///
124/// [`recv`](Self::recv) returning `None` means the subscription ENDED — either the pool was
125/// dropped or this subscriber fell behind and was terminated rather than silently skipped. A
126/// consumer that treats `None` as "nothing more to do" is reading a desync as quiet; treat it as
127/// "resubscribe and rebuild".
128pub struct FrameSubscription {
129    receiver: mpsc::Receiver<SourcedFrame>,
130}
131
132impl FrameSubscription {
133    /// The next frame, or `None` once the subscription has ended.
134    pub async fn recv(&mut self) -> Option<SourcedFrame> {
135        self.receiver.recv().await
136    }
137
138    /// The next frame if one is already queued.
139    pub fn try_recv(&mut self) -> Result<SourcedFrame, mpsc::error::TryRecvError> {
140        self.receiver.try_recv()
141    }
142}
143
144/// The pool's fan-out: many subscribers, each with its own bounded queue.
145pub struct FrameFanout {
146    subscribers: Mutex<Vec<mpsc::Sender<SourcedFrame>>>,
147    next_session: AtomicU64,
148}
149
150impl Default for FrameFanout {
151    fn default() -> Self {
152        Self::new()
153    }
154}
155
156impl FrameFanout {
157    pub fn new() -> Self {
158        Self {
159            subscribers: Mutex::new(Vec::new()),
160            next_session: AtomicU64::new(0),
161        }
162    }
163
164    /// Open a subscription with room for `capacity` unread frames.
165    ///
166    /// `capacity` is the consumer's own promise about how far behind it may fall: beyond it the
167    /// subscription is terminated rather than thinned.
168    pub async fn subscribe(self: &Arc<Self>, capacity: usize) -> FrameSubscription {
169        let (sender, receiver) = mpsc::channel(capacity.max(1));
170        self.subscribers.lock().await.push(sender);
171        FrameSubscription { receiver }
172    }
173
174    /// How many subscriptions are still live.
175    pub async fn subscriber_count(&self) -> usize {
176        self.subscribers.lock().await.len()
177    }
178
179    /// Allocate the identity of a new session at `address`.
180    ///
181    /// Allocation publishes nothing: a connection is identified before the pool decides whether to
182    /// admit it, so a rejected duplicate cannot announce a session that never ran.
183    /// [`open_session`](Self::open_session) announces an admitted one.
184    pub fn allocate_session(&self, address: SocketAddr) -> FrameSource {
185        FrameSource {
186            address,
187            session: SessionId(self.next_session.fetch_add(1, Ordering::Relaxed)),
188        }
189    }
190
191    /// Announce that `source` has begun, before it can publish anything else.
192    ///
193    /// The [`PoolFrame::Reset`] is delivered from INSIDE this call, so it is queued before any
194    /// frame the session's own task publishes. Publishing it from that task instead would make the
195    /// ordering a race.
196    pub async fn open_session(&self, source: FrameSource) {
197        self.publish(source, PoolFrame::Reset).await;
198    }
199
200    /// Deliver `frame` from `source` to every live subscriber, terminating any that has fallen
201    /// behind.
202    ///
203    /// A subscriber whose queue is FULL is removed, which drops the sender and ends its stream. A
204    /// subscriber whose receiver is already gone is removed too; that one is ordinary tidying.
205    pub async fn publish(&self, source: FrameSource, frame: PoolFrame) {
206        let sourced = SourcedFrame { source, frame };
207        let mut subscribers = self.subscribers.lock().await;
208        subscribers.retain(|sender| match sender.try_send(sourced.clone()) {
209            Ok(()) => true,
210            Err(mpsc::error::TrySendError::Full(_)) => {
211                log::warn!(
212                    "frame subscriber fell behind; terminating its subscription rather than \
213                     dropping a frame it would never learn it missed"
214                );
215                false
216            }
217            Err(mpsc::error::TrySendError::Closed(_)) => false,
218        });
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use std::net::{IpAddr, Ipv4Addr};
226
227    fn addr(last: u8) -> SocketAddr {
228        SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, last)), 8444)
229    }
230
231    fn source(fanout: &FrameFanout, last: u8) -> FrameSource {
232        fanout.allocate_session(addr(last))
233    }
234
235    fn peak(height: u32) -> PoolFrame {
236        PoolFrame::Peak {
237            height,
238            header_hash: Bytes32::new([height as u8; 32]),
239        }
240    }
241
242    /// **Every frame names the peer that sent it.**
243    ///
244    /// Two DIFFERENT peers publish the same kind of frame, which is the only fixture that can
245    /// distinguish attribution from a constant: a source hard-coded to anything at all, or an
246    /// envelope naming the pool rather than the peer, would give both frames one identity and fail
247    /// here. A single-peer fixture cannot see that.
248    ///
249    /// This is the property whose absence let any held peer inject `CoinStateUpdate`s
250    /// indistinguishable from the followed peer's.
251    #[tokio::test]
252    async fn a_frame_names_the_peer_that_sent_it() {
253        let fanout = Arc::new(FrameFanout::new());
254        let mut subscription = fanout.subscribe(8).await;
255
256        let honest = source(&fanout, 1);
257        let liar = source(&fanout, 2);
258
259        fanout.publish(honest, peak(100)).await;
260        fanout.publish(liar, peak(999)).await;
261
262        let first = subscription.try_recv().expect("the honest frame");
263        let second = subscription.try_recv().expect("the injected frame");
264
265        assert_eq!(first.source.address, addr(1));
266        assert_eq!(second.source.address, addr(2));
267        assert_ne!(
268            first.source, second.source,
269            "two peers must not share one frame identity, or an injected frame is \
270             indistinguishable from the followed peer's"
271        );
272        assert_eq!(
273            second.frame,
274            peak(999),
275            "the injected frame is still delivered — attribution is what lets a subscriber \
276             reject and eject its sender, not a filter here"
277        );
278    }
279
280    /// **A session's identity does not move when ANOTHER session starts.**
281    ///
282    /// The pool-wide generation this replaces was captured per handler at spawn, so a second
283    /// session made the first's frames report a generation that was no longer current: a consumer
284    /// honouring the contract discarded every earlier peer's frames on every ordinary refill.
285    ///
286    /// Peer 1 publishes, peer 2 opens, peer 1 publishes again, and both of peer 1's frames must
287    /// carry the SAME source. A fixture with one peer cannot express this at all.
288    #[tokio::test]
289    async fn a_second_session_does_not_change_the_identity_of_the_first() {
290        let fanout = Arc::new(FrameFanout::new());
291        let mut subscription = fanout.subscribe(8).await;
292
293        let first = source(&fanout, 1);
294        fanout.publish(first, peak(100)).await;
295
296        let second = source(&fanout, 2);
297        fanout.open_session(second).await;
298        fanout.publish(second, peak(101)).await;
299
300        fanout.publish(first, peak(102)).await;
301
302        let mut from_first = Vec::new();
303        while let Ok(sourced) = subscription.try_recv() {
304            if sourced.source == first {
305                from_first.push(sourced.frame);
306            }
307        }
308
309        assert_eq!(
310            from_first,
311            vec![peak(100), peak(102)],
312            "both of peer 1's frames must arrive under peer 1's own identity, before and after \
313             peer 2 connected"
314        );
315        assert_ne!(first.session, second.session, "sessions must be distinct");
316    }
317
318    /// **`Reset` opens exactly its OWN session and does not disturb another.**
319    ///
320    /// The nearest wrong implementation is a pool-wide reset: it would emit a `Reset` that a
321    /// subscriber following peer 1 must honour, invalidating state peer 1 never invalidated. Here
322    /// the only `Reset` peer 1 sees is its own.
323    #[tokio::test]
324    async fn opening_a_session_resets_only_that_session() {
325        let fanout = Arc::new(FrameFanout::new());
326        let mut subscription = fanout.subscribe(8).await;
327
328        let followed = source(&fanout, 1);
329        fanout.open_session(followed).await;
330        fanout.publish(followed, peak(100)).await;
331
332        let other = source(&fanout, 2);
333        fanout.open_session(other).await;
334
335        let mut resets_for_followed = 0usize;
336        let mut resets_for_other = 0usize;
337        while let Ok(sourced) = subscription.try_recv() {
338            if sourced.frame == PoolFrame::Reset {
339                if sourced.source == followed {
340                    resets_for_followed += 1;
341                } else if sourced.source == other {
342                    resets_for_other += 1;
343                }
344            }
345        }
346
347        assert_eq!(
348            resets_for_followed, 1,
349            "the followed session must be reset exactly once — when it opened, and never because \
350             another peer connected"
351        );
352        assert_eq!(resets_for_other, 1);
353    }
354
355    /// **`Reset` precedes the first frame of its session.**
356    ///
357    /// The assertion is on the ORDER — a `Reset` emitted after the session's first frame, or not
358    /// emitted at all, both fail here, and neither is visible from the frames' contents alone.
359    #[tokio::test]
360    async fn reset_is_delivered_before_the_first_frame_of_its_session() {
361        let fanout = Arc::new(FrameFanout::new());
362        let mut subscription = fanout.subscribe(8).await;
363
364        let session = source(&fanout, 1);
365        fanout.open_session(session).await;
366        fanout.publish(session, peak(101)).await;
367
368        let mut seen = Vec::new();
369        while let Ok(sourced) = subscription.try_recv() {
370            seen.push(sourced.frame);
371        }
372
373        let reset_at = seen
374            .iter()
375            .position(|f| *f == PoolFrame::Reset)
376            .expect("a session must announce itself with a Reset");
377        let first_frame_at = seen
378            .iter()
379            .position(|f| matches!(f, PoolFrame::Peak { height: 101, .. }))
380            .expect("the session's frame must be delivered");
381
382        assert!(
383            reset_at < first_frame_at,
384            "Reset must precede the first frame of its session: {seen:?}"
385        );
386    }
387
388    /// **A subscriber that falls behind is TERMINATED, never silently thinned.**
389    ///
390    /// Capacity is 2 and four frames are published without a single `recv`, so the third
391    /// overflows. The assertions are built so the nearest wrong implementation — drop the frame,
392    /// keep the subscription — cannot pass: it would deliver frames 1, 2 and then 4 (once room
393    /// appeared), leaving the stream OPEN. Here the stream must END after the two it accepted, and
394    /// the heights that overflowed must never appear.
395    #[tokio::test]
396    async fn a_subscriber_that_overflows_is_terminated_rather_than_missing_a_frame() {
397        let fanout = Arc::new(FrameFanout::new());
398        let mut subscription = fanout.subscribe(2).await;
399        let session = source(&fanout, 1);
400
401        for height in 1..=4u32 {
402            fanout.publish(session, peak(height)).await;
403        }
404
405        // Drained without ever awaiting, so a subscription that was WRONGLY kept alive fails an
406        // assertion here instead of hanging this test on a `recv` that never resolves.
407        let mut delivered = Vec::new();
408        let ended = loop {
409            match subscription.try_recv() {
410                Ok(SourcedFrame {
411                    frame: PoolFrame::Peak { height, .. },
412                    ..
413                }) => delivered.push(height),
414                Ok(_) => {}
415                Err(err) => break err,
416            }
417        };
418
419        assert_eq!(
420            delivered,
421            vec![1, 2],
422            "only the frames that fit may be delivered, and the stream must then end"
423        );
424        assert_eq!(
425            ended,
426            mpsc::error::TryRecvError::Disconnected,
427            "the subscription must be TERMINATED, not merely empty with frames silently skipped"
428        );
429        assert_eq!(
430            fanout.subscriber_count().await,
431            0,
432            "the overflowing subscription must be dropped, not retained and thinned"
433        );
434    }
435
436    /// The control: a subscriber that keeps up is not terminated.
437    ///
438    /// Without it, a `publish` that terminated EVERY subscriber would satisfy the overflow test.
439    #[tokio::test]
440    async fn a_subscriber_that_keeps_up_stays_subscribed() {
441        let fanout = Arc::new(FrameFanout::new());
442        let mut subscription = fanout.subscribe(2).await;
443        let session = source(&fanout, 1);
444
445        for height in 1..=4u32 {
446            fanout.publish(session, peak(height)).await;
447            assert!(matches!(
448                subscription.try_recv(),
449                Ok(SourcedFrame {
450                    frame: PoolFrame::Peak { .. },
451                    ..
452                })
453            ));
454        }
455
456        assert_eq!(fanout.subscriber_count().await, 1);
457    }
458}