Skip to main content

rag_rat_sync/
session.rs

1//! The symmetric sync session (phase D, #406).
2//!
3//! One protocol for both roles. Each peer sends [`Frame::Hello`] with the account it is syncing and
4//! every account-log entry hash it holds, then streams the entries the other lacks and ends with
5//! [`Frame::Done`]. The two directions run concurrently over one bidirectional stream, so a large
6//! transfer in one direction never blocks the other (the deadlock a send-then-receive ordering
7//! would cause on a bounded stream).
8//!
9//! The session is transport-agnostic — generic over any [`AsyncRead`]/[`AsyncWrite`] pair — and
10//! trusts nothing it receives: every entry is handed to [`SyncStore::ingest`], which re-verifies it
11//! from scratch. It is deliberately NOT `Send`-bound: [`SyncStore`] wraps a SQLite connection, so a
12//! caller runs one session at a time on a single task (concurrent sessions are a later slice).
13
14use std::collections::HashSet;
15use std::time::Duration;
16
17use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
18
19use crate::codec::{self, CodecError};
20use crate::wire::{Frame, MAX_ENTRIES_PER_PAGE, MAX_HELLO_HASHES};
21
22type Hash = [u8; 32];
23
24/// How long the receiver waits for the peer's next frame before aborting the session as idle. A
25/// peer that connects and never sends, or stalls mid-stream, would otherwise hold the (single-
26/// session) server forever, blocking every later peer. Generous — a slow but progressing transfer
27/// resets it on each frame — while still bounding a silent connection.
28pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
29
30/// The most entries one session will accept from a peer before aborting. A legitimate transfer for
31/// one account is bounded by that account's stored + parked capacity (a few thousand); this cap is
32/// far above that so an honest sync never hits it, while still bounding a peer that streams
33/// redelivered or junk entries forever. Paired with the empty-page rejection below, it turns "the
34/// receive loop runs until Done" into a bounded transfer, not an open-ended one a peer can hold
35/// open (#406: bounded frames, no amplification).
36pub const MAX_SESSION_ENTRIES: usize = 1_000_000;
37
38/// Cap the outgoing hello inventory to what the wire allows the peer to decode.
39///
40/// Advertising a SUBSET of what we hold is always correct, only ever less efficient: the peer sends
41/// every entry it has that is not in the advertised set — which, past the cap, includes some
42/// entries we already hold, and re-ingesting a held entry is an idempotent no-op. So an account
43/// with more than [`MAX_HELLO_HASHES`] entries still converges to the union; it just pays some
44/// redundant transfer. This deliberately avoids a "remainder reconcile" protocol: correctness does
45/// not need one, and the accounts D targets stay well under the cap regardless.
46fn bounded_inventory(hashes: impl Iterator<Item = Hash>) -> Vec<Hash> {
47    hashes.take(MAX_HELLO_HASHES).collect()
48}
49
50/// The store side of a session: what a peer offers and where received entries land. Implemented
51/// over the op log for production and over an in-memory map for tests.
52pub trait SyncStore {
53    /// The account this session is scoped to. A peer whose hello names a different account is a
54    /// misdirected connection and the session aborts.
55    fn account_id(&self) -> Hash;
56
57    /// Every held account-log entry as `(dedup_key, signed_bytes)`, read ONCE at session start. The
58    /// key is the SIGNED-envelope hash (`sha256(signed_bytes)`), NOT the entry_hash — two envelopes
59    /// can share an entry_hash but differ in signature, and the wire must treat them as distinct or
60    /// a peer holding one would suppress the other. Snapshotting up front keeps what we send
61    /// independent of what we concurrently ingest, so the two session halves never contend.
62    fn snapshot(&self) -> anyhow::Result<Vec<(Hash, Vec<u8>)>>;
63
64    /// Ingest one received entry's `signed_bytes`. Must be idempotent (re-ingesting a held entry is
65    /// a no-op) and must re-verify — the bytes came off the wire from an untrusted peer.
66    fn ingest(&mut self, signed_bytes: &[u8]) -> anyhow::Result<Ingested>;
67}
68
69/// Whether an ingested entry was newly stored, so a session can report real transfer versus
70/// redelivery without the store leaking its verdict taxonomy.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum Ingested {
73    /// The entry was accepted into the store (or durably parked pending its signer).
74    Stored,
75    /// Already held, or refused by verification — either way nothing new landed.
76    NoChange,
77}
78
79/// What one session moved. Symmetric: each peer both sends and receives.
80#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
81pub struct SessionReport {
82    pub entries_sent: usize,
83    pub entries_received: usize,
84    pub entries_newly_stored: usize,
85}
86
87/// A session that could not complete.
88#[derive(Debug)]
89pub enum SessionError {
90    /// The transport failed or the peer sent an unreadable frame.
91    Codec(CodecError),
92    /// The peer opened with something other than a hello, or named a different account.
93    Protocol(String),
94    /// Reading the local entry snapshot failed.
95    Store(anyhow::Error),
96}
97
98impl std::fmt::Display for SessionError {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        match self {
101            SessionError::Codec(e) => write!(f, "sync session transport: {e}"),
102            SessionError::Protocol(m) => write!(f, "sync session protocol violation: {m}"),
103            SessionError::Store(e) => write!(f, "sync session store: {e}"),
104        }
105    }
106}
107
108impl std::error::Error for SessionError {}
109
110/// Run one session to completion over `send`/`recv`, syncing account entries with the peer.
111///
112/// Both halves run under `join!` on the current task — no spawn, so `store` (and its SQLite
113/// connection) need not be `Send`. The sender owns an up-front snapshot of local entries; the
114/// receiver holds `&mut store` to ingest. Because the sender reads only the snapshot, the two never
115/// alias the store.
116pub async fn run_session<S, R, W>(
117    store: &mut S,
118    send: W,
119    recv: R,
120) -> Result<SessionReport, SessionError>
121where
122    S: SyncStore,
123    R: AsyncRead + Unpin,
124    W: AsyncWrite + Unpin,
125{
126    run_session_with_idle_timeout(store, send, recv, DEFAULT_IDLE_TIMEOUT).await
127}
128
129/// [`run_session`] with an explicit idle timeout — the receiver aborts if the peer sends no frame
130/// within `idle_timeout`. Exposed so tests can exercise the timeout without waiting the default.
131pub async fn run_session_with_idle_timeout<S, R, W>(
132    store: &mut S,
133    mut send: W,
134    mut recv: R,
135    idle_timeout: Duration,
136) -> Result<SessionReport, SessionError>
137where
138    S: SyncStore,
139    R: AsyncRead + Unpin,
140    W: AsyncWrite + Unpin,
141{
142    let account_id = store.account_id();
143    let snapshot = store.snapshot().map_err(SessionError::Store)?;
144    let have = bounded_inventory(snapshot.iter().map(|(h, _)| *h));
145
146    // Channel the peer's inventory from the receiver (which parses the peer hello) to the sender
147    // (which needs it to decide what to stream). A oneshot: exactly one hello per session.
148    let (peer_have_tx, peer_have_rx) = tokio::sync::oneshot::channel::<HashSet<Hash>>();
149
150    let sender = async move {
151        codec::write_frame(&mut send, &Frame::Hello { account_id, have })
152            .await
153            .map_err(SessionError::Codec)?;
154        // If the receiver aborted before delivering the peer hello, there is nothing to stream.
155        let Ok(peer_have) = peer_have_rx.await else {
156            return Ok(0usize);
157        };
158        let mut to_send: Vec<Vec<u8>> = snapshot
159            .into_iter()
160            .filter(|(hash, _)| !peer_have.contains(hash))
161            .map(|(_, bytes)| bytes)
162            .collect();
163        let total = to_send.len();
164        // Drain into fixed pages so no single frame exceeds the per-page cap.
165        let mut rest = to_send.split_off(0);
166        while !rest.is_empty() {
167            let tail = rest.split_off(rest.len().min(MAX_ENTRIES_PER_PAGE));
168            let page = std::mem::replace(&mut rest, tail);
169            let more = !rest.is_empty();
170            codec::write_frame(&mut send, &Frame::Entries { entries: page, more })
171                .await
172                .map_err(SessionError::Codec)?;
173        }
174        codec::write_frame(&mut send, &Frame::Done).await.map_err(SessionError::Codec)?;
175        // Cleanly finish the send half. On an iroh stream `shutdown` maps to quinn `finish` (a FIN
176        // the peer sees after the last frame); dropping without it would RESET and could truncate
177        // the final page. On a duplex it just closes the write half → clean EOF for the peer.
178        send.shutdown().await.map_err(|e| SessionError::Codec(CodecError::Io(e)))?;
179        Ok::<usize, SessionError>(total)
180    };
181
182    let receiver = async {
183        // The peer must open with a hello for the account we are syncing.
184        let hello = read_frame_before(&mut recv, idle_timeout).await?;
185        let Frame::Hello { account_id: peer_account, have: peer_have } = hello else {
186            return Err(SessionError::Protocol("peer did not open with a hello".into()));
187        };
188        if peer_account != account_id {
189            return Err(SessionError::Protocol(
190                "peer hello names a different account than this session".into(),
191            ));
192        }
193        // Hand the peer's inventory to the sender; if it already gave up, we still drain the
194        // stream.
195        let _ = peer_have_tx.send(peer_have.into_iter().collect());
196
197        let mut received = 0usize;
198        let mut newly_stored = 0usize;
199        // Page sequencing: a peer streams zero or more `Entries` pages, the last with `more:
200        // false`, then `Done`. `saw_page` records that at least one page arrived;
201        // `saw_final` that a `more: false` page marked the stream complete. Together they
202        // reject both a `Done` after a page that declared `more: true` (truncation) and any
203        // page sent AFTER the final one.
204        let mut saw_page = false;
205        let mut saw_final = false;
206        loop {
207            match read_frame_before(&mut recv, idle_timeout).await {
208                Ok(Frame::Entries { entries, more }) => {
209                    // A page after the one that declared `more: false` contradicts the sequencing —
210                    // the peer said the previous page was the last.
211                    if saw_final {
212                        return Err(SessionError::Protocol(
213                            "peer sent an Entries page after the final page".into(),
214                        ));
215                    }
216                    // An empty page is never sent by an honest peer (nothing to say → Done). It is
217                    // the shape a flood uses to hold the session open with `more: true` forever, so
218                    // reject it outright.
219                    if entries.is_empty() {
220                        return Err(SessionError::Protocol(
221                            "peer sent an empty Entries page".into(),
222                        ));
223                    }
224                    for bytes in entries {
225                        received += 1;
226                        if received > MAX_SESSION_ENTRIES {
227                            return Err(SessionError::Protocol(format!(
228                                "peer streamed more than {MAX_SESSION_ENTRIES} entries",
229                            )));
230                        }
231                        match store.ingest(&bytes).map_err(SessionError::Store)? {
232                            Ingested::Stored => newly_stored += 1,
233                            Ingested::NoChange => {},
234                        }
235                    }
236                    saw_page = true;
237                    saw_final = !more;
238                },
239                Ok(Frame::Done) => {
240                    if saw_page && !saw_final {
241                        return Err(SessionError::Protocol(
242                            "peer sent Done after declaring more pages would follow".into(),
243                        ));
244                    }
245                    break;
246                },
247                Ok(Frame::Hello { .. }) => {
248                    return Err(SessionError::Protocol("a second hello mid-session".into()));
249                },
250                Ok(Frame::Auth { .. }) => {
251                    // Auth belongs to the handshake the endpoint runs BEFORE `run_session`; an Auth
252                    // frame in the data phase is out of sequence.
253                    return Err(SessionError::Protocol("an auth frame mid-session".into()));
254                },
255                // `read_frame_before` has already mapped EOF (truncated transfer) and idle timeout
256                // into a `SessionError`, so any error here just propagates.
257                Err(e) => return Err(e),
258            }
259        }
260        Ok::<(usize, usize), SessionError>((received, newly_stored))
261    };
262
263    // `try_join!`, not `join!`: if either half errors, the other is cancelled immediately. Without
264    // it, a peer that sends a bad frame and stops reading would leave the sender blocked on QUIC
265    // flow control mid-stream, and the session would hang instead of failing.
266    let (entries_sent, (entries_received, entries_newly_stored)) =
267        tokio::try_join!(sender, receiver)?;
268    Ok(SessionReport { entries_sent, entries_received, entries_newly_stored })
269}
270
271/// Read the next frame, failing if the peer sends nothing within `idle_timeout`. Folds a clean EOF
272/// and an idle timeout into a `SessionError` — the caller propagates either as a session failure,
273/// so a stalled or silent peer cannot hold the (single-session) server open indefinitely.
274async fn read_frame_before<R: AsyncRead + Unpin>(
275    recv: &mut R,
276    idle_timeout: Duration,
277) -> Result<Frame, SessionError> {
278    match tokio::time::timeout(idle_timeout, codec::read_frame(recv)).await {
279        Ok(Ok(frame)) => Ok(frame),
280        Ok(Err(CodecError::Eof)) => Err(SessionError::Protocol(
281            "peer closed the stream before sending Done — transfer truncated".into(),
282        )),
283        Ok(Err(e)) => Err(SessionError::Codec(e)),
284        Err(_elapsed) => Err(SessionError::Protocol(format!(
285            "peer sent no frame within {idle_timeout:?} — session aborted as idle"
286        ))),
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use std::collections::HashMap;
293
294    use super::*;
295
296    /// An in-memory store: entries are `(hash, bytes)`, ingest inserts if absent. Enough to
297    /// exercise the protocol without a database — the DB-backed store has its own integration
298    /// test.
299    struct MemStore {
300        account: Hash,
301        entries: HashMap<Hash, Vec<u8>>,
302    }
303
304    impl MemStore {
305        fn new(account: Hash, entries: &[(Hash, Vec<u8>)]) -> Self {
306            Self { account, entries: entries.iter().cloned().collect() }
307        }
308    }
309
310    impl SyncStore for MemStore {
311        fn account_id(&self) -> Hash {
312            self.account
313        }
314        fn snapshot(&self) -> anyhow::Result<Vec<(Hash, Vec<u8>)>> {
315            let mut v: Vec<_> = self.entries.iter().map(|(h, b)| (*h, b.clone())).collect();
316            v.sort_by_key(|(h, _)| *h);
317            Ok(v)
318        }
319        fn ingest(&mut self, signed_bytes: &[u8]) -> anyhow::Result<Ingested> {
320            // The test's "hash" is the first 32 bytes of the payload it authored below.
321            let hash: Hash = signed_bytes[..32].try_into().unwrap();
322            match self.entries.entry(hash) {
323                std::collections::hash_map::Entry::Occupied(_) => Ok(Ingested::NoChange),
324                std::collections::hash_map::Entry::Vacant(slot) => {
325                    slot.insert(signed_bytes.to_vec());
326                    Ok(Ingested::Stored)
327                },
328            }
329        }
330    }
331
332    fn entry(seed: u8) -> (Hash, Vec<u8>) {
333        let mut bytes = vec![seed; 40];
334        bytes[..32].copy_from_slice(&[seed; 32]);
335        ([seed; 32], bytes)
336    }
337
338    async fn sync_pair(a: &mut MemStore, b: &mut MemStore) -> (SessionReport, SessionReport) {
339        let (a_send, b_recv) = tokio::io::duplex(1 << 20);
340        let (b_send, a_recv) = tokio::io::duplex(1 << 20);
341        let (ra, rb) =
342            tokio::join!(run_session(a, a_send, a_recv), run_session(b, b_send, b_recv),);
343        (ra.unwrap(), rb.unwrap())
344    }
345
346    #[tokio::test]
347    async fn a_peer_with_nothing_restores_the_full_set_from_the_other() {
348        let full: Vec<_> = (0u8..5).map(entry).collect();
349        let mut a = MemStore::new([0xac; 32], &full);
350        let mut b = MemStore::new([0xac; 32], &[]);
351        let (ra, rb) = sync_pair(&mut a, &mut b).await;
352
353        assert_eq!(ra.entries_sent, 5, "the full peer sends all five");
354        assert_eq!(rb.entries_newly_stored, 5, "the empty peer stores all five");
355        assert_eq!(a.entries.len(), 5, "the full peer is unchanged");
356        assert_eq!(b.entries.len(), 5, "the empty peer is now complete");
357        assert_eq!(a.entries, b.entries, "both hold the same set — restore-from-peer");
358    }
359
360    #[tokio::test]
361    async fn disjoint_peers_converge_to_the_union_both_directions() {
362        let mut a = MemStore::new([1; 32], &[entry(1), entry(2), entry(3)]);
363        let mut b = MemStore::new([1; 32], &[entry(3), entry(4), entry(5)]);
364        let (ra, rb) = sync_pair(&mut a, &mut b).await;
365
366        // Each sends only what the other lacks; the shared entry(3) is sent by neither... actually
367        // both send their non-shared entries. a lacks 4,5; b lacks 1,2.
368        assert_eq!(rb.entries_newly_stored, 2, "b gains 1 and 2");
369        assert_eq!(ra.entries_newly_stored, 2, "a gains 4 and 5");
370        let union: HashSet<Hash> = (1u8..=5).map(|s| [s; 32]).collect();
371        assert_eq!(a.entries.keys().copied().collect::<HashSet<_>>(), union);
372        assert_eq!(b.entries.keys().copied().collect::<HashSet<_>>(), union);
373    }
374
375    #[tokio::test]
376    async fn already_in_sync_transfers_nothing() {
377        let same: Vec<_> = (10u8..13).map(entry).collect();
378        let mut a = MemStore::new([2; 32], &same);
379        let mut b = MemStore::new([2; 32], &same);
380        let (ra, rb) = sync_pair(&mut a, &mut b).await;
381        assert_eq!(ra.entries_sent, 0);
382        assert_eq!(rb.entries_sent, 0);
383        assert_eq!(ra.entries_newly_stored, 0);
384        assert_eq!(rb.entries_newly_stored, 0);
385    }
386
387    /// A stream that ends after a `more: true` page — a truncated transfer — must FAIL, not report
388    /// success, or the caller would treat a partial account as complete.
389    #[tokio::test]
390    async fn a_truncated_transfer_fails_rather_than_reporting_success() {
391        use crate::codec::write_frame;
392        let mut receiver = MemStore::new([5; 32], &[]);
393        // Feed the receiver a hello then one page claiming more follows, then close abruptly.
394        let (mut peer_send, recv) = tokio::io::duplex(1 << 16);
395        let (send, _peer_recv) = tokio::io::duplex(1 << 16);
396        let feeder = tokio::spawn(async move {
397            write_frame(&mut peer_send, &Frame::Hello { account_id: [5; 32], have: vec![] })
398                .await
399                .unwrap();
400            write_frame(&mut peer_send, &Frame::Entries {
401                entries: vec![entry(7).1],
402                more: true, // a page CLAIMING more will follow …
403            })
404            .await
405            .unwrap();
406            // … then drop without Done: a truncated stream.
407        });
408        let result = run_session(&mut receiver, send, recv).await;
409        feeder.await.unwrap();
410        assert!(
411            matches!(result, Err(SessionError::Protocol(_))),
412            "EOF before Done is a truncated transfer, not success: {result:?}",
413        );
414    }
415
416    /// A peer that sends `Done` right after a `more: true` page declared an incomplete transfer
417    /// and then stopped — the receiver must reject it, not report success.
418    #[tokio::test]
419    async fn done_after_a_more_true_page_is_rejected() {
420        use crate::codec::write_frame;
421        let mut receiver = MemStore::new([6; 32], &[]);
422        let (mut peer_send, recv) = tokio::io::duplex(1 << 16);
423        let (send, _peer_recv) = tokio::io::duplex(1 << 16);
424        let feeder = tokio::spawn(async move {
425            write_frame(&mut peer_send, &Frame::Hello { account_id: [6; 32], have: vec![] })
426                .await
427                .unwrap();
428            write_frame(&mut peer_send, &Frame::Entries { entries: vec![entry(1).1], more: true })
429                .await
430                .unwrap();
431            write_frame(&mut peer_send, &Frame::Done).await.unwrap();
432        });
433        let result = run_session(&mut receiver, send, recv).await;
434        feeder.await.unwrap();
435        assert!(
436            matches!(result, Err(SessionError::Protocol(_))),
437            "Done after more:true is a declared-incomplete transfer: {result:?}",
438        );
439    }
440
441    /// An empty Entries page is the shape a flood uses to keep a session open forever; the receiver
442    /// rejects it rather than looping.
443    /// A peer that connects, sends a valid hello, then goes silent must not hold the session open:
444    /// the receiver aborts after the idle timeout. Uses a tiny timeout so the test is fast.
445    #[tokio::test]
446    async fn a_silent_peer_times_out() {
447        use crate::codec::write_frame;
448        let mut receiver = MemStore::new([11; 32], &[]);
449        // The peer sends a hello then never sends again and keeps the stream OPEN (holds
450        // `peer_send` for the whole test rather than dropping it, so there is no EOF — only
451        // silence).
452        let (mut peer_send, recv) = tokio::io::duplex(1 << 16);
453        let (send, _peer_recv) = tokio::io::duplex(1 << 16);
454        write_frame(&mut peer_send, &Frame::Hello { account_id: [11; 32], have: vec![] })
455            .await
456            .unwrap();
457        let result = run_session_with_idle_timeout(
458            &mut receiver,
459            send,
460            recv,
461            std::time::Duration::from_millis(50),
462        )
463        .await;
464        drop(peer_send); // keep the stream alive until after the timeout fired
465        match result {
466            Err(SessionError::Protocol(m)) => assert!(m.contains("idle"), "{m}"),
467            other => panic!("expected an idle-timeout abort: {other:?}"),
468        }
469    }
470
471    /// A page after the one that declared `more: false` contradicts the sequencing and is rejected.
472    #[tokio::test]
473    async fn a_page_after_the_final_page_is_rejected() {
474        use crate::codec::write_frame;
475        let mut receiver = MemStore::new([12; 32], &[]);
476        let (mut peer_send, recv) = tokio::io::duplex(1 << 16);
477        let (send, _peer_recv) = tokio::io::duplex(1 << 16);
478        let feeder = tokio::spawn(async move {
479            write_frame(&mut peer_send, &Frame::Hello { account_id: [12; 32], have: vec![] })
480                .await
481                .unwrap();
482            write_frame(&mut peer_send, &Frame::Entries { entries: vec![entry(1).1], more: false })
483                .await
484                .unwrap();
485            // A page after the final one contradicts `more: false`.
486            write_frame(&mut peer_send, &Frame::Entries { entries: vec![entry(2).1], more: false })
487                .await
488                .unwrap();
489        });
490        let result = run_session(&mut receiver, send, recv).await;
491        feeder.await.unwrap();
492        match result {
493            Err(SessionError::Protocol(m)) => assert!(m.contains("after the final page"), "{m}"),
494            other => panic!("expected the after-final-page guard: {other:?}"),
495        }
496    }
497
498    #[tokio::test]
499    async fn an_empty_entries_page_is_rejected() {
500        use crate::codec::write_frame;
501        let mut receiver = MemStore::new([8; 32], &[]);
502        let (mut peer_send, recv) = tokio::io::duplex(1 << 16);
503        let (send, _peer_recv) = tokio::io::duplex(1 << 16);
504        let feeder = tokio::spawn(async move {
505            write_frame(&mut peer_send, &Frame::Hello { account_id: [8; 32], have: vec![] })
506                .await
507                .unwrap();
508            write_frame(&mut peer_send, &Frame::Entries { entries: vec![], more: true })
509                .await
510                .unwrap();
511        });
512        let result = run_session(&mut receiver, send, recv).await;
513        feeder.await.unwrap();
514        // Assert the SPECIFIC guard fired — a dropped feeder also trips the EOF-before-Done guard,
515        // so a bare `Protocol` match would not distinguish the empty-page rejection from it.
516        match result {
517            Err(SessionError::Protocol(m)) => assert!(m.contains("empty Entries page"), "{m}"),
518            other => panic!("expected the empty-page guard: {other:?}"),
519        }
520    }
521
522    #[test]
523    fn the_outgoing_inventory_is_capped_to_the_wire_limit() {
524        let over = MAX_HELLO_HASHES + 100;
525        let hashes = (0..over).map(|i| {
526            let mut h = [0u8; 32];
527            h[..8].copy_from_slice(&(i as u64).to_be_bytes());
528            h
529        });
530        let bounded = bounded_inventory(hashes);
531        assert_eq!(bounded.len(), MAX_HELLO_HASHES, "never advertises more than the peer decodes");
532        // And the frame it produces is decodable (would be rejected as over-cap otherwise).
533        let frame = Frame::Hello { account_id: [0; 32], have: bounded };
534        assert!(Frame::decode(&frame.encode()).is_ok());
535    }
536
537    #[tokio::test]
538    async fn a_mismatched_account_aborts_the_session() {
539        let mut a = MemStore::new([1; 32], &[entry(1)]);
540        let mut b = MemStore::new([2; 32], &[entry(2)]);
541        let (a_send, b_recv) = tokio::io::duplex(1 << 16);
542        let (b_send, a_recv) = tokio::io::duplex(1 << 16);
543        let (ra, rb) =
544            tokio::join!(run_session(&mut a, a_send, a_recv), run_session(&mut b, b_send, b_recv),);
545        assert!(matches!(ra, Err(SessionError::Protocol(_))));
546        assert!(matches!(rb, Err(SessionError::Protocol(_))));
547    }
548}