use std::collections::HashSet;
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
use crate::codec::{self, CodecError};
use crate::wire::{Frame, MAX_ENTRIES_PER_PAGE, MAX_HELLO_HASHES};
type Hash = [u8; 32];
pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
pub const MAX_SESSION_ENTRIES: usize = 1_000_000;
fn bounded_inventory(hashes: impl Iterator<Item = Hash>) -> Vec<Hash> {
hashes.take(MAX_HELLO_HASHES).collect()
}
pub trait SyncStore {
fn account_id(&self) -> Hash;
fn snapshot(&self) -> anyhow::Result<Vec<(Hash, Vec<u8>)>>;
fn ingest(&mut self, signed_bytes: &[u8]) -> anyhow::Result<Ingested>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ingested {
Stored,
NoChange,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SessionReport {
pub entries_sent: usize,
pub entries_received: usize,
pub entries_newly_stored: usize,
}
#[derive(Debug)]
pub enum SessionError {
Codec(CodecError),
Protocol(String),
Store(anyhow::Error),
}
impl std::fmt::Display for SessionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SessionError::Codec(e) => write!(f, "sync session transport: {e}"),
SessionError::Protocol(m) => write!(f, "sync session protocol violation: {m}"),
SessionError::Store(e) => write!(f, "sync session store: {e}"),
}
}
}
impl std::error::Error for SessionError {}
pub async fn run_session<S, R, W>(
store: &mut S,
send: W,
recv: R,
) -> Result<SessionReport, SessionError>
where
S: SyncStore,
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
run_session_with_idle_timeout(store, send, recv, DEFAULT_IDLE_TIMEOUT).await
}
pub async fn run_session_with_idle_timeout<S, R, W>(
store: &mut S,
mut send: W,
mut recv: R,
idle_timeout: Duration,
) -> Result<SessionReport, SessionError>
where
S: SyncStore,
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let account_id = store.account_id();
let snapshot = store.snapshot().map_err(SessionError::Store)?;
let have = bounded_inventory(snapshot.iter().map(|(h, _)| *h));
let (peer_have_tx, peer_have_rx) = tokio::sync::oneshot::channel::<HashSet<Hash>>();
let sender = async move {
codec::write_frame(&mut send, &Frame::Hello { account_id, have })
.await
.map_err(SessionError::Codec)?;
let Ok(peer_have) = peer_have_rx.await else {
return Ok(0usize);
};
let mut to_send: Vec<Vec<u8>> = snapshot
.into_iter()
.filter(|(hash, _)| !peer_have.contains(hash))
.map(|(_, bytes)| bytes)
.collect();
let total = to_send.len();
let mut rest = to_send.split_off(0);
while !rest.is_empty() {
let tail = rest.split_off(rest.len().min(MAX_ENTRIES_PER_PAGE));
let page = std::mem::replace(&mut rest, tail);
let more = !rest.is_empty();
codec::write_frame(&mut send, &Frame::Entries { entries: page, more })
.await
.map_err(SessionError::Codec)?;
}
codec::write_frame(&mut send, &Frame::Done).await.map_err(SessionError::Codec)?;
send.shutdown().await.map_err(|e| SessionError::Codec(CodecError::Io(e)))?;
Ok::<usize, SessionError>(total)
};
let receiver = async {
let hello = read_frame_before(&mut recv, idle_timeout).await?;
let Frame::Hello { account_id: peer_account, have: peer_have } = hello else {
return Err(SessionError::Protocol("peer did not open with a hello".into()));
};
if peer_account != account_id {
return Err(SessionError::Protocol(
"peer hello names a different account than this session".into(),
));
}
let _ = peer_have_tx.send(peer_have.into_iter().collect());
let mut received = 0usize;
let mut newly_stored = 0usize;
let mut saw_page = false;
let mut saw_final = false;
loop {
match read_frame_before(&mut recv, idle_timeout).await {
Ok(Frame::Entries { entries, more }) => {
if saw_final {
return Err(SessionError::Protocol(
"peer sent an Entries page after the final page".into(),
));
}
if entries.is_empty() {
return Err(SessionError::Protocol(
"peer sent an empty Entries page".into(),
));
}
for bytes in entries {
received += 1;
if received > MAX_SESSION_ENTRIES {
return Err(SessionError::Protocol(format!(
"peer streamed more than {MAX_SESSION_ENTRIES} entries",
)));
}
match store.ingest(&bytes).map_err(SessionError::Store)? {
Ingested::Stored => newly_stored += 1,
Ingested::NoChange => {},
}
}
saw_page = true;
saw_final = !more;
},
Ok(Frame::Done) => {
if saw_page && !saw_final {
return Err(SessionError::Protocol(
"peer sent Done after declaring more pages would follow".into(),
));
}
break;
},
Ok(Frame::Hello { .. }) => {
return Err(SessionError::Protocol("a second hello mid-session".into()));
},
Ok(Frame::Auth { .. }) => {
return Err(SessionError::Protocol("an auth frame mid-session".into()));
},
Err(e) => return Err(e),
}
}
Ok::<(usize, usize), SessionError>((received, newly_stored))
};
let (entries_sent, (entries_received, entries_newly_stored)) =
tokio::try_join!(sender, receiver)?;
Ok(SessionReport { entries_sent, entries_received, entries_newly_stored })
}
async fn read_frame_before<R: AsyncRead + Unpin>(
recv: &mut R,
idle_timeout: Duration,
) -> Result<Frame, SessionError> {
match tokio::time::timeout(idle_timeout, codec::read_frame(recv)).await {
Ok(Ok(frame)) => Ok(frame),
Ok(Err(CodecError::Eof)) => Err(SessionError::Protocol(
"peer closed the stream before sending Done — transfer truncated".into(),
)),
Ok(Err(e)) => Err(SessionError::Codec(e)),
Err(_elapsed) => Err(SessionError::Protocol(format!(
"peer sent no frame within {idle_timeout:?} — session aborted as idle"
))),
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
struct MemStore {
account: Hash,
entries: HashMap<Hash, Vec<u8>>,
}
impl MemStore {
fn new(account: Hash, entries: &[(Hash, Vec<u8>)]) -> Self {
Self { account, entries: entries.iter().cloned().collect() }
}
}
impl SyncStore for MemStore {
fn account_id(&self) -> Hash {
self.account
}
fn snapshot(&self) -> anyhow::Result<Vec<(Hash, Vec<u8>)>> {
let mut v: Vec<_> = self.entries.iter().map(|(h, b)| (*h, b.clone())).collect();
v.sort_by_key(|(h, _)| *h);
Ok(v)
}
fn ingest(&mut self, signed_bytes: &[u8]) -> anyhow::Result<Ingested> {
let hash: Hash = signed_bytes[..32].try_into().unwrap();
match self.entries.entry(hash) {
std::collections::hash_map::Entry::Occupied(_) => Ok(Ingested::NoChange),
std::collections::hash_map::Entry::Vacant(slot) => {
slot.insert(signed_bytes.to_vec());
Ok(Ingested::Stored)
},
}
}
}
fn entry(seed: u8) -> (Hash, Vec<u8>) {
let mut bytes = vec![seed; 40];
bytes[..32].copy_from_slice(&[seed; 32]);
([seed; 32], bytes)
}
async fn sync_pair(a: &mut MemStore, b: &mut MemStore) -> (SessionReport, SessionReport) {
let (a_send, b_recv) = tokio::io::duplex(1 << 20);
let (b_send, a_recv) = tokio::io::duplex(1 << 20);
let (ra, rb) =
tokio::join!(run_session(a, a_send, a_recv), run_session(b, b_send, b_recv),);
(ra.unwrap(), rb.unwrap())
}
#[tokio::test]
async fn a_peer_with_nothing_restores_the_full_set_from_the_other() {
let full: Vec<_> = (0u8..5).map(entry).collect();
let mut a = MemStore::new([0xac; 32], &full);
let mut b = MemStore::new([0xac; 32], &[]);
let (ra, rb) = sync_pair(&mut a, &mut b).await;
assert_eq!(ra.entries_sent, 5, "the full peer sends all five");
assert_eq!(rb.entries_newly_stored, 5, "the empty peer stores all five");
assert_eq!(a.entries.len(), 5, "the full peer is unchanged");
assert_eq!(b.entries.len(), 5, "the empty peer is now complete");
assert_eq!(a.entries, b.entries, "both hold the same set — restore-from-peer");
}
#[tokio::test]
async fn disjoint_peers_converge_to_the_union_both_directions() {
let mut a = MemStore::new([1; 32], &[entry(1), entry(2), entry(3)]);
let mut b = MemStore::new([1; 32], &[entry(3), entry(4), entry(5)]);
let (ra, rb) = sync_pair(&mut a, &mut b).await;
assert_eq!(rb.entries_newly_stored, 2, "b gains 1 and 2");
assert_eq!(ra.entries_newly_stored, 2, "a gains 4 and 5");
let union: HashSet<Hash> = (1u8..=5).map(|s| [s; 32]).collect();
assert_eq!(a.entries.keys().copied().collect::<HashSet<_>>(), union);
assert_eq!(b.entries.keys().copied().collect::<HashSet<_>>(), union);
}
#[tokio::test]
async fn already_in_sync_transfers_nothing() {
let same: Vec<_> = (10u8..13).map(entry).collect();
let mut a = MemStore::new([2; 32], &same);
let mut b = MemStore::new([2; 32], &same);
let (ra, rb) = sync_pair(&mut a, &mut b).await;
assert_eq!(ra.entries_sent, 0);
assert_eq!(rb.entries_sent, 0);
assert_eq!(ra.entries_newly_stored, 0);
assert_eq!(rb.entries_newly_stored, 0);
}
#[tokio::test]
async fn a_truncated_transfer_fails_rather_than_reporting_success() {
use crate::codec::write_frame;
let mut receiver = MemStore::new([5; 32], &[]);
let (mut peer_send, recv) = tokio::io::duplex(1 << 16);
let (send, _peer_recv) = tokio::io::duplex(1 << 16);
let feeder = tokio::spawn(async move {
write_frame(&mut peer_send, &Frame::Hello { account_id: [5; 32], have: vec![] })
.await
.unwrap();
write_frame(&mut peer_send, &Frame::Entries {
entries: vec![entry(7).1],
more: true, })
.await
.unwrap();
});
let result = run_session(&mut receiver, send, recv).await;
feeder.await.unwrap();
assert!(
matches!(result, Err(SessionError::Protocol(_))),
"EOF before Done is a truncated transfer, not success: {result:?}",
);
}
#[tokio::test]
async fn done_after_a_more_true_page_is_rejected() {
use crate::codec::write_frame;
let mut receiver = MemStore::new([6; 32], &[]);
let (mut peer_send, recv) = tokio::io::duplex(1 << 16);
let (send, _peer_recv) = tokio::io::duplex(1 << 16);
let feeder = tokio::spawn(async move {
write_frame(&mut peer_send, &Frame::Hello { account_id: [6; 32], have: vec![] })
.await
.unwrap();
write_frame(&mut peer_send, &Frame::Entries { entries: vec![entry(1).1], more: true })
.await
.unwrap();
write_frame(&mut peer_send, &Frame::Done).await.unwrap();
});
let result = run_session(&mut receiver, send, recv).await;
feeder.await.unwrap();
assert!(
matches!(result, Err(SessionError::Protocol(_))),
"Done after more:true is a declared-incomplete transfer: {result:?}",
);
}
#[tokio::test]
async fn a_silent_peer_times_out() {
use crate::codec::write_frame;
let mut receiver = MemStore::new([11; 32], &[]);
let (mut peer_send, recv) = tokio::io::duplex(1 << 16);
let (send, _peer_recv) = tokio::io::duplex(1 << 16);
write_frame(&mut peer_send, &Frame::Hello { account_id: [11; 32], have: vec![] })
.await
.unwrap();
let result = run_session_with_idle_timeout(
&mut receiver,
send,
recv,
std::time::Duration::from_millis(50),
)
.await;
drop(peer_send); match result {
Err(SessionError::Protocol(m)) => assert!(m.contains("idle"), "{m}"),
other => panic!("expected an idle-timeout abort: {other:?}"),
}
}
#[tokio::test]
async fn a_page_after_the_final_page_is_rejected() {
use crate::codec::write_frame;
let mut receiver = MemStore::new([12; 32], &[]);
let (mut peer_send, recv) = tokio::io::duplex(1 << 16);
let (send, _peer_recv) = tokio::io::duplex(1 << 16);
let feeder = tokio::spawn(async move {
write_frame(&mut peer_send, &Frame::Hello { account_id: [12; 32], have: vec![] })
.await
.unwrap();
write_frame(&mut peer_send, &Frame::Entries { entries: vec![entry(1).1], more: false })
.await
.unwrap();
write_frame(&mut peer_send, &Frame::Entries { entries: vec![entry(2).1], more: false })
.await
.unwrap();
});
let result = run_session(&mut receiver, send, recv).await;
feeder.await.unwrap();
match result {
Err(SessionError::Protocol(m)) => assert!(m.contains("after the final page"), "{m}"),
other => panic!("expected the after-final-page guard: {other:?}"),
}
}
#[tokio::test]
async fn an_empty_entries_page_is_rejected() {
use crate::codec::write_frame;
let mut receiver = MemStore::new([8; 32], &[]);
let (mut peer_send, recv) = tokio::io::duplex(1 << 16);
let (send, _peer_recv) = tokio::io::duplex(1 << 16);
let feeder = tokio::spawn(async move {
write_frame(&mut peer_send, &Frame::Hello { account_id: [8; 32], have: vec![] })
.await
.unwrap();
write_frame(&mut peer_send, &Frame::Entries { entries: vec![], more: true })
.await
.unwrap();
});
let result = run_session(&mut receiver, send, recv).await;
feeder.await.unwrap();
match result {
Err(SessionError::Protocol(m)) => assert!(m.contains("empty Entries page"), "{m}"),
other => panic!("expected the empty-page guard: {other:?}"),
}
}
#[test]
fn the_outgoing_inventory_is_capped_to_the_wire_limit() {
let over = MAX_HELLO_HASHES + 100;
let hashes = (0..over).map(|i| {
let mut h = [0u8; 32];
h[..8].copy_from_slice(&(i as u64).to_be_bytes());
h
});
let bounded = bounded_inventory(hashes);
assert_eq!(bounded.len(), MAX_HELLO_HASHES, "never advertises more than the peer decodes");
let frame = Frame::Hello { account_id: [0; 32], have: bounded };
assert!(Frame::decode(&frame.encode()).is_ok());
}
#[tokio::test]
async fn a_mismatched_account_aborts_the_session() {
let mut a = MemStore::new([1; 32], &[entry(1)]);
let mut b = MemStore::new([2; 32], &[entry(2)]);
let (a_send, b_recv) = tokio::io::duplex(1 << 16);
let (b_send, a_recv) = tokio::io::duplex(1 << 16);
let (ra, rb) =
tokio::join!(run_session(&mut a, a_send, a_recv), run_session(&mut b, b_send, b_recv),);
assert!(matches!(ra, Err(SessionError::Protocol(_))));
assert!(matches!(rb, Err(SessionError::Protocol(_))));
}
}