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}