//! Exchange state machine for tines-core. //! //! `Exchange` tracks the peers this node knows about and drives the //! content-defined gossip protocol: acknowledging received observations, //! summarizing what we hold, requesting what peers are missing, and delivering //! observations (fragmenting them across the wire when they exceed the MTU). //! //! Everything here is `#![no_std]`-clean and touches **no allocation**: peers //! live in a fixed [`heapless`] array, observation send queues are //! [`heapless`] vectors, and fragment buffers are stack arrays. No path reads //! untrusted wire bytes without first going through [`Frame::decode`], which //! turns any malformed input into an `Err` that the caller drops. use crate::consts; use crate::{ bloom::Bloom, frag::Reassembler, wire::{Frame, WireError}, Event, NodeId, Observation, ObservationId, SendError, Store, Tick, }; use heapless::Vec; /// Worst-case bytes a [`Frame::Summary`] adds around its bloom bits: the /// `WIRE_VERSION` byte (1), the variant tag (1), `from` (16), `salt` as a /// varint (up to 5), and the bits length as a varint (up to 2). const SUMMARY_OVERHEAD: usize = 25; /// A fragmented observation part-way through transmission to one peer. /// /// Every fragment of a stream is cut from the same encoding: the age is frozen /// when the first fragment goes out, and `len`/`count` let a later fragment /// detect that the stored observation no longer encodes the same way (or the /// MTU changed). The stream is then abandoned and restarted rather than /// sending bytes the receiver cannot reassemble. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct InFlight { /// The observation being fragmented. pub id: ObservationId, /// Index of the next fragment to send. pub next: u8, /// Total fragments in the stream. pub count: u8, /// `age_ms` the stream was encoded with. pub age_ms: u64, /// Length of the encoded observation the fragments are cut from. (A /// fragment cut from the wrong offset would reassemble to corrupt bytes.) pub len: usize, } /// Why the `Exchange` dropped an inbound frame it could not use; reported /// via [`NodeEvent::Dropped`]. A peer's `Exchange` rejects expired addressed /// observations by policy — those are *not* counted as drops. #[non_exhaustive] #[derive(Clone, Copy, Debug, PartialEq)] pub enum DropReason { /// The frame's leading `WIRE_VERSION` byte did not match /// [`consts::WIRE_VERSION`]. VersionMismatch { /// The version byte read off the wire. version: u8, }, /// The frame was not a valid `Frame` (truncated, garbage, or empty). Malformed, } /// Emitted by [`crate::Node::drain_events`] for either a broadcast a peer /// confirmed holding (a *confirmed delivery*) or an inbound frame the peer /// dropped and counted. #[non_exhaustive] #[derive(Clone, Copy, Debug, PartialEq)] pub enum NodeEvent { /// A broadcast observation the peer at `peer` confirmed holding (via an /// `Ack` receipt); `id` is the broadcast's content-ID. Delivered { /// The node id of the peer that acknowledged receiving the observation. peer: NodeId, /// The content-ID of the broadcast observation that was acknowledged. id: ObservationId, }, /// The peer at `addr` sent a frame we dropped and counted under `reason`. /// `count` is how many frames were counted for this `(addr, reason)` pair /// since the last drain (always `>= 1`); `addr` is `None` when the /// drop-counter table is full and the count lives in the single overflow /// slot instead. Dropped { /// The wire address of the peer that sent the dropped frame. `None` /// only for the overflow slot, which has no address to name. addr: Option, /// Why the frame was dropped. reason: DropReason, /// The number of frames folded into this counter since the last drain. count: u32, }, } /// The `Exchange`'s counted-drop table: up to [`consts::DROP_TABLE_CAP`] /// distinct `(addr, DropReason)` pairs, each with a saturating count, plus a /// single overflow counter. Counters are drained — and reset — through /// [`Exchange::drain_events`]. #[derive(Clone, Debug)] pub(crate) struct DropTable { /// `(addr, reason, count)`, in insertion order; `count` saturates at `u32`. entries: Vec<(A, DropReason, u32), { consts::DROP_TABLE_CAP }>, /// A single slot for drops that arrive once the table is full: no address, /// just the reason and a combined count. Reported with `addr: None`. overflow: Option<(DropReason, u32)>, } impl DropTable { /// Creates an empty drop-counter table. fn new() -> Self { Self { entries: Vec::new(), overflow: None, } } /// Records one drop for `(addr, reason)`: bumps the matching entry's count /// if present, otherwise adds a new entry, or folds into the single overflow /// counter once the table is full. fn record(&mut self, addr: A, reason: DropReason) { for entry in self.entries.iter_mut() { if entry.0 == addr && entry.1 == reason { entry.2 = entry.2.saturating_add(1); return; } } if self.entries.is_full() { match self.overflow { Some((_, ref mut count)) => *count = count.saturating_add(1), None => self.overflow = Some((reason, 1)), } } else { let _ = self.entries.push((addr, reason, 1)); } } /// Emits one [`NodeEvent::Dropped`] per non-zero entry (resetting its count, /// keeping the key) followed by the overflow counter, if any. fn drain(&mut self) -> Vec, { consts::DROP_TABLE_CAP + 1 }> { let mut out: Vec, { consts::DROP_TABLE_CAP + 1 }> = Vec::new(); let mut i = 0; while let Some(entry) = self.entries.get_mut(i) { let (addr, reason, count) = (entry.0.clone(), entry.1, entry.2); if count > 0 { let _ = out.push(NodeEvent::Dropped { addr: Some(addr), reason, count, }); entry.2 = 0; } i += 1; } if let Some((reason, count)) = self.overflow.take() { if count > 0 { let _ = out.push(NodeEvent::Dropped { addr: None, reason, count, }); } } out } } /// A single peer the `Exchange` is tracking. pub(crate) struct Peer { /// Wire address of the peer. pub addr: A, /// Node id learned from the first frame received from this peer. pub id: Option, /// The peer's last-sent summary, if any. Used to queue only what they lack. pub their_summary: Option, /// Observations to send to this peer, youngest (lowest current age) first; /// the front (`index 0`) is the next id to emit. pub send_queue: Vec, /// Tick of the last summary we sent to this peer, if any. pub last_summary_sent: Option, /// Reassembler for inbound fragmented streams from this peer. pub reassembler: Reassembler, /// An observation currently mid-fragmentation to this peer. It is finished /// before any other observation is sent to the peer. pub in_flight: Option, /// Whether the next new observation for this peer should come from the /// broadcast diff rather than custody, so neither starves the other. prefer_broadcast: bool, /// The last addressed id started for this peer. Custody resumes after it, /// so every candidate gets a turn even when the bounce table overflows. custody_cursor: Option, } impl Peer { /// Creates a peer with no known id, no summary, empty queue, and a fresh /// reassembler — `last_summary_sent` is `None` so a summary is emitted on /// the next [`Exchange::next_frame`]. fn new(addr: A) -> Self { Peer { addr, id: None, their_summary: None, send_queue: Vec::new(), last_summary_sent: None, reassembler: Reassembler::new(), in_flight: None, prefer_broadcast: false, custody_cursor: None, } } } /// The bookkeeping [`Exchange::next_frame`] changed for the frame it last /// produced, kept so [`Exchange::on_send_error`] can put it back. struct Undo { addr: A, salt: u32, /// `Some(previous)` when the frame was a summary. last_summary_sent: Option>, in_flight: Option, prefer_broadcast: bool, custody_cursor: Option, /// `(position, id)` removed from the send queue. requeue: Option<(usize, ObservationId)>, /// Id whose `(id, addr)` bounce record this frame added. bounce: Option, } /// A bounded ring of `(observation-id, addr)` pairs awaiting an `Ack` /// emission. The oldest entry is dropped when full. #[derive(Clone, Debug)] struct AckBuffer { entries: Vec<(ObservationId, A), { consts::ACK_BUFFER_CAP }>, } impl AckBuffer { fn new() -> Self { Self { entries: Vec::new(), } } fn front(&self) -> Option<&(ObservationId, A)> { self.entries.first() } fn len(&self) -> usize { self.entries.len() } /// Enqueue `(id, addr)`, dropping the oldest entry if the buffer is full. fn enqueue(&mut self, id: ObservationId, addr: A) { if self.entries.is_full() { self.entries.remove(0); } let _ = self.entries.push((id, addr)); } } /// The ids removed from a pending buffer by a failed (Busy) frame /// emission, so [`Exchange::on_send_error`] can restore them for retry. /// Dropped on `TooLarge`. #[derive(Debug)] struct UndoAck { addr: A, ids: Vec, is_bcast: bool, } /// The exchange state machine, tracking up to [`consts::MAX_PEERS`] peers. pub(crate) struct Exchange { me: NodeId, salt: u32, peers: Vec, { consts::MAX_PEERS }>, rr: usize, /// `(id, addr, direction)` for each addressed message received from, or /// fully sent to, `addr` during its current contact. [`Exchange::next_frame`] /// uses it so an addressed message is offered to each peer at most once per /// contact and never bounced straight back to its source. Records for an /// address are dropped when its contact starts or ends. Bounded at /// `SEND_QUEUE_CAP`: on overflow the oldest record is evicted, so /// suppression only weakens (ID dedup absorbs a repeat) and never fails /// closed on a `no_std` target. The `direction` (a `Sent` or `Received`) /// lets a bounce only suppress an id the local node actually handed out to, /// or received from, `addr` — never one merely observed while forwarding. bounce: Vec<(ObservationId, A, BounceKind), { consts::SEND_QUEUE_CAP }>, /// `(id, addr)` for each broadcast observation dispatched to `addr` on the /// broadcast emit path. Used to validate inbound `Ack` receipts against /// broadcasts we actually sent; overflow drops the oldest record. bcast_sent: Vec<(ObservationId, A), { consts::BCAST_SENT_CAP }>, /// One [`NodeEvent::Delivered`] per broadcast id a peer has confirmed, /// pushed drop-oldest when full. Drain through [`Exchange::drain_events`]. events: Vec, { consts::EVENT_QUEUE_CAP }>, /// Counted drops: distinct `(addr, DropReason)` pairs, each with a /// saturating count, plus a single overflow counter; reported through /// [`Exchange::drain_events`] and reset on drain. drops: DropTable, /// Pending addressed acks: ids awaiting an `Ack` for a peer that sent an /// addressed observation. acks: AckBuffer, /// Pending broadcast acks: ids awaiting an `Ack` for a peer that sent a /// broadcast observation. bcast_acks: AckBuffer, /// How to undo the last frame [`Exchange::next_frame`] produced. undo: Option>, /// The ids drained from a pending buffer by an in-flight `Ack` that the /// transport rejected with `Busy`; [`Exchange::on_send_error`] restores /// them. Cleared when a frame is successfully sent or a new frame is /// emitted, so a `Busy` retry re-queues the ids rather than re-sending. ack_undo: Option>, } impl Exchange { /// Creates an empty exchange for local node `me`. The salt starts at 0. pub fn new(me: NodeId) -> Self { Exchange { me, salt: 0, peers: Vec::new(), rr: 0, bounce: Vec::new(), bcast_sent: Vec::new(), events: Vec::new(), drops: DropTable::new(), undo: None, ack_undo: None, acks: AckBuffer::new(), bcast_acks: AckBuffer::new(), } } /// Handles one transport event. /// /// Returns `Some(obs)` when an inbound observation is newly stored (the /// caller may react); otherwise `None`. pub fn on_event( &mut self, ev: Event, store: &mut S, now: Tick, ) -> Option { match ev { Event::Contact(addr) => { self.add_peer(addr); None } Event::Lost(addr) => { self.remove_peer(&addr); None } Event::Frame(addr, bytes) => self.on_frame(addr, &bytes, store, now), } } /// Produces at most one outbound frame, writing its bytes into `out` and /// returning the destination address and byte count. The frame is never /// longer than `mtu`. /// /// Priority: (1) send a Summary to any peer due for one, then (2) emit one /// Obs/Frag frame from a peer via round-robin. Within a peer, a fragment /// stream in flight is finished first; otherwise custody (addressed) and /// broadcast observations take turns. /// /// The exchange's state assumes the frame is sent. If the transport /// refuses it, call [`Exchange::on_send_error`] before the next call. pub fn next_frame( &mut self, store: &S, mtu: usize, now: Tick, out: &mut [u8], ) -> Option<(A, usize)> { self.undo = None; self.ack_undo = None; let me = self.me; // Priority 1: a summary is due for some peer. for peer in self.peers.iter_mut() { if !summary_due(peer, now) { continue; } // A link too narrow for even a 1-byte filter cannot carry summaries. let Some(bytes) = summary_bytes(mtu) else { break; }; let mut bloom = Bloom::new(self.salt); store.for_each_broadcast_id(&mut |id| bloom.insert(id)); let bloom = bloom.fold(bytes)?; let bits = Vec::::from_slice(bloom.bits()).ok()?; let frame = Frame::Summary { from: me, salt: self.salt, bits, }; let n = frame.encode(out).ok()?; if n > mtu { break; } self.undo = Some(Undo { addr: peer.addr.clone(), salt: self.salt, last_summary_sent: Some(peer.last_summary_sent), in_flight: peer.in_flight, prefer_broadcast: peer.prefer_broadcast, custody_cursor: peer.custody_cursor, requeue: None, bounce: None, }); peer.last_summary_sent = Some(now); self.salt = self.salt.wrapping_add(1); return Some((peer.addr.clone(), n)); } // Priority 1.5: emit a pending Ack for a peer that has one outstanding, // before any further addressed/broadcast traffic. if let Some(res) = self.drain_pending_ack(mtu, out) { return Some(res); } // Priority 2: round-robin, emit one frame from a single peer. let len = self.peers.len(); if len == 0 { return None; } for _ in 0..len { let idx = self.rr % len; self.rr = self.rr.wrapping_add(1); if let Some(res) = self.emit_for_peer(idx, store, mtu, now, out) { return Some(res); } } None } /// Reverts the bookkeeping for the frame the last /// [`Exchange::next_frame`] produced, after the transport failed to send /// it. /// /// For `Busy` and `Unreachable` the next call produces the same frame /// again: a summary stays due, an observation or fragment stays queued, and /// an addressed message is not counted as delivered. `TooLarge` is final — /// a frame the transport can never carry is dropped rather than retried on /// every poll. pub fn on_send_error(&mut self, err: &SendError) { let pending = self.undo.take(); if !matches!(err, SendError::TooLarge) { // A pending-ack drain is reverted on Busy (but dropped on TooLarge) // independently of the undo struct, which is None for an Ack-only // frame. if let Some(ack) = self.ack_undo.take() { self.restore_ack_drain(ack); } } let Some(undo) = pending else { return; }; if matches!(err, SendError::TooLarge) { return; } self.salt = undo.salt; if let Some(id) = undo.bounce { if let Some(pos) = self .bounce .iter() .position(|(b, a, _)| *b == id && *a == undo.addr) { self.bounce.remove(pos); } } let Some(peer) = self.peers.iter_mut().find(|p| p.addr == undo.addr) else { return; }; if let Some(prev) = undo.last_summary_sent { peer.last_summary_sent = prev; } peer.in_flight = undo.in_flight; peer.prefer_broadcast = undo.prefer_broadcast; peer.custody_cursor = undo.custody_cursor; if let Some((pos, id)) = undo.requeue { if !peer.send_queue.contains(&id) { let pos = pos.min(peer.send_queue.len()); let _ = peer.send_queue.insert(pos, id); } } } /// Drain up to one pending `Ack` from the addressed then broadcast /// buffers. Returns `(dest_addr, bytes)` when an `Ack` was emitted into /// `out`, else `None`. fn drain_pending_ack(&mut self, mtu: usize, out: &mut [u8]) -> Option<(A, usize)> { // Addressed acks take priority over broadcast acks. if let Some(res) = self.drain_ack_buffer(false, mtu, out) { return Some(res); } self.drain_ack_buffer(true, mtu, out) } /// Drain one `Ack` for a single destination from the given buffer, /// splitting across frames to fit `mtu`. On success the ids that did not /// fit are left in the buffer; on a `Busy` rejection they are restored by /// [`Exchange::on_send_error`] (see [`Exchange::ack_undo`]). fn drain_ack_buffer( &mut self, is_bcast: bool, mtu: usize, out: &mut [u8], ) -> Option<(A, usize)> { // Pull every pending id for the destination out of the buffer. let addr; let mut send_n = 0usize; let mut pulled: heapless::Vec = heapless::Vec::new(); { let buffer = if is_bcast { &mut self.bcast_acks } else { &mut self.acks }; addr = buffer.front()?.1.clone(); let mut i = 0; while i < buffer.len() { if buffer.entries[i].1 == addr { let (id, _a) = buffer.entries.remove(i); if pulled.push(id).is_err() { // Overflow while pulling out: restore this entry and stop. let _ = buffer.entries.insert(i, (id, addr.clone())); break; } } else { i += 1; } } // How many ids fit in `mtu`? Each id dropped shrinks the frame. for k in (1..=pulled.len()).rev() { if Self::encode_ack(self.me, &pulled[..k], out, mtu).is_some() { send_n = k; break; } } if send_n == 0 { // Even a single id overflows `mtu`: drop this addr's oldest // pending ack and re-keep the rest for the next poll, so the // remainder retries rather than stalling. The offending addr's // entries were already pulled into `pulled`, so only they are // touched here — never a *different* peer's oldest entry. for id in &pulled[1..] { let _ = buffer.entries.push((*id, addr.clone())); } return None; } } // Emit the fitting ids (they fit by the loop above). let n = Self::encode_ack(self.me, &pulled[..send_n], out, mtu).expect("fits per loop"); // Keep the ids that did not fit for a later poll. for id in &pulled[send_n..] { let buffer = if is_bcast { &mut self.bcast_acks } else { &mut self.acks }; let _ = buffer.entries.push((*id, addr.clone())); } // Record the ids we removed and did not re-keep so a `Busy` retry can // re-queue them (the retained ids are already back in the buffer). let mut sent: heapless::Vec = heapless::Vec::new(); for id in &pulled[..send_n] { let _ = sent.push(*id); } let return_addr = addr.clone(); self.ack_undo = Some(UndoAck { addr, ids: sent, is_bcast, }); Some((return_addr, n)) } /// Re-add the ids drained from a pending buffer by a failed (Busy) frame so /// they are retried on the next poll. fn restore_ack_drain(&mut self, ack: UndoAck) { let buffer = if ack.is_bcast { &mut self.bcast_acks } else { &mut self.acks }; for id in ack.ids.iter() { let _ = buffer.entries.push((*id, ack.addr.clone())); } } /// Encode an `Ack { from: me, ids }` into `out`, returning the byte count if /// it fits within `mtu`. Returns `None` if it overflows `mtu` (or the ids /// exceed [`consts::ACK_IDS_CAP`]). fn encode_ack(me: NodeId, ids: &[ObservationId], out: &mut [u8], mtu: usize) -> Option { let ids_vec = heapless::Vec::::from_slice(ids).ok()?; let frame = Frame::Ack { from: me, ids: ids_vec, }; let n = frame.encode(out).ok()?; if n <= mtu { Some(n) } else { None } } /// Emits at most one Obs/Frag frame for the peer at `idx`. fn emit_for_peer( &mut self, idx: usize, store: &S, mtu: usize, now: Tick, out: &mut [u8], ) -> Option<(A, usize)> { // Finish the stream in flight first: starting another observation // would abandon it part-way. An observation that has since been // trimmed or expired, or that no longer encodes as the stream began, // is abandoned (and restarted from fragment 0 if still wanted). if let Some(f) = self.peers[idx].in_flight { if let Some(obs) = store.get(&f.id, now).filter(|o| !reject(o)) { if let Some(res) = self.emit(idx, f.id, &obs, Some(f), mtu, out) { return Some(res); } } self.peers[idx].in_flight = None; } // Custody and broadcast take turns, so neither starves the other. let broadcast_first = self.peers[idx].prefer_broadcast; for broadcast in [broadcast_first, !broadcast_first] { let res = if broadcast { self.emit_broadcast(idx, store, mtu, now, out) } else { self.forward_addressed(idx, store, mtu, now, out) }; if res.is_some() { self.peers[idx].prefer_broadcast = !broadcast; return res; } } None } /// Starts sending the front of the peer's broadcast send queue, dropping /// ids that are no longer stored or cannot be sent at this MTU. fn emit_broadcast( &mut self, idx: usize, store: &S, mtu: usize, now: Tick, out: &mut [u8], ) -> Option<(A, usize)> { let addr = self.peers[idx].addr.clone(); loop { let front = *self.peers[idx].send_queue.first()?; if let Some(obs) = store.get(&front, now) { if let Some(res) = self.emit(idx, front, &obs, None, mtu, out) { self.record_bcast_sent(front, addr.clone()); return Some(res); } } self.peers[idx].send_queue.remove(0); } } /// Records that a broadcast for `id` was dispatched to `addr`, evicting the /// oldest record when `bcast_sent` is full. fn record_bcast_sent(&mut self, id: ObservationId, addr: A) { if bcast_sent_contains(&self.bcast_sent, id, &addr) { return; } if self.bcast_sent.is_full() { self.bcast_sent.remove(0); } let _ = self.bcast_sent.push((id, addr)); } /// Addressed store-and-forward for the peer at `idx`: start sending one /// addressed observation, preferring *delivery* (`to == peer.id`) over /// *epidemic* forwarding (`to == Some(other != me)`). /// /// Never emits if the peer's id is unknown or equals `me`. Candidates that /// were already exchanged with this peer this contact, or that cannot be /// sent at this MTU, are skipped; the rest are taken in id order, resuming /// after the last one started for this peer. Returns `None` when nothing /// qualifies. fn forward_addressed( &mut self, idx: usize, store: &S, mtu: usize, now: Tick, out: &mut [u8], ) -> Option<(A, usize)> { let me = self.me; let recipient = self.peers[idx].id?; if recipient == me { return None; } let addr = self.peers[idx].addr.clone(); let cursor = self.peers[idx].custody_cursor; for deliver in [true, false] { let found = select_addressed(store, now, &self.bounce, &addr, cursor, |id, obs| { let wanted = if deliver { obs.dest.as_ref().map(|d| d.to) == Some(recipient) } else { obs.dest.is_some() && obs.dest.as_ref().map(|d| d.to) != Some(me) }; // `out` is scratch here; the chosen frame is re-encoded below. wanted && encode_frame(me, *id, obs, None, mtu, out).is_some() }); if let Some((id, obs)) = found { let res = self.emit(idx, id, &obs, None, mtu, out); if res.is_some() { self.peers[idx].custody_cursor = Some(id); } return res; } } None } /// Encodes the next frame of `obs` for the peer at `idx` (see /// [`encode_frame`]) and records what it changes. Once the last frame of /// the observation is out, `id` leaves the peer's send queue and, if /// addressed, is recorded as exchanged with this peer. fn emit( &mut self, idx: usize, id: ObservationId, obs: &Observation, resume: Option, mtu: usize, out: &mut [u8], ) -> Option<(A, usize)> { let (n, next) = encode_frame(self.me, id, obs, resume, mtu, out)?; let peer = &mut self.peers[idx]; let addr = peer.addr.clone(); let mut undo = Undo { addr: addr.clone(), salt: self.salt, last_summary_sent: None, in_flight: peer.in_flight, prefer_broadcast: peer.prefer_broadcast, custody_cursor: peer.custody_cursor, requeue: None, bounce: None, }; peer.in_flight = next; if next.is_none() { if let Some(pos) = peer.send_queue.iter().position(|&x| x == id) { peer.send_queue.remove(pos); undo.requeue = Some((pos, id)); } if obs.dest.is_some() && record_bounce(&mut self.bounce, id, addr.clone(), BounceKind::Sent) { undo.bounce = Some(id); } } self.undo = Some(undo); Some((addr, n)) } // Only reached from alloc-gated tests, so unused under --no-default-features. #[allow(dead_code)] /// Returns the current peers. pub fn peers(&self) -> &[Peer] { &self.peers } /// `Exchange`s emit [`NodeEvent`]s — confirmed deliveries plus counted /// drops — through `Exchange::drain_events`; `tines` surfaces them to its /// transport, so the drain method is unused inside `tines-core` itself. /// /// Returns confirmed deliveries first, oldest first, then one /// [`NodeEvent::Dropped`] per non-zero counter (resetting each counter). /// Sized to hold the entire delivery queue plus the whole drop table and /// its overflow slot, so a full delivery queue never evicts a counted drop. #[allow(dead_code)] pub(crate) fn drain_events( &mut self, ) -> Vec, { consts::EVENT_QUEUE_CAP + consts::DROP_TABLE_CAP + 1 }> { let mut drained: Vec< NodeEvent, { consts::EVENT_QUEUE_CAP + consts::DROP_TABLE_CAP + 1 }, > = Vec::new(); // Confirmed deliveries, oldest first. while !self.events.is_empty() { if drained.is_full() { break; } let ev = self.events.remove(0); let _ = drained.push(ev); } // Then one counted drop per non-zero counter, resetting each. for dropped in self.drops.drain() { let _ = drained.push(dropped); } drained } fn add_peer(&mut self, addr: A) { if self.peers.iter().any(|p| p.addr == addr) { return; } self.forget_bounces(&addr); if self.peers.len() < consts::MAX_PEERS { let _ = self.peers.push(Peer::new(addr)); } } fn remove_peer(&mut self, addr: &A) { if let Some(idx) = self.peers.iter().position(|p| p.addr == *addr) { self.peers.remove(idx); } self.forget_bounces(addr); } /// Drops the bounce records for `addr`: suppression lasts one contact. fn forget_bounces(&mut self, addr: &A) { self.bounce.retain(|(_, baddr, _)| baddr != addr); } /// Returns the index of the peer at `addr`, adding it as an implicit /// contact if unknown and the table is not full. fn peer_index(&mut self, addr: &A) -> Option { if let Some(idx) = self.peers.iter().position(|p| &p.addr == addr) { return Some(idx); } if self.peers.len() < consts::MAX_PEERS { self.forget_bounces(addr); let _ = self.peers.push(Peer::new(addr.clone())); return Some(self.peers.len() - 1); } None } fn on_frame( &mut self, addr: A, bytes: &[u8], store: &mut S, now: Tick, ) -> Option { let frame = match Frame::decode(bytes) { Ok(frame) => frame, Err(WireError::VersionMismatch { version }) => { // A wrong-version frame is a counted drop, not a silent drop. self.drops .record(addr, DropReason::VersionMismatch { version }); return None; } // Any other decode failure (malformed, truncated, or the encode-only // TooLarge which decode never yields) is a counted malformed drop. Err(_) => { self.drops.record(addr, DropReason::Malformed); return None; } }; let idx = self.peer_index(&addr)?; let peer = &mut self.peers[idx]; peer.id = Some(frame.sender()); match frame { Frame::Summary { salt, bits, .. } => { if let Some(bloom) = Bloom::from_parts(salt, &bits) { peer.their_summary = Some(bloom); rebuild_send_queue(peer, &*store, now); } None } Frame::Obs { from: _, obs } => self.accept(obs, addr, store, now), Frame::Frag { from: _, id, index, count, data, } => { let obs = peer.reassembler.feed(id, index, count, &data, now)?; self.accept(obs, addr, store, now) } Frame::Ack { from, ids } => self.on_ack(addr, from, ids, store, now), } } /// Handles an inbound `Ack` receipt from the peer at `addr`, whose node id /// is `from`. /// /// For each id in the receipt: an id the local node `Sent` to `addr` (per /// the bounce table) is released from custody so it is never forwarded /// again; an id the node dispatched as a broadcast to `addr` (per /// `bcast_sent`) fires a [`NodeEvent::Delivered`]. A receipt for an id the /// node never dispatched to `addr` is silently ignored. No path panics on /// malformed input — an oversized id list just drops the overflow. fn on_ack( &mut self, addr: A, from: NodeId, ids: Vec, store: &mut S, now: Tick, ) -> Option { for id in ids.iter() { if was_sent(&self.bounce, *id, &addr) { // Addressed: release custody for the local copy on the first // exact Ack from ANY peer the node dispatched it to — a relay // that merely holds an epidemic-forwarded copy, or the intended // recipient alike. The custody chain tracks that a copy left us, // so we stop forwarding this id; final delivery to the recipient // is best-effort, not guaranteed. if store.get(id, now).is_some_and(|obs| obs.dest.is_some()) { store.release(id); } } else if bcast_sent_contains(&self.bcast_sent, *id, &addr) { // Broadcast: the peer confirms it holds our broadcast. let _ = self.events.push(NodeEvent::Delivered { peer: from, id: *id, }); } } None } /// Stores an observation received from `addr`, returning it if it was new. /// /// An addressed message is recorded against `addr` whether it was new or /// a duplicate: the sender evidently holds it, so it is never offered back /// this contact. fn accept( &mut self, obs: Observation, addr: A, store: &mut S, now: Tick, ) -> Option { if reject(&obs) { return None; } let id = obs.id(); let addressed = obs.dest.is_some(); let returned = obs.clone(); let res = store.insert(obs, now); // Enqueue a pending ack only for what the store actually kept: a newly // stored observation, or a duplicate whose local copy is still held. A // store that rejected the observation (full, backend error) holds // nothing, so it must not ack — for an addressed offer that would // release the sender's custody for a message nobody holds, and for a // broadcast it would fire a false confidence event at the sender. The // single exception among the kept copies is a duplicate *addressed* // offer whose local copy has been released — a released holder must // not ack. if res.is_ok() { let duplicate = !matches!(res, Ok(true)); let released = store.is_released(&id); if !(duplicate && addressed && released) { self.enqueue_pending_ack(id, addr.clone(), addressed); } } if addressed && res.is_ok() { record_bounce(&mut self.bounce, id, addr, BounceKind::Received); } match res { Ok(true) => Some(returned), _ => None, } } /// Enqueue `(id, addr)` as a pending ack into the addressed (`acks`) or /// broadcast (`bcast_acks`) buffer, dropping the oldest entry if full. fn enqueue_pending_ack(&mut self, id: ObservationId, addr: A, addressed: bool) { let buffer = if addressed { &mut self.acks } else { &mut self.bcast_acks }; buffer.enqueue(id, addr); } } /// Returns `true` when a peer is due for a summary: none sent yet, or the /// summary interval has elapsed since the last one. fn summary_due(peer: &Peer, now: Tick) -> bool { match peer.last_summary_sent { None => true, Some(sent) => now.saturating_sub(sent) >= consts::SUMMARY_INTERVAL_MS, } } /// The largest bloom size, in bytes, whose Summary frame fits in `mtu`: a /// power of two (see [`Bloom::fold`]) no larger than `SUMMARY_BYTES`. Narrower /// links get a smaller filter, which only raises the false-positive rate — /// a delayed propagation, never an inconsistency, and salt rotation varies /// which ids are affected on each round. fn summary_bytes(mtu: usize) -> Option { let room = mtu .saturating_sub(SUMMARY_OVERHEAD) .min(consts::SUMMARY_BYTES); if room == 0 { return None; } Some(1 << (usize::BITS - 1 - room.leading_zeros())) } /// Returns `true` when a received [`Observation`] must be dropped before it is /// stored: it is an addressed message (`dest = Some`) whose age has passed its /// lifetime. /// /// An addressed message always carries a lifetime, so the only way to reject /// is age exceeding that lifetime. Broadcasts (`dest = None`) are never /// rejected. The `Dest` type now enforces the addressed/broadcast pairing, so /// there is no separate "malformed addressing" case to drop. fn reject(obs: &Observation) -> bool { obs.dest .as_ref() .is_some_and(|d| obs.age_ms > d.lifetime_ms) } /// Finds the non-expired, well-formed addressed observation matching /// `predicate` with the smallest id after `after`, wrapping round to the /// smallest id overall, and skipping any id already exchanged with `addr` this /// contact. /// /// `reject` excludes expired/malformed addressed messages (the expiry /// invariant) and `is_sourced` enforces bounce suppression. `predicate` is /// only consulted for an id that would beat the current pick. Returns the /// stored key and age-adjusted observation. fn select_addressed( store: &S, now: Tick, bounce: &[(ObservationId, A, BounceKind)], addr: &A, after: Option, mut predicate: impl FnMut(&ObservationId, &Observation) -> bool, ) -> Option<(ObservationId, Observation)> { let mut ahead: Option<(ObservationId, Observation)> = None; let mut wrapped: Option<(ObservationId, Observation)> = None; store.for_each_addressed_id(&mut |id| { let is_ahead = after.is_none_or(|a| *id > a); let beats = |pick: &Option<(ObservationId, Observation)>| { pick.as_ref().is_none_or(|(best, _)| id < best) }; let improves = if is_ahead { beats(&ahead) } else { ahead.is_none() && beats(&wrapped) }; if !improves || is_sourced(bounce, *id, addr) { return; } let Some(obs) = store.get(id, now) else { return; }; if obs.dest.is_none() || reject(&obs) || store.is_released(id) || !predicate(id, &obs) { return; } if is_ahead { ahead = Some((*id, obs)); } else { wrapped = Some((*id, obs)); } }); ahead.or(wrapped) } /// The direction that produced a bounce record: the local node `Sent` the id /// to `addr`, or `Received` it from `addr`. Used to make bounce suppression /// direction-aware — a `Sent` record can only release custody, a `Received` /// record can only suppress re-offering. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum BounceKind { Sent, Received, } /// Returns `true` when `(id, addr)` has been recorded in the bounce table — /// i.e. `id` was received from, or sent to, `addr` this contact under either /// direction. Offering it to that peer again would only bounce, so it is /// skipped. fn is_sourced( bounce: &[(ObservationId, A, BounceKind)], id: ObservationId, addr: &A, ) -> bool { bounce .iter() .any(|(bid, baddr, _kind)| *bid == id && baddr == addr) } /// Whether `id` was `Sent` to `addr`. An inbound `Ack` from `addr` may only /// release custody of an id the local node actually dispatched to it — never /// an id it only received, or merely observed while forwarding. fn was_sent( bounce: &[(ObservationId, A, BounceKind)], id: ObservationId, addr: &A, ) -> bool { bounce .iter() .any(|(bid, baddr, kind)| *bid == id && baddr == addr && *kind == BounceKind::Sent) } /// Records an `(id, addr, direction)` bounce in the table, evicting the oldest /// record when full. Returns `true` if a new record was added. fn record_bounce( bounce: &mut Vec<(ObservationId, A, BounceKind), { consts::SEND_QUEUE_CAP }>, id: ObservationId, addr: A, kind: BounceKind, ) -> bool { if is_sourced(bounce, id, &addr) { return false; } if bounce.is_full() { bounce.remove(0); } let _ = bounce.push((id, addr, kind)); true } /// Whether `(id, addr)` has been recorded in `bcast_sent` — i.e. the local node /// actually dispatched that broadcast to that peer. Used to validate inbound /// `Ack` receipts against broadcasts we truly sent. fn bcast_sent_contains( bcast_sent: &[(ObservationId, A)], id: ObservationId, addr: &A, ) -> bool { bcast_sent .iter() .any(|(bid, baddr)| *bid == id && baddr == addr) } /// Encodes one frame of `obs` (whose id is `id`) into `out`. /// /// Starting a stream (`resume = None`), the whole observation goes out as one /// Obs frame if that fits in `mtu`; otherwise fragment 0 of its postcard /// encoding. Resuming, fragment `resume.next` is cut from the encoding at the /// frozen `resume.age_ms`, so every fragment of a stream comes from identical /// bytes even as the observation ages or the clock steps backwards. /// /// Returns the frame length and the stream state to keep — `None` once the /// observation's last frame is out. Returns `None` if the observation cannot /// be sent at this MTU, or no longer encodes to the length and fragment count /// the stream started with. fn encode_frame( me: NodeId, id: ObservationId, obs: &Observation, resume: Option, mtu: usize, out: &mut [u8], ) -> Option<(usize, Option)> { if resume.is_none() { let frame = Frame::Obs { from: me, obs: obs.clone(), }; if let Ok(n) = frame.encode(out) { if n <= mtu { return Some((n, None)); } } } let age_ms = resume.map_or(obs.age_ms, |f| f.age_ms); let frozen = Observation { age_ms, ..obs.clone() }; let mut obs_buf = [0u8; consts::MAX_FRAME * 2]; let obs_bytes = postcard::to_slice(&frozen, &mut obs_buf).ok()?; let (chunk, count) = crate::frag::plan(obs_bytes.len(), mtu)?; let index = match resume { Some(f) if f.len != obs_bytes.len() || usize::from(f.count) != count => return None, Some(f) => f.next, None => 0, }; let start = usize::from(index) * chunk; let end = core::cmp::min(start + chunk, obs_bytes.len()); let data = obs_bytes.get(start..end)?; let frame = Frame::Frag { from: me, id, index, count: count as u8, data: Vec::from_slice(data).ok()?, }; let n = frame.encode(out).ok()?; if n > mtu { return None; } let next = (usize::from(index) + 1 < count).then_some(InFlight { id, next: index + 1, count: count as u8, age_ms, len: obs_bytes.len(), }); Some((n, next)) } /// Rebuilds a peer's send queue to the ids in `store` they do not already hold /// (per their summary), kept youngest-first and capped at `SEND_QUEUE_CAP`. fn rebuild_send_queue(peer: &mut Peer, store: &S, now: Tick) { let Some(their) = peer.their_summary.as_ref() else { return; }; // Top-K youngest-first: a sorted (age ascending) buffer capped at the queue // capacity. When full, only a candidate younger than the current oldest // (last element) is admitted, dropping that oldest. let mut heap: Vec<(ObservationId, u64), { consts::SEND_QUEUE_CAP }> = Vec::new(); // Broadcasts only (`to == None`): addressed messages are delivered through // `forward_addressed` and must never leak into the broadcast diff, mirroring // the bloom-summary path in `next_frame` which also iterates broadcast ids. store.for_each_broadcast_id(&mut |id| { if their.might_contain(id) { return; } let obs = match store.get(id, now) { Some(obs) => obs, None => return, }; insert_youngest(&mut heap, *id, obs.age_ms); }); peer.send_queue.clear(); for (id, _) in heap { peer.send_queue .push(id) .expect("heap is capped at SEND_QUEUE_CAP"); } } /// Inserts `(id, age)` into the age-ascending top-K buffer, keeping it sorted /// and capped at `SEND_QUEUE_CAP`. fn insert_youngest( heap: &mut Vec<(ObservationId, u64), { consts::SEND_QUEUE_CAP }>, id: ObservationId, age: u64, ) { if heap.len() < consts::SEND_QUEUE_CAP { let pos = insert_pos(heap, age); let _ = heap.insert(pos, (id, age)); return; } // Full: admit only if younger than the current oldest (last element). if let Some((_, oldest)) = heap.last() { if age < *oldest { heap.pop(); let pos = insert_pos(heap, age); let _ = heap.insert(pos, (id, age)); } } } /// Returns the index at which `age` belongs to keep `heap` age-ascending. fn insert_pos(heap: &Vec<(ObservationId, u64), { consts::SEND_QUEUE_CAP }>, age: u64) -> usize { let mut pos = heap.len(); while pos > 0 && heap[pos - 1].1 > age { pos -= 1; } pos } #[cfg(all(test, feature = "alloc"))] mod tests { use super::*; use crate::{ Dest, MemStore, Observation, Payload, SendError, Store, StoreError, Topic, TrimPolicy, }; use std::vec::Vec; const ME: NodeId = [42; 16]; fn obs( observer: [u8; 16], seq: u64, age: u64, at: Option, topic: &[u8], payload: &[u8], ) -> Observation { Observation { observer, seq, age_ms: age, observed_at: at, topic: Topic::from_slice(topic).unwrap(), payload: Payload::from_slice(payload).unwrap(), dest: None, sig: None, } } /// Encodes a frame into a stack [`FrameBuf`] for feeding [`Event::Frame`]. fn frame_bytes(f: &Frame) -> heapless::Vec { let mut buf = [0u8; consts::MAX_FRAME]; let n = f.encode(&mut buf).expect("frame fits in MAX_FRAME"); heapless::Vec::from_slice(&buf[..n]).expect("encoded frame fits") } #[test] fn contact_triggers_summary_frame() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); ex.on_event(Event::Contact(1), &mut store, 0); let mut buf = [0u8; consts::MAX_FRAME]; let (addr, n) = ex .next_frame(&store, consts::MAX_FRAME, 0, &mut buf) .expect("summary on contact"); assert_eq!(addr, 1); match Frame::decode(&buf[..n]).unwrap() { Frame::Summary { from, .. } => assert_eq!(from, ME), other => panic!("expected a Summary frame, got {other:?}"), } } #[test] fn summary_from_peer_queues_only_what_they_lack() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let a = obs([2; 16], 1, 100, None, b"t", b"a"); let b = obs([2; 16], 2, 50, None, b"t", b"b"); let c = obs([2; 16], 3, 10, None, b"t", b"c"); let id_a = a.id(); let id_b = b.id(); let id_c = c.id(); store.insert(a, 0).unwrap(); store.insert(b, 0).unwrap(); store.insert(c, 0).unwrap(); // Peer contacts us, then sends a summary containing only id B. ex.on_event(Event::Contact(1), &mut store, 0); let mut their = Bloom::new(0); their.insert(&id_b); let bits = heapless::Vec::from_slice(their.bits()).unwrap(); let sum = Frame::Summary { from: [2; 16], salt: 0, bits, }; ex.on_event(Event::Frame(1, frame_bytes(&sum)), &mut store, 0); // First poll still emits our own Summary (peer never saw ours). let mut emitted = Vec::new(); for _ in 0..10 { let mut buf = [0u8; consts::MAX_FRAME]; match ex.next_frame(&store, consts::MAX_FRAME, 0, &mut buf) { Some((_, n)) => emitted.push(Frame::decode(&buf[..n]).unwrap()), None => break, } } assert_eq!(emitted.len(), 3, "our summary + Obs for A and C only"); match &emitted[0] { Frame::Summary { from, .. } => assert_eq!(*from, ME), other => panic!("expected our Summary first, got {other:?}"), } // Youngest-first among {A, C}: C (age 10) before A (age 100). assert_obs_id(&emitted[1], id_c); assert_obs_id(&emitted[2], id_a); assert!( !emitted .iter() .any(|f| matches!(f, Frame::Obs { obs, .. } if obs.id() == id_b)), "B must be omitted" ); assert!(ex .next_frame(&store, consts::MAX_FRAME, 0, &mut [0u8; consts::MAX_FRAME]) .is_none()); } #[test] fn rebuild_send_queue_excludes_addressed() { // A store with one broadcast (`dest = None`) and one addressed // (`dest = Some([3; 16])`) observation. After a peer contacts us and // sends an empty Summary (lacking everything), the broadcast diff must // queue only the broadcast id and never leak in the addressed id. let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let b = obs([2; 16], 1, 0, None, b"t", b"b"); let a_id = { let mut a = obs([2; 16], 2, 0, None, b"t", b"a"); a.dest = Some(Dest { to: [3; 16], lifetime_ms: consts::SUMMARY_INTERVAL_MS * 10, }); a.id() }; let b_id = b.id(); store.insert(b, 0).unwrap(); store .insert( { let mut a = obs([2; 16], 2, 0, None, b"t", b"a"); a.dest = Some(Dest { to: [3; 16], lifetime_ms: consts::SUMMARY_INTERVAL_MS * 10, }); a }, 0, ) .unwrap(); // Peer contacts us, then sends an empty Summary (holds nothing). ex.on_event(Event::Contact(1), &mut store, 0); let bits = heapless::Vec::from_slice(&[0u8; consts::SUMMARY_BYTES]).unwrap(); let sum = Frame::Summary { from: [2; 16], salt: 0, bits, }; ex.on_event(Event::Frame(1, frame_bytes(&sum)), &mut store, 0); let peer = &ex.peers()[0]; let q: std::vec::Vec<_> = peer.send_queue.iter().copied().collect(); assert!(q.contains(&b_id), "broadcast id must be queued: {q:?}"); assert!(!q.contains(&a_id), "addressed id must NOT be queued: {q:?}"); assert_eq!(q.len(), 1, "only the broadcast id should be queued"); } fn assert_obs_id(frame: &Frame, id: ObservationId) { match frame { Frame::Obs { obs, .. } => assert_eq!(obs.id(), id), other => panic!("expected an Obs frame, got {other:?}"), } } #[test] fn received_obs_is_stored_and_returned() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); ex.on_event(Event::Contact(1), &mut store, 0); let obs = obs([2; 16], 7, 0, None, b"t", b"hi"); let frame = Frame::Obs { from: [2; 16], obs: obs.clone(), }; let returned = ex .on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0) .expect("obs returned"); assert_eq!(returned, obs); assert!(store.contains(&obs.id())); } /// Encodes an addressed observation from `observer` to `to` (for ack-path /// tests). fn observed_addressed( observer: NodeId, seq: u64, to: NodeId, topic: &[u8], payload: &[u8], ) -> Observation { let mut o = obs(observer, seq, 0, None, topic, payload); o.dest = Some(Dest { to, lifetime_ms: consts::SUMMARY_INTERVAL_MS * 10, }); o } /// Drains frames until an `Ack` carrying `id` is found (summary frames are /// skipped), reporting whether one was seen. fn ack_for(ex: &mut Exchange, store: &MemStore, id: ObservationId) -> bool { let mut buf = [0u8; consts::MAX_FRAME]; for _ in 0..consts::MAX_FRAME { if let Some((_, n)) = ex.next_frame(store, consts::MAX_FRAME, 0, &mut buf) { match Frame::decode(&buf[..n]) { Ok(Frame::Ack { ids, .. }) if ids.contains(&id) => return true, _ => {} } } } false } #[test] fn released_duplicate_is_not_acked() { let a: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); empty_summary(&mut ex, &mut store, 1, a); // A held an addressed message to B; B's store already holds it and has // released it (e.g. it was delivered and confirmed). let x = observed_addressed(a, 1, ME, b"t", b"hi"); let x_id = x.id(); store.insert(x.clone(), 0).unwrap(); store.release(&x_id); assert!(store.is_released(&x_id)); // A re-offers the same addressed message: a duplicate whose local copy // is released, so B must not enqueue a pending ack for it. let frame = Frame::Obs { from: a, obs: x }; ex.on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0); // The duplicate never enters the buffer and no Ack carries X. assert!( ex.acks.entries.is_empty(), "a released duplicate is not enqueued" ); for _ in 0..consts::MAX_FRAME { let mut buf = [0u8; consts::MAX_FRAME]; if let Some((_, n)) = ex.next_frame(&store, consts::MAX_FRAME, 0, &mut buf) { if let Ok(Frame::Ack { ids, .. }) = Frame::decode(&buf[..n]) { assert!( !ids.contains(&x_id), "a released duplicate must not be acked" ); } } } assert!( ex.acks.entries.is_empty(), "the released duplicate never left the buffer" ); } /// A `Store` that delegates to a [`MemStore`] but rejects `insert` with /// `StoreError::Full` once it has held `cap` observations — a stand-in for /// a real backend that refuses when full. `MemStore` itself never fills. struct FullStore { cap: usize, inner: MemStore, } impl FullStore { fn new(cap: usize) -> Self { Self { cap, inner: MemStore::new(), } } } impl Store for FullStore { fn insert(&mut self, obs: Observation, now: Tick) -> Result { if self.inner.len() >= self.cap { return Err(StoreError::Full); } self.inner.insert(obs, now) } fn contains(&self, id: &ObservationId) -> bool { self.inner.contains(id) } fn get(&self, id: &ObservationId, now: Tick) -> Option { self.inner.get(id, now) } fn len(&self) -> usize { self.inner.len() } fn for_each_id(&self, f: &mut dyn FnMut(&ObservationId)) { self.inner.for_each_id(f) } fn for_each_broadcast_id(&self, f: &mut dyn FnMut(&ObservationId)) { self.inner.for_each_broadcast_id(f) } fn for_each_addressed_id(&self, f: &mut dyn FnMut(&ObservationId)) { self.inner.for_each_addressed_id(f) } fn release(&mut self, id: &ObservationId) { self.inner.release(id) } fn is_released(&self, id: &ObservationId) -> bool { self.inner.is_released(id) } fn for_each_topic(&self, prefix: &[u8], now: Tick, f: &mut dyn FnMut(&Observation)) { self.inner.for_each_topic(prefix, now, f) } fn trim(&mut self, policy: &TrimPolicy, now: Tick) -> usize { self.inner.trim(policy, now) } } /// A store that rejected a receive (full, or backend error) must not ack, /// whether the offer was addressed or a broadcast. Acking would release /// the sender's custody for a message nobody holds (addressed) or fire a /// false confidence event (broadcast) — custody lost for a message no one /// keeps. Regression test for the full-store custody bug. #[test] fn store_full_receive_is_not_acked() { let a: NodeId = [2; 16]; let mut ex = Exchange::new(ME); // A store that never keeps anything it is asked to insert. let mut store = FullStore::new(0); // Make peer `a` known and appearing to lack everything. let bits = heapless::Vec::from_slice(&[0u8; consts::SUMMARY_BYTES]).unwrap(); let sum = Frame::Summary { from: a, salt: 0, bits, }; ex.on_event(Event::Frame(1, frame_bytes(&sum)), &mut store, 0); // A broadcast from A, received into a store that rejects it. let b = obs(a, 1, 0, None, b"t", b"bc"); let b_id = b.id(); ex.on_event( Event::Frame(1, frame_bytes(&Frame::Obs { from: a, obs: b })), &mut store, 0, ); // An addressed offer from A to ME, received into the same store. let x = observed_addressed(a, 2, ME, b"t", b"hi"); let x_id = x.id(); ex.on_event( Event::Frame(1, frame_bytes(&Frame::Obs { from: a, obs: x })), &mut store, 0, ); // Nothing was kept, so nothing is acked: no pending ack queued and no // Ack frame carries either id. assert!( ex.acks.entries.is_empty(), "a store-full addressed receive must not be acked" ); assert!( ex.bcast_acks.entries.is_empty(), "a store-full broadcast receive must not be acked" ); for _ in 0..consts::MAX_FRAME { let mut buf = [0u8; consts::MAX_FRAME]; if let Some((_, n)) = ex.next_frame(&store, consts::MAX_FRAME, 0, &mut buf) { if let Ok(Frame::Ack { ids, .. }) = Frame::decode(&buf[..n]) { assert!( !ids.contains(&b_id) && !ids.contains(&x_id), "a store-full receive must not ack id {b_id:?} or {x_id:?}" ); } } } assert!( ex.acks.entries.is_empty(), "the store-full acks never left the buffer" ); } #[test] fn ack_fits_mtu() { let a: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); empty_summary(&mut ex, &mut store, 1, a); // Receive 12 broadcasts from A -> 12 pending broadcast acks. for seq in 0..12u64 { let y = obs(a, seq, 0, None, b"t", b"bc"); let frame = Frame::Obs { from: a, obs: y }; ex.on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0); } assert_eq!(ex.bcast_acks.len(), 12, "each received broadcast is acked"); // At an mtu too small for all 12, every Ack carries only a few ids, // split across frames. let mtu = 110usize; let mut max_ids = 0usize; let mut acks = 0usize; for _ in 0..consts::MAX_FRAME { let mut buf = [0u8; consts::MAX_FRAME]; if let Some((_, n)) = ex.next_frame(&store, mtu, 0, &mut buf) { match Frame::decode(&buf[..n]) { Ok(Frame::Ack { ids, .. }) => { acks += 1; max_ids = max_ids.max(ids.len()); } Ok(Frame::Summary { .. }) => {} _ => {} } } } assert!( acks >= 2, "12 ids must split across multiple Ack frames at mtu {mtu}, got {acks}" ); assert!( max_ids <= 5, "each Ack stays within mtu (max {max_ids} ids)" ); } #[test] fn broadcast_flood_does_not_evict_custody_ack() { let a: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); empty_summary(&mut ex, &mut store, 1, a); // An addressed custody message A -> B, which B received (pending // addressed ack). let x = observed_addressed(a, 1, ME, b"t", b"hi"); let x_id = x.id(); let frame = Frame::Obs { from: a, obs: x }; ex.on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0); assert!( !ex.acks.entries.is_empty(), "an addressed message has a pending ack" ); // Flood B with far more broadcasts than bcast_acks can hold; overflow // drops the oldest. for seq in 0..400u64 { let y = obs(a, seq, 0, None, b"t", b"bc"); let frame = Frame::Obs { from: a, obs: y }; ex.on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0); } assert!( ex.bcast_acks.entries.is_full(), "bcast_acks flooded to capacity" ); // Draining must still emit an Ack carrying the addressed custody id X. let mut saw_x = false; for _ in 0..consts::MAX_FRAME { let mut buf = [0u8; consts::MAX_FRAME]; if let Some((_, n)) = ex.next_frame(&store, consts::MAX_FRAME, 0, &mut buf) { if let Ok(Frame::Ack { ids, .. }) = Frame::decode(&buf[..n]) { if ids.contains(&x_id) { saw_x = true; } } } } assert!( saw_x, "an addressed pending ack must survive a broadcast flood" ); } #[test] fn duplicate_broadcast_is_acked() { let a: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); empty_summary(&mut ex, &mut store, 1, a); // B receives broadcast Y from A, then the same Y again (a duplicate). let y = obs(a, 1, 0, None, b"t", b"bc"); let y_id = y.id(); // First reception yields an Ack for Y. let frame1 = Frame::Obs { from: a, obs: y.clone(), }; ex.on_event(Event::Frame(1, frame_bytes(&frame1)), &mut store, 0); assert!(ack_for(&mut ex, &store, y_id), "first reception is acked"); // Duplicate reception yields another Ack for Y (duplicates ARE acked). let frame2 = Frame::Obs { from: a, obs: y }; ex.on_event(Event::Frame(1, frame_bytes(&frame2)), &mut store, 0); assert!( ack_for(&mut ex, &store, y_id), "duplicate reception is acked" ); } /// An mtu too small for even one id drops only the offending destination's /// oldest pending ack and leaves a *different* peer's pending ack untouched. #[test] fn drain_ack_buffer_overflow_drops_only_target_peer() { let a: NodeId = [2; 16]; let b: NodeId = [9; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); empty_summary(&mut ex, &mut store, 1, a); empty_summary(&mut ex, &mut store, 2, b); // One broadcast ack pending for addr 1 (a), one for addr 2 (b). let y1 = obs(a, 1, 0, None, b"t", b"bc"); let y2 = obs(b, 1, 0, None, b"t", b"bc"); ex.on_event( Event::Frame(1, frame_bytes(&Frame::Obs { from: a, obs: y1 })), &mut store, 0, ); ex.on_event( Event::Frame(2, frame_bytes(&Frame::Obs { from: b, obs: y2 })), &mut store, 0, ); assert_eq!(ex.bcast_acks.len(), 2, "each broadcast has a pending ack"); // An mtu too small for even a single id: the first poll drops addr 1's // oldest pending ack but must leave addr 2's untouched. let mut buf = [0u8; consts::MAX_FRAME]; assert!( ex.next_frame(&store, 4usize, 0, &mut buf).is_none(), "no ack fits the tiny mtu" ); assert_eq!( ex.bcast_acks.len(), 1, "only the offending destination's ack was dropped" ); let remaining = ex.bcast_acks.entries.first().map(|(_, addr)| *addr); assert_eq!( remaining, Some(2u8), "addr 2's pending ack is preserved, not a dropped peer's" ); } #[test] fn duplicate_obs_returns_none() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); ex.on_event(Event::Contact(1), &mut store, 0); let obs = obs([2; 16], 7, 0, None, b"t", b"hi"); let frame = Frame::Obs { from: [2; 16], obs: obs.clone(), }; assert!(ex .on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0) .is_some()); let dup = Frame::Obs { from: [2; 16], obs: obs.clone(), }; assert!(ex .on_event(Event::Frame(1, frame_bytes(&dup)), &mut store, 0) .is_none()); } #[test] fn malformed_frame_is_dropped() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); ex.on_event(Event::Contact(1), &mut store, 0); let before = store.len(); let bogus = Event::Frame(1, heapless::Vec::from_slice(&[0xFFu8; 50]).unwrap()); assert_eq!(ex.on_event(bogus, &mut store, 0), None); assert_eq!( store.len(), before, "store must be unchanged by a malformed frame" ); } #[test] fn frame_from_unknown_addr_creates_peer() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); // No Contact: a Summary from addr 9 must implicitly create the peer. let bits = heapless::Vec::from_slice(&[0u8; consts::SUMMARY_BYTES]).unwrap(); let sum = Frame::Summary { from: [9; 16], salt: 0, bits, }; ex.on_event(Event::Frame(9, frame_bytes(&sum)), &mut store, 0); let peers = ex.peers(); assert_eq!(peers.len(), 1); assert_eq!(peers[0].id, Some([9; 16])); } #[test] fn lost_removes_peer() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); ex.on_event(Event::Contact(1), &mut store, 0); assert_eq!(ex.peers().len(), 1); ex.on_event(Event::Lost(1), &mut store, 0); assert_eq!(ex.peers().len(), 0); } #[test] fn peer_table_full_ignores_new_contact() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); for i in 1..=consts::MAX_PEERS as u8 { ex.on_event(Event::Contact(i), &mut store, 0); } assert_eq!(ex.peers().len(), consts::MAX_PEERS); // Overflow contact is ignored, not a panic. ex.on_event(Event::Contact(200), &mut store, 0); assert_eq!(ex.peers().len(), consts::MAX_PEERS); } #[test] fn summary_resent_after_interval() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); ex.on_event(Event::Contact(1), &mut store, 0); let mut buf = [0u8; consts::MAX_FRAME]; let (_, n) = ex .next_frame(&store, consts::MAX_FRAME, 0, &mut buf) .expect("first summary"); match Frame::decode(&buf[..n]).unwrap() { Frame::Summary { salt, .. } => assert_eq!(salt, 0), other => panic!("expected a Summary, got {other:?}"), } // Immediately again: our summary is fresh, nothing else queued -> None. assert!(ex .next_frame(&store, consts::MAX_FRAME, 0, &mut [0u8; consts::MAX_FRAME]) .is_none()); // After the interval: another Summary, now rotated to salt 1. let mut buf = [0u8; consts::MAX_FRAME]; let (_, n) = ex .next_frame( &store, consts::MAX_FRAME, consts::SUMMARY_INTERVAL_MS, &mut buf, ) .expect("second summary"); match Frame::decode(&buf[..n]).unwrap() { Frame::Summary { salt, .. } => assert_eq!(salt, 1), other => panic!("expected a Summary, got {other:?}"), } } #[test] fn salt_rotates_each_summary() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); ex.on_event(Event::Contact(1), &mut store, 0); let s0 = summary_salt(&mut ex, &store, 0); let s1 = summary_salt(&mut ex, &store, consts::SUMMARY_INTERVAL_MS); assert_eq!(s0, 0); assert_eq!(s1, s0 + 1); } fn summary_salt(ex: &mut Exchange, store: &MemStore, now: Tick) -> u32 { let mut buf = [0u8; consts::MAX_FRAME]; let (_, n) = ex .next_frame(store, consts::MAX_FRAME, now, &mut buf) .expect("summary"); match Frame::decode(&buf[..n]).unwrap() { Frame::Summary { salt, .. } => salt, other => panic!("expected a Summary, got {other:?}"), } } #[test] fn large_obs_goes_out_as_frags() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let big = obs(ME, 1, 0, None, b"t", &[0u8; 1024]); let id = big.id(); store.insert(big, 0).unwrap(); // Peer sends an empty summary (holds nothing) so it lacks everything. ex.on_event(Event::Contact(1), &mut store, 0); let bits = heapless::Vec::from_slice(&[0u8; consts::SUMMARY_BYTES]).unwrap(); let sum = Frame::Summary { from: [1; 16], salt: 0, bits, }; ex.on_event(Event::Frame(1, frame_bytes(&sum)), &mut store, 0); // mtu 200 forces fragmentation; first poll still emits our Summary. let mtu = 200usize; let mut indices = Vec::new(); for _ in 0..consts::MAX_FRAME { let mut buf = [0u8; consts::MAX_FRAME]; match ex.next_frame(&store, mtu, 0, &mut buf) { Some((_, n)) => match Frame::decode(&buf[..n]).unwrap() { Frame::Summary { .. } => {} Frame::Frag { index, count, .. } => indices.push((index, count)), other => panic!("expected only Frag frames, got {other:?}"), }, None => break, } } assert!( !indices.is_empty(), "the large observation must be fragmented" ); let count = indices[0].1; assert!( count >= 2, "a 1024-byte payload must span multiple fragments" ); for (i, &(index, c)) in indices.iter().enumerate() { assert_eq!(index, i as u8, "fragments must carry increasing indices"); assert_eq!(c, count, "all fragments share the same count"); } let last = indices.last().copied().unwrap(); assert_eq!( last.0 + 1, last.1, "fragment indices are contiguous to count" ); assert_eq!(indices.len(), count as usize); // After draining, in_flight clears and the queue drops the id. assert!(ex.peers()[0].in_flight.is_none()); assert!(!ex.peers()[0].send_queue.contains(&id)); } #[test] fn inbound_frags_are_reassembled_and_stored() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let big = obs([2; 16], 7, 0, None, b"t", &[0u8; 1024]); let id = big.id(); // Fragment the observation exactly as the peer would, then feed each // Frag frame through the exchange's inbound path (no prior Contact). let mut frames: Vec> = Vec::new(); crate::frag::fragment([2; 16], &big, 200, &mut |b| { frames.push(heapless::Vec::from_slice(b).unwrap()); }) .expect("fragment large obs"); for f in frames { let _ = ex.on_event(Event::Frame(9, f), &mut store, 0); } assert!(store.contains(&id), "reassembled obs must be stored"); assert_eq!(store.len(), 1); } #[test] fn expired_addressed_obs_is_rejected() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); ex.on_event(Event::Contact(1), &mut store, 0); // Addressed to us, lifetime 100ms, but age 200ms -> expired. let mut o = obs([2; 16], 7, 0, None, b"t", b"hi"); o.dest = Some(Dest { to: ME, lifetime_ms: 100, }); o.age_ms = 200; let frame = Frame::Obs { from: [2; 16], obs: o.clone(), }; assert!( ex.on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0) .is_none(), "an expired addressed obs must not be delivered" ); assert_eq!( store.len(), 0, "an expired addressed obs must not be stored" ); } #[test] fn expired_addressed_frag_is_rejected() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); // A large addressed obs that must be fragmented; already expired. let mut big = obs([2; 16], 7, 0, None, b"t", &[0u8; 1024]); big.dest = Some(Dest { to: ME, lifetime_ms: 100, }); big.age_ms = 200; let mut frames: Vec> = Vec::new(); crate::frag::fragment([2; 16], &big, 200, &mut |b| { frames.push(heapless::Vec::from_slice(b).unwrap()); }) .expect("fragment the addressed obs into frags"); for f in frames { let _ = ex.on_event(Event::Frame(9, f), &mut store, 0); } assert_eq!( store.len(), 0, "an expired addressed obs must not survive fragmentation" ); } #[test] fn valid_addressed_obs_is_accepted() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); ex.on_event(Event::Contact(1), &mut store, 0); // A well-formed, non-expired addressed obs addressed to us IS stored. let mut o = obs([2; 16], 7, 0, None, b"t", b"hi"); o.dest = Some(Dest { to: ME, lifetime_ms: 1000, }); let id = o.id(); let frame = Frame::Obs { from: [2; 16], obs: o.clone(), }; assert!( ex.on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0) .is_some(), "a valid addressed obs must be accepted" ); assert!(store.contains(&id)); assert_eq!(store.len(), 1); } #[test] fn expired_addressed_obs_is_not_forwarded() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); // An addressed obs already past its lifetime, inserted directly so it // bypasses the receive-side reject (this tests the FORWARD path). let mut o = obs([2; 16], 7, 0, None, b"t", b"hi"); o.dest = Some(Dest { to: [3; 16], lifetime_ms: 100, }); o.age_ms = 200; let id = o.id(); store.insert(o, 0).unwrap(); // A peer whose NodeId we learn (not the recipient, so epidemic applies). ex.on_event(Event::Contact(1), &mut store, 0); let bits = heapless::Vec::from_slice(&[0u8; consts::SUMMARY_BYTES]).unwrap(); let sum = Frame::Summary { from: [2; 16], salt: 0, bits, }; ex.on_event(Event::Frame(1, frame_bytes(&sum)), &mut store, 0); // The expired obs must never be forwarded to any peer. assert!( drain_addressed_obs(&mut ex, &store, Some([3; 16])).is_none(), "an expired addressed obs must not be forwarded" ); // It is still held (expiry is checked at select/forward time, not // retroactively removed here). assert!(store.contains(&id)); } #[test] fn addressed_obs_not_in_summary() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let b = obs([2; 16], 1, 0, None, b"t", b"b"); let b_id = b.id(); let mut a = obs([2; 16], 2, 0, None, b"t", b"a"); a.dest = Some(Dest { to: [2; 16], lifetime_ms: consts::SUMMARY_INTERVAL_MS * 10, }); let a_id = a.id(); store.insert(b, 0).unwrap(); store.insert(a, 0).unwrap(); // Peer contacts us, so a summary is due on the next poll. ex.on_event(Event::Contact(1), &mut store, 0); let mut buf = [0u8; consts::MAX_FRAME]; let (_, n) = ex .next_frame(&store, consts::MAX_FRAME, 0, &mut buf) .expect("summary on contact"); match Frame::decode(&buf[..n]).unwrap() { Frame::Summary { salt, bits, .. } => { let bloom = Bloom::from_parts(salt, &bits).expect("valid summary bloom"); assert!(bloom.might_contain(&b_id), "broadcast id must be present"); assert!( !bloom.might_contain(&a_id), "addressed id must be absent from the summary" ); } other => panic!("expected a Summary frame, got {other:?}"), } } /// Drains `next_frame` and returns the first `Obs` frame whose destination matches /// `want`, along with its destination address — skipping summaries and /// non-Obs frames. Breaks on the first match or when `next_frame` yields /// `None`. fn drain_addressed_obs( ex: &mut Exchange, store: &MemStore, want: Option<[u8; 16]>, ) -> Option<(u8, ObservationId)> { let mut result: Option<(u8, ObservationId)> = None; for _ in 0..consts::MAX_FRAME { let mut buf = [0u8; consts::MAX_FRAME]; match ex.next_frame(store, consts::MAX_FRAME, 0, &mut buf) { Some((addr, n)) => match Frame::decode(&buf[..n]).unwrap() { Frame::Obs { obs, .. } if obs.dest.as_ref().map(|d| d.to) == want => { result = Some((addr, obs.id())); break; } _ => {} }, None => break, } } result } #[test] fn addressed_obs_delivered_to_recipient() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); // An addressed obs to [2;16] already held in the store. let mut o = obs([2; 16], 5, 0, None, b"t", b"hi"); o.dest = Some(Dest { to: [2; 16], lifetime_ms: consts::SUMMARY_INTERVAL_MS * 10, }); let id = o.id(); store.insert(o, 0).unwrap(); // Peer at addr 1 contacts us and learns its id from a broadcast frame // (so peer.id = Some([2; 16]) without recording a bounce for our obs). ex.on_event(Event::Contact(1), &mut store, 0); let hello = Frame::Obs { from: [2; 16], obs: obs([2; 16], 99, 0, None, b"t", b"x"), }; ex.on_event(Event::Frame(1, frame_bytes(&hello)), &mut store, 0); assert_eq!(ex.peers()[0].id, Some([2; 16])); // Draining must deliver the addressed Obs to its recipient at addr 1. let got = drain_addressed_obs(&mut ex, &store, Some([2; 16])); let (addr, emitted_id) = got.expect("addressed obs must be delivered to its recipient at addr 1"); assert_eq!(addr, 1, "delivered to the recipient peer"); assert_eq!(emitted_id, id, "the exact addressed obs was delivered"); } #[test] fn addressed_obs_epidemic_forwards_to_non_recipient() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); // Addressed to an unknown node [3;16]; the only peer's id is [2;16]. let mut o = obs([2; 16], 5, 0, None, b"t", b"hi"); o.dest = Some(Dest { to: [3; 16], lifetime_ms: consts::SUMMARY_INTERVAL_MS * 10, }); let id = o.id(); store.insert(o, 0).unwrap(); ex.on_event(Event::Contact(1), &mut store, 0); let hello = Frame::Obs { from: [2; 16], obs: obs([2; 16], 99, 0, None, b"t", b"x"), }; ex.on_event(Event::Frame(1, frame_bytes(&hello)), &mut store, 0); // Draining must epidemic-forward the obs to addr 1 even though the // recipient [3;16] is not yet a known peer (never dropped). let got = drain_addressed_obs(&mut ex, &store, Some([3; 16])); let (addr, emitted_id) = got.expect("unknown-recipient addressed obs must be epidemic-forwarded"); assert_eq!(addr, 1); assert_eq!(emitted_id, id); } #[test] fn addressed_obs_does_not_bounce_to_source() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); // A single addressed obs, received from addr 1, addressed to [3;16]. let mut o = obs([2; 16], 7, 0, None, b"t", b"hi"); o.dest = Some(Dest { to: [3; 16], lifetime_ms: consts::SUMMARY_INTERVAL_MS * 10, }); let frame = Frame::Obs { from: [2; 16], obs: o, }; ex.on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0); // Draining must never bounce the addressed obs back to its source (addr // 1): only a summary should come out, never an Obs to [3;16] at addr 1. let mut bounced = false; for _ in 0..consts::MAX_FRAME { let mut buf = [0u8; consts::MAX_FRAME]; match ex.next_frame(&store, consts::MAX_FRAME, 0, &mut buf) { Some((addr, n)) => match Frame::decode(&buf[..n]).unwrap() { Frame::Obs { obs, .. } if obs.dest.as_ref().map(|d| d.to) == Some([3; 16]) && addr == 1 => { bounced = true; break; } _ => {} }, None => break, } } assert!( !bounced, "an addressed obs must not be forwarded back to its source" ); } /// A payload whose bytes differ by position, so a fragment cut from the /// wrong offset corrupts the reassembled observation. fn ramp(len: usize) -> std::vec::Vec { (0..len).map(|i| (i % 251) as u8).collect() } fn addressed(observer: [u8; 16], seq: u64, to: [u8; 16], payload: &[u8]) -> Observation { let mut o = obs(observer, seq, 0, None, b"t", payload); o.dest = Some(Dest { to, lifetime_ms: 1_000_000, }); o } /// Feeds an empty full-size summary from `from` at `addr`, so the peer is /// known by id and appears to lack every broadcast observation. fn empty_summary(ex: &mut Exchange, store: &mut MemStore, addr: u8, from: NodeId) { let bits = heapless::Vec::from_slice(&[0u8; consts::SUMMARY_BYTES]).unwrap(); let sum = Frame::Summary { from, salt: 0, bits, }; ex.on_event(Event::Frame(addr, frame_bytes(&sum)), store, 0); } /// Polls `ex` once at `now`/`mtu` and decodes the frame, if any. fn poll_frame( ex: &mut Exchange, store: &MemStore, mtu: usize, now: Tick, ) -> Option<(u8, Frame, FrameBufT)> { let mut buf = [0u8; consts::MAX_FRAME]; let (addr, n) = ex.next_frame(store, mtu, now, &mut buf)?; assert!(n <= mtu, "emitted a {n}-byte frame over mtu {mtu}"); let bytes = heapless::Vec::from_slice(&buf[..n]).unwrap(); Some((addr, Frame::decode(&buf[..n]).unwrap(), bytes)) } type FrameBufT = heapless::Vec; #[test] fn frag_stream_survives_tick_going_backwards() { // Pick a payload whose encoding at age 20 000 (a 3-byte varint) is one // byte past a whole number of 136-byte chunks (mtu 200): re-encoding at // a 1-byte age would then leave the last fragment's start past the end. let chunk = 200 - crate::frag::FRAG_OVERHEAD; let big = (900..1024) .map(|len| obs([2; 16], 1, 20_000, None, b"t", &ramp(len))) .find(|o| { let mut buf = [0u8; consts::MAX_FRAME * 2]; postcard::to_slice(o, &mut buf).unwrap().len() % chunk == 1 }) .unwrap(); let big = Observation { age_ms: 0, ..big }; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let id = big.id(); store.insert(big, 0).unwrap(); ex.on_event(Event::Contact(1), &mut store, 0); empty_summary(&mut ex, &mut store, 1, [9; 16]); let mut rx = Exchange::new([9; 16]); let mut rx_store = MemStore::new(); // Send everything but the last fragment at a large age (3-byte // varint), then let the tick jump backwards so the age re-encodes // shorter just before the final fragment is cut. let mut sent = 0; for i in 0..64 { let now = if i < 8 { 20_000 } else { 100 }; let Some((_, frame, bytes)) = poll_frame(&mut ex, &store, 200, now) else { break; }; if matches!(frame, Frame::Frag { .. }) { sent += 1; } rx.on_event(Event::Frame(7, bytes), &mut rx_store, now); } assert!(sent > 1, "the observation must have been fragmented"); assert!( rx_store.contains(&id), "receiver must reassemble the observation" ); } #[test] fn frag_stream_survives_age_crossing_varint_boundary() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let big = obs([2; 16], 1, 0, None, b"t", &ramp(1000)); let id = big.id(); store.insert(big, 0).unwrap(); ex.on_event(Event::Contact(1), &mut store, 0); empty_summary(&mut ex, &mut store, 1, [9; 16]); let mut rx = Exchange::new([9; 16]); let mut rx_store = MemStore::new(); // Polling every 50 ms carries age_ms past 127 (1 -> 2 varint bytes) // part-way through the stream. for i in 0..64u64 { let now = i * 50; let Some((_, _, bytes)) = poll_frame(&mut ex, &store, 200, now) else { break; }; rx.on_event(Event::Frame(7, bytes), &mut rx_store, now); } assert!( rx_store.contains(&id), "receiver must reassemble the observation" ); } #[test] fn summary_fits_small_mtu_and_drives_sync() { let mtu = 200; let mut a = Exchange::new([1; 16]); let mut sa = MemStore::new(); let mut b = Exchange::new([2; 16]); let mut sb = MemStore::new(); let oa = obs([1; 16], 1, 0, None, b"t", b"from-a"); let ob = obs([2; 16], 1, 0, None, b"t", b"from-b"); let (ida, idb) = (oa.id(), ob.id()); sa.insert(oa, 0).unwrap(); sb.insert(ob, 0).unwrap(); a.on_event(Event::Contact(2), &mut sa, 0); b.on_event(Event::Contact(1), &mut sb, 0); for _ in 0..20 { if let Some((_, _, bytes)) = poll_frame(&mut a, &sa, mtu, 0) { b.on_event(Event::Frame(1, bytes), &mut sb, 0); } if let Some((_, _, bytes)) = poll_frame(&mut b, &sb, mtu, 0) { a.on_event(Event::Frame(2, bytes), &mut sa, 0); } } assert!( sa.contains(&idb), "a must receive b's observation over a small mtu" ); assert!( sb.contains(&ida), "b must receive a's observation over a small mtu" ); } #[test] fn bounce_table_overflow_does_not_starve_broadcast_or_custody() { let peer: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let mut want = std::collections::BTreeSet::new(); for seq in 0..300u64 { let o = addressed([3; 16], seq, peer, b"x"); want.insert(o.id()); store.insert(o, 0).unwrap(); } let bcast = obs([3; 16], 1000, 0, None, b"t", b"broadcast"); let bid = bcast.id(); store.insert(bcast, 0).unwrap(); ex.on_event(Event::Contact(1), &mut store, 0); empty_summary(&mut ex, &mut store, 1, peer); let mut got = std::collections::BTreeSet::new(); let mut saw_broadcast = false; for _ in 0..700 { match poll_frame(&mut ex, &store, consts::MAX_FRAME, 0) { Some((_, Frame::Obs { obs, .. }, _)) if obs.dest.is_some() => { got.insert(obs.id()); } Some((_, Frame::Obs { obs, .. }, _)) if obs.id() == bid => saw_broadcast = true, _ => {} } } assert!( saw_broadcast, "custody traffic must not starve the broadcast diff" ); assert_eq!(got, want, "every addressed message must be delivered"); } #[test] fn addressed_frag_streams_are_not_clobbered() { let peer: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let x = addressed([3; 16], 1, peer, &ramp(1000)); let y = addressed([3; 16], 2, peer, &ramp(999)); let (idx, idy) = (x.id(), y.id()); store.insert(x, 0).unwrap(); store.insert(y, 0).unwrap(); ex.on_event(Event::Contact(1), &mut store, 0); empty_summary(&mut ex, &mut store, 1, peer); let mut rx = Exchange::new(peer); let mut rx_store = MemStore::new(); for _ in 0..64 { let Some((_, _, bytes)) = poll_frame(&mut ex, &store, 200, 0) else { break; }; rx.on_event(Event::Frame(7, bytes), &mut rx_store, 0); } assert!( rx_store.contains(&idx), "first addressed stream must complete" ); assert!( rx_store.contains(&idy), "second addressed stream must complete" ); } #[test] fn failed_send_is_retried() { let peer: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let a = addressed([3; 16], 1, peer, b"addressed"); let b = obs([3; 16], 2, 0, None, b"t", b"broadcast"); let big = obs([3; 16], 3, 5, None, b"t", &ramp(1000)); store.insert(a, 0).unwrap(); store.insert(b, 0).unwrap(); store.insert(big, 0).unwrap(); ex.on_event(Event::Contact(1), &mut store, 0); empty_summary(&mut ex, &mut store, 1, peer); // Every frame that hits a Busy transport must come out again on the // next poll, unchanged. let mut frames = 0; while let Some((_, first, _)) = poll_frame(&mut ex, &store, 200, 0) { ex.on_send_error(&SendError::Busy); let (_, again, _) = poll_frame(&mut ex, &store, 200, 0).expect("retry after Busy"); assert_eq!(again, first, "a Busy frame must be re-emitted"); frames += 1; assert!(frames < 64, "exchange never drained"); } assert!( frames > 3, "summary, addressed, broadcast and fragments were all exercised" ); } #[test] fn bounce_suppression_resets_on_new_contact() { let peer: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let o = addressed([3; 16], 1, peer, b"hi"); let id = o.id(); store.insert(o, 0).unwrap(); ex.on_event(Event::Contact(1), &mut store, 0); empty_summary(&mut ex, &mut store, 1, peer); assert_eq!( drain_addressed_obs(&mut ex, &store, Some(peer)), Some((1, id)) ); // The frame was lost; the peer leaves and comes back. ex.on_event(Event::Lost(1), &mut store, 0); ex.on_event(Event::Contact(1), &mut store, 0); empty_summary(&mut ex, &mut store, 1, peer); assert_eq!( drain_addressed_obs(&mut ex, &store, Some(peer)), Some((1, id)), "a new contact must get another delivery attempt" ); } #[test] fn unemittable_addressed_obs_does_not_block_others() { let peer: NodeId = [2; 16]; // Small enough that a 1000-byte obs needs more than 64 fragments, big // enough that a tiny obs fits in one Obs frame. let mtu = 80; let small = addressed([3; 16], 0, peer, b"s"); let sid = small.id(); let mut seq = 1; let big = loop { let b = addressed([3; 16], seq, peer, &ramp(1000)); if b.id() < sid { break b; } seq += 1; }; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); store.insert(big, 0).unwrap(); store.insert(small, 0).unwrap(); ex.on_event(Event::Contact(1), &mut store, 0); empty_summary(&mut ex, &mut store, 1, peer); let mut delivered = false; for _ in 0..8 { if let Some((_, Frame::Obs { obs, .. }, _)) = poll_frame(&mut ex, &store, mtu, 0) { delivered |= obs.id() == sid; } } assert!( delivered, "a deliverable message must not wait behind an undeliverable one" ); } #[test] fn duplicate_addressed_obs_is_not_sent_back() { let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let o = addressed([3; 16], 1, [4; 16], b"hi"); // Two carriers, at addrs 1 and 2, each hand us the same message. for (addr, from) in [(1u8, [5u8; 16]), (2, [6; 16])] { let f = Frame::Obs { from, obs: o.clone(), }; ex.on_event(Event::Frame(addr, frame_bytes(&f)), &mut store, 0); } for _ in 0..16 { if let Some((addr, Frame::Obs { obs, .. }, _)) = poll_frame(&mut ex, &store, consts::MAX_FRAME, 0) { panic!("sent {:?} back to carrier {addr}", obs.id()); } } } /// A `MemStore` that counts `get` calls. struct CountingStore { inner: MemStore, gets: core::cell::Cell, } impl Store for CountingStore { fn insert(&mut self, obs: Observation, now: Tick) -> Result { self.inner.insert(obs, now) } fn contains(&self, id: &ObservationId) -> bool { self.inner.contains(id) } fn get(&self, id: &ObservationId, now: Tick) -> Option { self.gets.set(self.gets.get() + 1); self.inner.get(id, now) } fn len(&self) -> usize { self.inner.len() } fn for_each_id(&self, f: &mut dyn FnMut(&ObservationId)) { self.inner.for_each_id(f) } fn for_each_broadcast_id(&self, f: &mut dyn FnMut(&ObservationId)) { self.inner.for_each_broadcast_id(f) } fn for_each_addressed_id(&self, f: &mut dyn FnMut(&ObservationId)) { self.inner.for_each_addressed_id(f) } fn release(&mut self, id: &ObservationId) { self.inner.release(id) } fn is_released(&self, id: &ObservationId) -> bool { self.inner.is_released(id) } fn for_each_topic(&self, prefix: &[u8], now: Tick, f: &mut dyn FnMut(&Observation)) { self.inner.for_each_topic(prefix, now, f) } fn trim(&mut self, policy: &crate::TrimPolicy, now: Tick) -> usize { self.inner.trim(policy, now) } } #[test] fn custody_scan_skips_broadcast_observations() { let mut ex = Exchange::new(ME); let mut store = CountingStore { inner: MemStore::new(), gets: core::cell::Cell::new(0), }; let mut theirs = Bloom::new(0); for seq in 0..200 { let o = obs([3; 16], seq, 0, None, b"t", b"x"); theirs.insert(&o.id()); store.insert(o, 0).unwrap(); } ex.on_event(Event::Contact(1), &mut store, 0); // The peer already holds every broadcast, so there is nothing to send. let sum = Frame::Summary { from: [2; 16], salt: 0, bits: heapless::Vec::from_slice(theirs.bits()).unwrap(), }; ex.on_event(Event::Frame(1, frame_bytes(&sum)), &mut store, 0); let mut buf = [0u8; consts::MAX_FRAME]; ex.next_frame(&store, consts::MAX_FRAME, 0, &mut buf); // our summary store.gets.set(0); for _ in 0..10 { assert!(ex .next_frame(&store, consts::MAX_FRAME, 0, &mut buf) .is_none()); } assert_eq!( store.gets.get(), 0, "custody must not read broadcast observations" ); } /// Drives `ex` so that `id` (addressed or broadcast) is emitted to `addr`: /// contact, an empty summary (so the peer lacks everything), then poll /// `next_frame` until an Obs carrying `id` goes out. The emit path records /// the Sent bounce (addressed) or the `bcast_sent` entry (broadcast). fn deliver_to(ex: &mut Exchange, store: &mut MemStore, addr: u8, id: ObservationId) { ex.on_event(Event::Contact(addr), store, 0); empty_summary(ex, store, addr, [2; 16]); for _ in 0..consts::MAX_FRAME { if matches!( poll_frame(ex, store, consts::MAX_FRAME, 0), Some((_, Frame::Obs { obs, .. }, _)) if obs.id() == id ) { break; } } } /// An Ack for an addressed observation the peer received releases custody: /// the id is marked released and is no longer selected for forwarding. #[test] fn ack_releases_addressed_custody_after_sent() { let peer: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let x = addressed([3; 16], 1, peer, b"hi"); let x_id = x.id(); store.insert(x, 0).unwrap(); deliver_to(&mut ex, &mut store, 1, x_id); assert!( !store.is_released(&x_id), "custody is held until the peer confirms delivery" ); let ids = heapless::Vec::from_slice(&[x_id]).unwrap(); let ack = Frame::Ack { from: peer, ids }; ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); assert!( store.is_released(&x_id), "an Ack releases custody for a dispatched addressed id" ); } /// After an Ack releases custody, a new contact drops the bounce records, /// but the released id is still not selected: the is_released mark on the /// stored entry keeps it out of forwarding. #[test] fn ack_released_id_will_reselect_and_reject() { let peer: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let x = addressed([3; 16], 1, peer, b"hi"); let x_id = x.id(); store.insert(x, 0).unwrap(); deliver_to(&mut ex, &mut store, 1, x_id); let ids = heapless::Vec::from_slice(&[x_id]).unwrap(); let ack = Frame::Ack { from: peer, ids }; ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); assert!(store.is_released(&x_id), "an Ack releases custody of X"); // A new contact drops the bounce records; the released id must still be // refused by the custody scan. ex.on_event(Event::Lost(1), &mut store, 0); ex.on_event(Event::Contact(1), &mut store, 0); empty_summary(&mut ex, &mut store, 1, peer); let mut reemitted = false; for _ in 0..consts::MAX_FRAME { if matches!( poll_frame(&mut ex, &store, consts::MAX_FRAME, 0), Some((_, Frame::Obs { obs, .. }, _)) if obs.id() == x_id ) { reemitted = true; break; } } assert!( !reemitted, "a released id must not be selected again after a new contact" ); } /// The custody chain releases on the first Ack from ANY peer the node /// dispatched the observation to — a relay (a non-recipient that merely /// holds an epidemic-forwarded copy), not just the intended recipient. This /// is the binding release semantics; release tracks that a copy left us, not /// final delivery. #[test] fn ack_releases_custody_from_a_relay() { let relay: NodeId = [2; 16]; let recipient: NodeId = [3; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); // ME holds X, addressed to `recipient`, and dispatched it to the relay. let x = observed_addressed([7; 16], 1, recipient, b"t", b"hi"); let x_id = x.id(); store.insert(x.clone(), 0).unwrap(); deliver_to(&mut ex, &mut store, 1, x_id); assert!( !store.is_released(&x_id), "custody is held until an ack from a peer we sent to" ); // A relay (not the recipient) confirms it holds X. let ids = heapless::Vec::from_slice(&[x_id]).unwrap(); let ack = Frame::Ack { from: relay, ids }; ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); assert!( store.is_released(&x_id), "an Ack from any peer we sent to (a relay here) releases custody" ); } /// An Ack for a broadcast we dispatched to the peer fires exactly one /// `NodeEvent::Delivered` naming the peer and the broadcast id. #[test] fn ack_broadcast_fires_confidence_event() { let peer: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let b = obs([3; 16], 1, 0, None, b"t", b"bc"); let b_id = b.id(); store.insert(b, 0).unwrap(); deliver_to(&mut ex, &mut store, 1, b_id); assert!(!store.is_released(&b_id)); let ids = heapless::Vec::from_slice(&[b_id]).unwrap(); let ack = Frame::Ack { from: peer, ids }; ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); let events = ex.drain_events(); assert_eq!( events.len(), 1, "one broadcast ack fires one confidence event" ); match events[0] { NodeEvent::Delivered { peer: ev_peer, id: ev_id, } => { assert_eq!(ev_peer, peer); assert_eq!(ev_id, b_id); } other => panic!("expected a Delivered event, got {other:?}"), } } /// A broadcast Ack does not release custody: there is no local addressed /// copy to release, so the broadcast stays held. #[test] fn ack_broadcast_release_does_not_store_broadcast() { let peer: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let b = obs([3; 16], 1, 0, None, b"t", b"bc"); let b_id = b.id(); store.insert(b, 0).unwrap(); deliver_to(&mut ex, &mut store, 1, b_id); assert!( !store.is_released(&b_id), "a broadcast is held, not released" ); let ids = heapless::Vec::from_slice(&[b_id]).unwrap(); let ack = Frame::Ack { from: peer, ids }; ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); assert!( !store.is_released(&b_id), "an Ack must not release a broadcast we dispatched" ); } /// An Ack for an id the local node only received (not dispatched) must NOT /// release it: release applies to dispatched ids only. #[test] fn is_released_not_selected_after_ack() { let peer: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); // A broadcast received from the peer: the node only received it. let x = obs(peer, 1, 0, None, b"t", b"hi"); let x_id = x.id(); let frame = Frame::Obs { from: peer, obs: x }; ex.on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0); assert!(store.contains(&x_id)); assert!(store.get(&x_id, 0).unwrap().dest.is_none()); let ids = heapless::Vec::from_slice(&[x_id]).unwrap(); let ack = Frame::Ack { from: peer, ids }; ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); assert!( !store.is_released(&x_id), "an Ack must not release an id the local node only received" ); } /// The custody scan skips a released id but still offers non-released ids: /// releasing the broadcast leaves the addressed id offered, and releasing /// the addressed id leaves nothing offered. #[test] fn select_addressed_still_selects_non_released() { let mut store = MemStore::new(); let a = addressed([3; 16], 1, [4; 16], b"a"); let b = obs([3; 16], 2, 0, None, b"t", b"bc"); let a_id = a.id(); let b_id = b.id(); store.insert(a, 0).unwrap(); store.insert(b, 0).unwrap(); // Release the broadcast only: the non-released addressed id is still // offered. store.release(&b_id); let empty: &[(ObservationId, u8, BounceKind)] = &[]; let found = select_addressed(&store, 0, empty, &1u8, None, |_id, obs| obs.dest.is_some()); assert_eq!(found.map(|(id, _)| id), Some(a_id)); // Release the addressed id too: nothing remains selectable. store.release(&a_id); let found = select_addressed(&store, 0, empty, &1u8, None, |_id, obs| obs.dest.is_some()); assert!(found.is_none(), "an id released by an Ack is not selected"); } /// After an Ack releases custody, the release mark persists on the stored /// entry across a new contact: dropping the exchange's bounce records does /// not re-open the id, so it is never offered again. #[test] fn is_released_not_offered_after_contact_ends() { let peer: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let x = addressed([3; 16], 1, peer, b"hi"); let x_id = x.id(); store.insert(x, 0).unwrap(); deliver_to(&mut ex, &mut store, 1, x_id); let ids = heapless::Vec::from_slice(&[x_id]).unwrap(); let ack = Frame::Ack { from: peer, ids }; ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); assert!( store.is_released(&x_id), "an Ack releases custody for a dispatched id" ); // A new contact drops the exchange's bounce records, but the release // mark on the stored entry persists. ex.on_event(Event::Lost(1), &mut store, 0); ex.on_event(Event::Contact(1), &mut store, 0); assert!( store.is_released(&x_id), "the release mark persists across a new contact; the id is not re-offered" ); } /// A broadcast Ack from a peer that never held the broadcast fires no /// `NodeEvent::Delivered`: the receipt names a broadcast we did not dispatch /// to that peer. #[test] fn ack_broadcast_forged_id_no_confidence() { let peer: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let b = obs([3; 16], 1, 0, None, b"t", b"bc"); let b_id = b.id(); store.insert(b, 0).unwrap(); // Dispatch the broadcast to a different peer (addr 2), not addr 1. deliver_to(&mut ex, &mut store, 2, b_id); // Peer 1 (addr 1) claims to hold b_id though it never did. let ids = heapless::Vec::from_slice(&[b_id]).unwrap(); let ack = Frame::Ack { from: peer, ids }; ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); let events = ex.drain_events(); assert!( events.is_empty(), "a broadcast Ack for a broadcast not sent to the peer fires no confidence event" ); } /// drain_events returns confidence events oldest-first, in the order the /// broadcasts were acknowledged. #[test] fn drain_events_returns_confidence_events_oldest_first() { let peer: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let a = obs([3; 16], 1, 0, None, b"t", b"bc-a"); let b = obs([3; 16], 2, 0, None, b"t", b"bc-b"); let a_id = a.id(); let b_id = b.id(); store.insert(a, 0).unwrap(); store.insert(b, 0).unwrap(); deliver_to(&mut ex, &mut store, 1, a_id); deliver_to(&mut ex, &mut store, 1, b_id); let ack_for = |ids: heapless::Vec| Frame::Ack { from: peer, ids, }; let ids_a = heapless::Vec::from_slice(&[a_id]).unwrap(); ex.on_event(Event::Frame(1, frame_bytes(&ack_for(ids_a))), &mut store, 0); let ids_b = heapless::Vec::from_slice(&[b_id]).unwrap(); ex.on_event(Event::Frame(1, frame_bytes(&ack_for(ids_b))), &mut store, 0); let events = ex.drain_events(); assert_eq!( events.len(), 2, "each broadcast ack fires a confidence event" ); match events[0] { NodeEvent::Delivered { id, .. } => { assert_eq!(id, a_id, "events come back oldest-first"); } other => panic!("expected a Delivered event, got {other:?}"), } match events[1] { NodeEvent::Delivered { id, .. } => { assert_eq!(id, b_id); } other => panic!("expected a Delivered event, got {other:?}"), } } /// drain_events empties the queue: a second drain yields nothing. #[test] fn drain_events_empties_queue() { let peer: NodeId = [2; 16]; let mut ex = Exchange::new(ME); let mut store = MemStore::new(); let b = obs([3; 16], 1, 0, None, b"t", b"bc"); let b_id = b.id(); store.insert(b, 0).unwrap(); deliver_to(&mut ex, &mut store, 1, b_id); let ids = heapless::Vec::from_slice(&[b_id]).unwrap(); let ack = Frame::Ack { from: peer, ids }; ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); let first = ex.drain_events(); assert_eq!(first.len(), 1, "the queued confidence event is drained"); match first[0] { NodeEvent::Delivered { id, .. } => { assert_eq!(id, b_id); } other => panic!("expected a Delivered event, got {other:?}"), } let second = ex.drain_events(); assert!( second.is_empty(), "drain_events empties the queue; a second drain yields nothing" ); } /// A frame whose leading byte isn't WIRE_VERSION is a counted /// version-mismatch drop, reported with the sender, the version byte, and a /// count of one. #[test] fn version_mismatch_is_counted() { let mut store = MemStore::new(); let mut ex = Exchange::::new(ME); // The first byte is 0, which isn't WIRE_VERSION: a version mismatch. let bytes = heapless::Vec::::from_slice(&[0u8; 10]).unwrap(); assert_eq!(ex.on_event(Event::Frame(1, bytes), &mut store, 0), None); let drained = ex.drain_events(); assert_eq!(drained.len(), 1, "exactly one counted drop"); match drained.into_iter().next().unwrap() { NodeEvent::Dropped { addr, reason, count, } => { assert_eq!(addr, Some(1u8)); assert_eq!(reason, DropReason::VersionMismatch { version: 0 }); assert_eq!(count, 1); } other => panic!("expected a counted Dropped, got {other:?}"), } } /// A frame that passes the version gate (byte 0 is WIRE_VERSION) but whose /// body isn't a valid postcard `Frame` is a counted malformed drop. #[test] fn malformed_is_counted() { let mut store = MemStore::new(); let mut ex = Exchange::::new(ME); let mut bytes = heapless::Vec::::new(); bytes.push(consts::WIRE_VERSION).unwrap(); bytes.push(0xFF).unwrap(); bytes.push(0xFF).unwrap(); assert_eq!(ex.on_event(Event::Frame(1, bytes), &mut store, 0), None); let drained = ex.drain_events(); assert_eq!(drained.len(), 1, "exactly one counted drop"); match drained.into_iter().next().unwrap() { NodeEvent::Dropped { addr, reason, count, } => { assert_eq!(addr, Some(1u8)); assert_eq!(reason, DropReason::Malformed); assert_eq!(count, 1); } other => panic!("expected a counted Dropped, got {other:?}"), } } /// A repeat of the same (addr, reason) folds into one counter whose count /// is two: the two frames are one drop, counted once with count 2. #[test] fn same_drop_increments_count() { let mut store = MemStore::new(); let mut ex = Exchange::::new(ME); let bytes = heapless::Vec::::from_slice(&[0u8; 10]).unwrap(); assert_eq!( ex.on_event(Event::Frame(1, bytes.clone()), &mut store, 0), None ); assert_eq!(ex.on_event(Event::Frame(1, bytes), &mut store, 0), None); let drained = ex.drain_events(); assert_eq!(drained.len(), 1, "a repeat is folded into one counter"); match drained.into_iter().next().unwrap() { NodeEvent::Dropped { addr, reason, count, } => { assert_eq!(addr, Some(1u8)); assert_eq!(reason, DropReason::VersionMismatch { version: 0 }); assert_eq!(count, 2, "both frames counted against one peer+reason"); } other => panic!("expected a single counted Dropped with count 2, got {other:?}"), } } /// An addressed observation that arrives already past its lifetime_ms is /// rejected by policy; that rejection is NOT a counted drop (unlike a /// decode error). #[test] fn expired_addressed_is_not_a_drop() { let mut store = MemStore::new(); let mut ex = Exchange::::new(ME); // Addressed to us, lifetime 100ms, but age 200ms -> expired. let mut o = obs([2; 16], 7, 200, None, b"t", b"hi"); o.dest = Some(Dest { to: ME, lifetime_ms: 100, }); let frame = Frame::Obs { from: [2; 16], obs: o, }; assert_eq!( ex.on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0), None, "an expired addressed obs must not be delivered" ); // A policy rejection is not a counted drop. assert!( ex.drain_events().is_empty(), "an expired addressed obs is rejected by policy, not counted as a drop" ); } /// A full delivery queue and a counted drop survive together: drops are /// appended after the deliveries and none are evicted (the drop-oldest /// eviction of the delivery queue is not in play here, and the drop-table /// and delivery queue never evict each other). #[test] fn drops_do_not_evict_delivered() { let mut store = MemStore::new(); let mut ex = Exchange::::new(ME); // Fill the delivery queue to the brim with confirmed broadcasts. for _ in 0..consts::EVENT_QUEUE_CAP { ex.events .push(NodeEvent::Delivered { peer: [7; 16], id: [0u8; 16], }) .unwrap(); } // One counted drop lands alongside the full delivery queue. let bytes = heapless::Vec::::from_slice(&[0u8; 10]).unwrap(); assert_eq!(ex.on_event(Event::Frame(1, bytes), &mut store, 0), None); let drained = ex.drain_events(); let mut delivered = 0usize; let mut dropped = 0usize; for ev in drained { match ev { NodeEvent::Delivered { .. } => delivered += 1, NodeEvent::Dropped { .. } => dropped += 1, } } assert_eq!( delivered, consts::EVENT_QUEUE_CAP, "every queued delivery survives a full queue" ); assert_eq!(dropped, 1, "the counted drop is reported alongside them"); } /// Once the drop-counter table is full, the next drop folds into a single /// overflow counter reported with `addr: None`; the table's 32 entries are /// all still reported with a real address. #[test] fn overflow_counter_has_no_addr() { let mut store = MemStore::new(); let mut ex = Exchange::::new(ME); // Drive DROP_TABLE_CAP + 1 distinct (addr, reason) pairs: the table // holds DROP_TABLE_CAP, so the one beyond the cap folds into the single // overflow counter and is reported with no address. for addr in 0..=(consts::DROP_TABLE_CAP as u8) { let bytes = heapless::Vec::::from_slice(&[0u8; 10]).unwrap(); assert_eq!( ex.on_event(Event::Frame(addr, bytes.clone()), &mut store, 0), None ); } let drained = ex.drain_events(); let mut reported = 0usize; let mut overflow = 0usize; for ev in drained { match ev { NodeEvent::Dropped { addr: Some(..), .. } => reported += 1, NodeEvent::Dropped { addr: None, .. } => overflow += 1, NodeEvent::Delivered { .. } => {} } } assert_eq!( overflow, 1, "the drop beyond the table cap must report with no address" ); assert_eq!( reported, consts::DROP_TABLE_CAP, "all capped entries reported" ); } }