//! 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,
/// The frame's `from` no longer matches the node id this peer was pinned to
/// on its first frame (F6). The pinned id is kept; the frame is not stored.
#[non_exhaustive]
IdentityChanged,
}
/// 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,
/// Tick of the last inbound frame this peer sent; used to evict a peer
/// idle beyond [`consts::PEER_IDLE_MS`] as if Lost.
last_inbound: Option,
/// Tick of the last summary we acted on from this peer; enforces the
/// one-summary-per-[`consts::SUMMARY_INTERVAL_MS`] replay limit (F9).
last_summary_received: 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,
last_inbound: None,
last_summary_received: 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