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}