eidetica/testing.rs
1//! In-process multi-instance test harness (Tier 0).
2//!
3//! Standing up several real `Instance`s that sync with one another takes a pile
4//! of identical boilerplate: create a user and key, enable sync, register a
5//! transport, start serving, resolve the bound address. [`Cluster`] does *that*
6//! plumbing and nothing else — it hands back wired peers and leaves every policy
7//! decision (auth, what to write, how to drive sync) to the test.
8//!
9//! That split is deliberate. A correctness harness for authenticated CRDT sync
10//! must keep the two things it exists to exercise — the **transport** and the
11//! **auth** — under the test's control, not baked into the setup:
12//!
13//! - **Transport is a seam.** [`ClusterBuilder::transport`] takes any
14//! [`TestTransport`]; the default is [`HttpLoopback`]. A controllable in-memory
15//! transport (deliver / reorder / drop / single-step — Tier 1) is a drop-in
16//! here, which is the point: Tier 1 extends this, it doesn't replace it.
17//! - **Auth is the test's.** The harness never grants keys or permissions for
18//! you. A peer exposes its `User`, key id, and key name; the test creates its
19//! database with whatever auth posture it's exercising. [`add_auth_keys`] and
20//! [`set_global_auth_key`] are policy-neutral *tools* the test composes — they
21//! apply the keys you pass, they don't choose them.
22//!
23//! What the harness owns is plumbing only: wiring peers, marking a tree
24//! sync-enabled ([`Peer::serve`]), driving an exchange ([`Cluster::exchange`]),
25//! and observing convergence ([`Cluster::converged`]). It does not hold your
26//! databases — the test opens and keeps those itself.
27//!
28//! This is **topology A** (multi-peer sync): N independent `Instance`s, each
29//! owning an in-memory backend. Sync is driven explicitly (no background timers),
30//! so a test fully orders the exchange. The multi-client / single-service
31//! topology is a separate harness that lands with the `service` feature.
32//!
33//! Gated behind `cfg(any(test, feature = "testing"))` alongside [`FixedClock`]
34//! and [`Instance::create_backend_with_clock`]; never compiled into a release build.
35//!
36//! ```no_run
37//! # async fn ex() -> eidetica::Result<()> {
38//! use eidetica::{
39//! auth::{Permission, types::AuthKey},
40//! crdt::Doc,
41//! testing::{Cluster, set_global_auth_key},
42//! user::types::SyncSettings,
43//! };
44//!
45//! let mut net = Cluster::builder().peers(2).build().await?;
46//!
47//! // Peer 0 creates a database with auth the *test* chooses, then serves it.
48//! let key0 = net.peer(0).key_id().clone();
49//! let mut settings = Doc::new();
50//! settings.set("name", "chat");
51//! let db = net.peer_mut(0).user_mut().create_database(settings, &key0).await?;
52//! let room = db.root_id().clone();
53//! set_global_auth_key(&db, AuthKey::active(None, Permission::Write(10))).await?;
54//! net.peer_mut(0).serve(&room).await?;
55//!
56//! // Peer 1 bootstraps with its own key, then converges against peer 0.
57//! let key1 = net.peer(1).key_id().clone();
58//! let signing_key1 = net.peer(1).user().get_signing_key(&key1)?;
59//! let name1 = net.peer(1).key_name().to_string();
60//! let addr0 = net.peer(0).address().clone();
61//! net.peer(1)
62//! .sync()
63//! .sync_with_peer_for_bootstrap_with_key(
64//! &addr0,
65//! &room,
66//! &signing_key1,
67//! &name1,
68//! Permission::Write(10),
69//! )
70//! .await?;
71//! net.peer_mut(1)
72//! .user_mut()
73//! .track_database(room.clone(), &key1, SyncSettings::disabled())
74//! .await?;
75//!
76//! net.exchange(1, 0, &room).await?;
77//! assert!(net.converged(&[0, 1], &room).await?);
78//! # Ok(()) }
79//! ```
80
81use std::sync::Arc;
82
83use async_trait::async_trait;
84
85use crate::{
86 Database, Entry, Instance, NewUser, Result, Snapshot,
87 auth::{Permission, crypto::PublicKey, types::AuthKey},
88 backend::{BackendImpl, VerificationStatus, database::InMemory},
89 clock::{Clock, FixedClock},
90 crdt::Doc,
91 entry::ID,
92 sync::{
93 Address, Sync,
94 error::SyncError,
95 handler::SyncHandler,
96 protocol::{RequestContext, SyncRequest, SyncResponse},
97 transports::{SyncTransport, TransportBuilder, http::HttpTransport},
98 },
99 user::{User, types::SyncSettings},
100};
101
102/// Display name given to every peer's signing key. Exposed per peer via
103/// [`Peer::key_name`] so a test can name it in a bootstrap request.
104const KEY_NAME: &str = "test-key";
105
106/// How a peer makes itself reachable to other peers. The one seam Tier 1 swaps:
107/// implement this over an in-memory, controllable network and the rest of the
108/// harness is unchanged.
109#[async_trait]
110pub trait TestTransport: Send + std::marker::Sync {
111 /// Register this transport on `sync`, start serving, and return the address
112 /// other peers use to reach it. Called once per peer at build time.
113 async fn serve(&self, sync: &Sync) -> Result<Address>;
114}
115
116/// Default [`TestTransport`]: HTTP over an OS-assigned loopback port.
117#[derive(Debug, Default, Clone)]
118pub struct HttpLoopback;
119
120#[async_trait]
121impl TestTransport for HttpLoopback {
122 async fn serve(&self, sync: &Sync) -> Result<Address> {
123 sync.register_transport("http", HttpTransport::builder().bind("127.0.0.1:0"))
124 .await?;
125 sync.accept_connections().await?;
126 Ok(Address::http(sync.get_server_address().await?))
127 }
128}
129
130/// Builder for a [`Cluster`]. Obtain via [`Cluster::builder`].
131pub struct ClusterBuilder {
132 peers: usize,
133 clock: Option<Arc<dyn Clock>>,
134 transport: Arc<dyn TestTransport>,
135}
136
137impl ClusterBuilder {
138 /// Number of peers (independent `Instance`s) to start. Defaults to 2.
139 pub fn peers(mut self, n: usize) -> Self {
140 self.peers = n;
141 self
142 }
143
144 /// Share a single clock across every peer (e.g. a [`FixedClock`] the test
145 /// drives by hand). When unset, each peer gets its own fresh `FixedClock`,
146 /// mirroring the standard `test_instance()` setup.
147 pub fn clock(mut self, clock: Arc<dyn Clock>) -> Self {
148 self.clock = Some(clock);
149 self
150 }
151
152 /// Use a custom [`TestTransport`] for every peer. Defaults to [`HttpLoopback`].
153 pub fn transport(mut self, transport: Arc<dyn TestTransport>) -> Self {
154 self.transport = transport;
155 self
156 }
157
158 /// Build the cluster: for each peer, open an instance, create a user and
159 /// signing key, enable sync, and serve over the configured transport.
160 pub async fn build(self) -> Result<Cluster> {
161 let mut peers = Vec::with_capacity(self.peers);
162 for i in 0..self.peers {
163 let clock: Arc<dyn Clock> = match &self.clock {
164 Some(shared) => shared.clone(),
165 None => Arc::new(FixedClock::default()),
166 };
167 // Create the backend and bootstrap its admin user in one step; each
168 // peer is a fresh in-memory instance, so its only user is this one.
169 let (instance, mut user) = Instance::create_backend_with_clock(
170 Box::new(InMemory::new()),
171 clock,
172 NewUser::passwordless(format!("peer{i}")),
173 )
174 .await?;
175 let key_id = user.add_private_key(Some(KEY_NAME)).await?;
176
177 instance.enable_sync().await?;
178 let sync = instance
179 .sync()
180 .expect("sync handle present immediately after enable_sync");
181 let address = self.transport.serve(&sync).await?;
182
183 peers.push(Peer {
184 instance,
185 user,
186 key_id,
187 sync,
188 address,
189 });
190 }
191 Ok(Cluster { peers })
192 }
193}
194
195/// A set of in-process eidetica peers wired for multi-peer sync. Each peer is a
196/// full `Instance` with its own backend. The cluster owns the wiring; the test
197/// owns the databases, the auth, and the order of operations.
198pub struct Cluster {
199 peers: Vec<Peer>,
200}
201
202impl Cluster {
203 /// Start building a cluster. Defaults: 2 peers, per-peer `FixedClock`,
204 /// [`HttpLoopback`] transport.
205 pub fn builder() -> ClusterBuilder {
206 ClusterBuilder {
207 peers: 2,
208 clock: None,
209 transport: Arc::new(HttpLoopback),
210 }
211 }
212
213 /// Number of peers in the cluster.
214 pub fn len(&self) -> usize {
215 self.peers.len()
216 }
217
218 /// Whether the cluster has no peers.
219 pub fn is_empty(&self) -> bool {
220 self.peers.is_empty()
221 }
222
223 /// Shared access to a peer (instance, user, key, address).
224 pub fn peer(&self, i: usize) -> &Peer {
225 &self.peers[i]
226 }
227
228 /// Mutable access to a peer, for `create_database` / `open_database` on its
229 /// `User`.
230 pub fn peer_mut(&mut self, i: usize) -> &mut Peer {
231 &mut self.peers[i]
232 }
233
234 /// Have peer `to` bootstrap `tree` from peer `from`: request the tree with
235 /// `to`'s own key (asking for `permission`), flush, and track it locally
236 /// (sync disabled — the test turns on [`serve`]/[`auto_sync`] if it wants
237 /// more). The joining-peer dance as one call.
238 ///
239 /// `permission` is the access level `to` requests; the harness does not pick
240 /// it — auth posture stays the test's. `from` must already be serving `tree`
241 /// (see [`Peer::serve`]) with a policy that admits this request.
242 ///
243 /// [`serve`]: Peer::serve
244 /// [`auto_sync`]: Cluster::auto_sync
245 pub async fn bootstrap(
246 &mut self,
247 from: usize,
248 to: usize,
249 tree: &ID,
250 permission: Permission,
251 ) -> Result<()> {
252 let from_addr = self.peers[from].address.clone();
253 let to_key = self.peers[to].key_id.clone();
254 let to_signing_key = self.peers[to]
255 .user
256 .get_signing_key(&to_key)
257 .expect("peer key must be unlocked");
258 self.peers[to]
259 .sync
260 .sync_with_peer_for_bootstrap_with_key(
261 &from_addr,
262 tree,
263 &to_signing_key,
264 KEY_NAME,
265 permission,
266 )
267 .await?;
268 self.peers[to].sync.flush().await?;
269 self.peers[to]
270 .user
271 .track_database(tree.clone(), &to_key, SyncSettings::disabled())
272 .await?;
273 Ok(())
274 }
275
276 /// Drive a sync exchange for `tree`, initiated by peer `from` against peer
277 /// `to`. eidetica's `sync_with_peer` exchanges in *both* directions, so after
278 /// this both peers hold each other's entries for the tree. Both sync queues
279 /// are flushed; flush errors propagate (this is a bug-finding tool — it does
280 /// not swallow them).
281 pub async fn exchange(&self, from: usize, to: usize, tree: &ID) -> Result<()> {
282 let to_addr = self.peers[to].address.clone();
283 self.peers[from]
284 .sync
285 .sync_with_peer(&to_addr, Some(tree))
286 .await?;
287 self.peers[from].sync.flush().await?;
288 self.peers[to].sync.flush().await?;
289 Ok(())
290 }
291
292 /// Turn on **background, automatic** sync of `tree` between peers `a` and `b`,
293 /// in both directions. After this a commit on either peer is queued for the
294 /// other automatically (sync-on-commit) — no per-write [`exchange`] call. Use
295 /// [`flush`] to push the queue immediately, or let the background interval
296 /// carry it.
297 ///
298 /// Both peers must already hold `tree` (e.g. one [`Peer::serve`]d it and the
299 /// other bootstrapped it). This wires the peer relationship both ways
300 /// (register peer + dial-back address + per-tree sync target) and re-tracks
301 /// the tree as `on_commit` on each side.
302 ///
303 /// [`exchange`]: Cluster::exchange
304 /// [`flush`]: Cluster::flush
305 pub async fn auto_sync(&mut self, a: usize, b: usize, tree: &ID) -> Result<()> {
306 let a_pub = self.peers[a].sync.get_device_pubkey()?;
307 let b_pub = self.peers[b].sync.get_device_pubkey()?;
308 let a_addr = self.peers[a].address.clone();
309 let b_addr = self.peers[b].address.clone();
310 let a_key = self.peers[a].key_id.clone();
311 let b_key = self.peers[b].key_id.clone();
312
313 // Each peer learns how to reach the other. A prior bootstrap may already
314 // have registered the peer, so registration is idempotent here.
315 register_peer_idempotent(&self.peers[a].sync, &b_pub).await?;
316 self.peers[a].sync.add_peer_address(&b_pub, b_addr).await?;
317 register_peer_idempotent(&self.peers[b].sync, &a_pub).await?;
318 self.peers[b].sync.add_peer_address(&a_pub, a_addr).await?;
319
320 // Each peer tracks the tree on-commit and targets the other for it.
321 self.peers[a]
322 .user
323 .track_database(tree.clone(), &a_key, SyncSettings::on_commit())
324 .await?;
325 self.peers[a].sync.add_tree_sync(&b_pub, tree).await?;
326 self.peers[b]
327 .user
328 .track_database(tree.clone(), &b_key, SyncSettings::on_commit())
329 .await?;
330 self.peers[b].sync.add_tree_sync(&a_pub, tree).await?;
331 Ok(())
332 }
333
334 /// [`auto_sync`] every peer pair in the cluster — a full mesh, so a commit on
335 /// any peer fans out to all the others. Both peers of every pair must already
336 /// hold `tree`. Does not flush; call [`flush_all`] to drain the setup pushes.
337 ///
338 /// [`auto_sync`]: Cluster::auto_sync
339 /// [`flush_all`]: Cluster::flush_all
340 pub async fn auto_sync_all(&mut self, tree: &ID) -> Result<()> {
341 let n = self.len();
342 for a in 0..n {
343 for b in (a + 1)..n {
344 self.auto_sync(a, b, tree).await?;
345 }
346 }
347 Ok(())
348 }
349
350 /// Push peer `peer`'s pending auto-sync queue to its targets now, instead of
351 /// waiting for the background interval. The deterministic barrier for
352 /// auto-sync tests: commit, `flush`, assert.
353 pub async fn flush(&self, peer: usize) -> Result<()> {
354 self.peers[peer].sync.flush().await
355 }
356
357 /// Drain the whole cluster: repeatedly [`flush`] every peer until all pending
358 /// auto-sync work has propagated everywhere, then settle.
359 ///
360 /// A single pass over the peers is **not** enough in general. `flush` visits
361 /// each peer once, in index order, so a pass advances an in-flight entry at
362 /// most one hop along its sync path (and only in the index direction — an
363 /// entry that must travel "backwards", from a higher-indexed peer to a lower
364 /// one, waits for the next pass). One pass suffices only when every peer
365 /// pushes directly to every other (a full mesh); a sparser topology — a relay
366 /// chain — needs up to one pass per hop. `len() + 1` passes covers the worst
367 /// case, since no propagation path through `len()` peers is longer than
368 /// `len() - 1` hops. This is the whole-cluster barrier: after it, every
369 /// deliverable entry has reached every peer.
370 ///
371 /// [`flush`]: Cluster::flush
372 pub async fn flush_all(&self) -> Result<()> {
373 for _ in 0..(self.len() + 1) {
374 for peer in 0..self.len() {
375 self.flush(peer).await?;
376 }
377 }
378 Ok(())
379 }
380
381 /// The [`Snapshot`] peer `peer` currently holds for `tree` — the canonical
382 /// (sorted, deduplicated) tip set identifying its state. [`Snapshot::EMPTY`]
383 /// if the peer has never seen the tree.
384 pub async fn snapshot(&self, peer: usize, tree: &ID) -> Result<Snapshot> {
385 self.peers[peer].instance.backend().snapshot(tree).await
386 }
387
388 /// True if the named `peers` all agree on `tree`'s [`Snapshot`] — the
389 /// convergence invariant. The caller names which peers should have converged;
390 /// a peer that never received the tree has an empty snapshot and will not
391 /// match. Comparison is `Snapshot` set-equality, so tip order never matters.
392 pub async fn converged(&self, peers: &[usize], tree: &ID) -> Result<bool> {
393 let mut reference: Option<Snapshot> = None;
394 for &i in peers {
395 let snapshot = self.snapshot(i, tree).await?;
396 match &reference {
397 None => reference = Some(snapshot),
398 Some(r) if *r != snapshot => return Ok(false),
399 Some(_) => {}
400 }
401 }
402 Ok(true)
403 }
404
405 /// Whether *every* peer agrees on `tree`'s tip set — the common convergence
406 /// check. Shorthand for [`converged`] over all peers; the explicit
407 /// `&[peers]` form stays for partition tests that expect only a subset to
408 /// agree.
409 ///
410 /// [`converged`]: Cluster::converged
411 pub async fn converged_all(&self, tree: &ID) -> Result<bool> {
412 let all: Vec<usize> = (0..self.peers.len()).collect();
413 self.converged(&all, tree).await
414 }
415
416 /// Drive bidirectional [`exchange`] across every peer pair, round after
417 /// round, until the whole cluster holds an identical tip set for `tree` —
418 /// then return `true`. Bounded to `peers` rounds (a complete graph converges
419 /// in one, the budget is slack for safety); returns the final convergence
420 /// status if the budget is spent without settling.
421 ///
422 /// Quiescent only: there must be no concurrent writes while this runs (it
423 /// has no way to observe them). Every peer must already hold and serve
424 /// `tree` so it can answer an exchange — `bootstrap` then [`Peer::serve`] on
425 /// each joiner. The fixpoint barrier the N-peer / partition-heal tests
426 /// assert against.
427 ///
428 /// [`exchange`]: Cluster::exchange
429 pub async fn converge(&self, tree: &ID) -> Result<bool> {
430 let n = self.peers.len();
431 for _ in 0..n.max(1) {
432 if self.converged_all(tree).await? {
433 return Ok(true);
434 }
435 for i in 0..n {
436 for j in (i + 1)..n {
437 self.exchange(i, j, tree).await?;
438 }
439 }
440 }
441 self.converged_all(tree).await
442 }
443
444 // ===== invariant assertions =====
445 //
446 // Tip-set equality ([`converged`]) proves two peers *agree*, but it is a weak
447 // invariant: it says nothing about *what* they agreed on. Two peers can share
448 // a tip set yet differ below it, or converge onto a state that quietly dropped
449 // a signed entry, or store a received entry as `Failed`. These walk the full
450 // entry set behind the tips and assert the properties tip equality misses.
451 // They panic (not return `false`) with a diagnostic — invariant violation is a
452 // test failure, and the message should name the offending peer and entry.
453
454 /// The concrete local backend engine for peer `peer`. `Cluster` peers always
455 /// run on an in-memory backend, so the off-seam raw reads the invariant checks
456 /// need — the full entry dump ([`BackendImpl::get_tree`]) and per-entry
457 /// verification status — are always reachable through it.
458 fn local_engine(&self, peer: usize) -> Arc<dyn BackendImpl> {
459 self.peers[peer]
460 .instance
461 .backend()
462 .local_engine()
463 .expect("Cluster peers run on a local in-memory backend")
464 }
465
466 /// Every entry peer `peer` holds for `tree`, in id order. The full DAG of the
467 /// tree — settings, auth, and every store — not just the tips.
468 pub async fn entries(&self, peer: usize, tree: &ID) -> Result<Vec<Entry>> {
469 let mut entries = self.local_engine(peer).get_tree(tree).await?;
470 entries.sort_by_key(|e| e.id());
471 Ok(entries)
472 }
473
474 /// The id of every entry peer `peer` holds for `tree`, sorted.
475 pub async fn entry_ids(&self, peer: usize, tree: &ID) -> Result<Vec<ID>> {
476 Ok(self
477 .entries(peer, tree)
478 .await?
479 .into_iter()
480 .map(|e| e.id())
481 .collect())
482 }
483
484 /// Assert no peer in `peers` is missing an entry another holds for `tree` —
485 /// the merge converged onto the *union* of histories, never silently dropping
486 /// one peer's signed entry. Stronger than [`converged`], which only compares
487 /// tips.
488 ///
489 /// Limitation: if *every* peer dropped the same entry the union is also short
490 /// it, so this can't see that loss — use [`assert_all_present`] with an
491 /// externally-known id set for the absolute form.
492 ///
493 /// [`converged`]: Cluster::converged
494 /// [`assert_all_present`]: Cluster::assert_all_present
495 pub async fn assert_no_lost_entries(&self, peers: &[usize], tree: &ID) -> Result<()> {
496 use std::collections::BTreeSet;
497 let mut union: BTreeSet<ID> = BTreeSet::new();
498 let mut per_peer: Vec<(usize, BTreeSet<ID>)> = Vec::with_capacity(peers.len());
499 for &p in peers {
500 let ids: BTreeSet<ID> = self.entry_ids(p, tree).await?.into_iter().collect();
501 union.extend(ids.iter().cloned());
502 per_peer.push((p, ids));
503 }
504 for (p, ids) in &per_peer {
505 let missing: Vec<&ID> = union.difference(ids).collect();
506 assert!(
507 missing.is_empty(),
508 "peer {p} lost {} entr{} other peers hold for the tree: {missing:?}",
509 missing.len(),
510 if missing.len() == 1 { "y" } else { "ies" },
511 );
512 }
513 Ok(())
514 }
515
516 /// Assert every id in `expected` is present on every peer in `peers`. The
517 /// absolute form of [`assert_no_lost_entries`]: the test names entries it knows
518 /// were committed (e.g. ids captured from its own writes) and demands they
519 /// survive the merge everywhere.
520 ///
521 /// [`assert_no_lost_entries`]: Cluster::assert_no_lost_entries
522 pub async fn assert_all_present(
523 &self,
524 peers: &[usize],
525 tree: &ID,
526 expected: &[ID],
527 ) -> Result<()> {
528 for &p in peers {
529 let ids: std::collections::BTreeSet<ID> =
530 self.entry_ids(p, tree).await?.into_iter().collect();
531 let missing: Vec<&ID> = expected.iter().filter(|id| !ids.contains(id)).collect();
532 assert!(
533 missing.is_empty(),
534 "peer {p} is missing expected entries: {missing:?}",
535 );
536 }
537 Ok(())
538 }
539
540 /// Assert every entry peer `peer` holds for `tree` carries a well-formed
541 /// signature. A synced CRDT under global auth must never store an unsigned or
542 /// malformed-signature entry; this catches one that slipped through.
543 pub async fn assert_all_signed(&self, peer: usize, tree: &ID) -> Result<()> {
544 for e in self.entries(peer, tree).await? {
545 assert!(
546 !e.sig.is_unsigned(),
547 "peer {peer} holds an unsigned entry: {}",
548 e.id(),
549 );
550 if let Some(reason) = e.sig.malformed_reason() {
551 panic!(
552 "peer {peer} holds a malformed-signature entry {}: {reason}",
553 e.id(),
554 );
555 }
556 }
557 Ok(())
558 }
559
560 /// Assert no entry peer `peer` holds for `tree` is in the `Failed` verification
561 /// state — every entry, including those received over sync, verified against
562 /// the tree's auth. Stronger than tip equality: a peer can converge on the
563 /// right tips while having stored a received entry that does not verify.
564 ///
565 /// This is only a meaningful convergence invariant once sync runs a per-entry
566 /// verification pass that promotes received entries after their signing
567 /// context arrives. On a build where sync ingestion records a placeholder
568 /// status instead of a real signature check (see the TODO on
569 /// [`VerificationStatus`] and `docs/src/design/verification.md`), the stored
570 /// status does not reflect verification and this assertion should not be used
571 /// — a bootstrapped peer legitimately holds entries marked `Failed` that no
572 /// pass has yet promoted. Provided for the harness's forward path: exercise it
573 /// once verification-on-ingest is in place.
574 ///
575 /// [`VerificationStatus`]: crate::backend::VerificationStatus
576 pub async fn assert_all_verified(&self, peer: usize, tree: &ID) -> Result<()> {
577 let engine = self.local_engine(peer);
578 for e in self.entries(peer, tree).await? {
579 let status = engine.get_verification_status(&e.id()).await?;
580 assert!(
581 matches!(status, VerificationStatus::Verified),
582 "peer {peer} stored entry {} as {status:?}, expected Verified",
583 e.id(),
584 );
585 }
586 Ok(())
587 }
588}
589
590/// One peer in a [`Cluster`]: a full `Instance` plus the handles a test needs to
591/// act as that peer. It does **not** hold the peer's application databases — the
592/// test opens and keeps those.
593pub struct Peer {
594 instance: Instance,
595 user: User,
596 key_id: PublicKey,
597 sync: Arc<Sync>,
598 address: Address,
599}
600
601impl Peer {
602 /// This peer's `Instance`.
603 pub fn instance(&self) -> &Instance {
604 &self.instance
605 }
606
607 /// This peer's logged-in user session.
608 pub fn user(&self) -> &User {
609 &self.user
610 }
611
612 /// Mutable user session, for `create_database` / `open_database` directly.
613 pub fn user_mut(&mut self) -> &mut User {
614 &mut self.user
615 }
616
617 /// This peer's signing key id (the `SigKey` for its database operations).
618 pub fn key_id(&self) -> &PublicKey {
619 &self.key_id
620 }
621
622 /// The display name of this peer's signing key, for naming it in a bootstrap
623 /// request.
624 pub fn key_name(&self) -> &str {
625 KEY_NAME
626 }
627
628 /// This peer's sync handle.
629 pub fn sync(&self) -> &Arc<Sync> {
630 &self.sync
631 }
632
633 /// The address other peers use to reach this peer.
634 pub fn address(&self) -> &Address {
635 &self.address
636 }
637
638 /// Mark `tree` sync-enabled on this peer so its sync handler will serve it to
639 /// bootstrapping peers. Delegates to [`User::enable_sync`], which flips the
640 /// user's preference and recomputes the host's combined sync state — the same
641 /// path a real consumer takes. The database must already be tracked (it is, on
642 /// any peer that created it via `create_database` or joined it via
643 /// [`Cluster::bootstrap`]). Pure plumbing: set whatever auth the test needs on
644 /// the database *before* calling this.
645 ///
646 /// [`User::enable_sync`]: crate::user::User::enable_sync
647 pub async fn serve(&mut self, tree: &ID) -> Result<()> {
648 self.user.enable_sync(tree).await
649 }
650}
651
652/// Register `pubkey` as a peer of `sync`, treating an already-registered peer as
653/// success — a prior bootstrap commonly registers it first.
654async fn register_peer_idempotent(sync: &Sync, pubkey: &PublicKey) -> Result<()> {
655 match sync.register_peer(pubkey, Some("peer")).await {
656 Ok(()) => Ok(()),
657 Err(crate::Error::Sync(e))
658 if matches!(*e, crate::sync::error::SyncError::PeerAlreadyExists(_)) =>
659 {
660 Ok(())
661 }
662 Err(e) => Err(e),
663 }
664}
665
666// ===== auth tools (policy-neutral: apply the keys the caller passes) =====
667
668/// Apply per-key auth to a database via a settings transaction. The caller
669/// chooses the keys and permissions; this just writes them.
670pub async fn add_auth_keys(db: &Database, keys: &[(&PublicKey, AuthKey)]) -> Result<()> {
671 let txn = db.new_transaction().await?;
672 let settings = txn.get_settings()?;
673 for (pubkey, key) in keys {
674 settings.set_auth_key(pubkey, key.clone()).await?;
675 }
676 txn.commit().await?;
677 Ok(())
678}
679
680/// Set the global (wildcard) auth key on a database via a settings transaction.
681/// The caller chooses the permission level.
682pub async fn set_global_auth_key(db: &Database, key: AuthKey) -> Result<()> {
683 let txn = db.new_transaction().await?;
684 let settings = txn.get_settings()?;
685 settings.set_global_auth_key(key).await?;
686 txn.commit().await?;
687 Ok(())
688}
689
690// ===== SimTransport: in-memory, controllable transport (Tier 1 seam) =====
691//
692// `HttpLoopback` is real HTTP over loopback: it delivers in wired order, so
693// "convergence is order-independent" is unprovable and a partition can only be
694// modelled coarsely (don't call `exchange`). `SimTransport` swaps the one seam
695// the harness left open — [`TestTransport`] — for an in-process fabric that
696// routes a [`SyncRequest`] straight to the target peer's [`SyncHandler`]: no
697// sockets, no ports, deterministic, and *controllable*. A test holds a
698// [`SimNetwork`] handle and partitions links mid-run.
699//
700// This is Tier 1 of the harness. It does not replace Tier 0 — it plugs into it:
701// `Cluster::builder().transport(Arc::new(SimLoopback::new(net.clone())))`.
702
703/// In-memory message fabric shared by every [`SimTransport`] in a cluster, and
704/// the control handle a test uses to inject faults. A drop-in for
705/// [`HttpLoopback`] via [`ClusterBuilder::transport`] that additionally lets a
706/// test [`partition`] links and [`heal`] them.
707///
708/// `Clone` is a shared handle (an `Arc` inside): the copy a test keeps and the
709/// copies inside each peer's transport all see the same fabric.
710///
711/// [`partition`]: SimNetwork::partition
712/// [`heal`]: SimNetwork::heal
713#[derive(Clone, Default)]
714pub struct SimNetwork {
715 inner: Arc<std::sync::Mutex<SimState>>,
716}
717
718#[derive(Default)]
719struct SimState {
720 /// Peer address -> that peer's serving handler, populated when it serves.
721 handlers: std::collections::HashMap<String, Arc<dyn SyncHandler>>,
722 /// Directed links currently dropping traffic: `(from_addr, to_addr)`.
723 blocked: std::collections::HashSet<(String, String)>,
724 /// Monotonic id source for peer addresses.
725 next_id: usize,
726 /// When set, `SendEntries` pushes are captured in `queue` instead of being
727 /// delivered to the receiver's handler inline. Request/response traffic
728 /// (handshake, tree-sync) always delivers inline regardless.
729 manual_delivery: bool,
730 /// Captured, not-yet-delivered messages, in send order. Only populated in
731 /// manual-delivery mode.
732 queue: Vec<InFlight>,
733 /// Monotonic id source for captured messages (delivery handles).
734 next_seq: usize,
735}
736
737/// A `SendEntries` push captured in manual-delivery mode, awaiting an explicit
738/// [`SimNetwork::deliver_one`] / [`deliver`](SimNetwork::deliver) / etc. The
739/// sender already received an optimistic `Ack`, so this models a message
740/// in-flight on the wire: the network decides when, in what order, and how many
741/// times the receiver actually sees it.
742struct InFlight {
743 /// Stable delivery handle, unique for the life of the fabric.
744 seq: usize,
745 /// Sender's sim address (becomes the receiver's `remote_address`).
746 from: String,
747 /// Receiver's sim address (whose handler will process the request).
748 to: String,
749 /// The captured request — always a `SyncRequest::SendEntries`.
750 request: SyncRequest,
751}
752
753impl SimNetwork {
754 /// A fresh, empty fabric.
755 pub fn new() -> Self {
756 Self::default()
757 }
758
759 fn lock(&self) -> std::sync::MutexGuard<'_, SimState> {
760 self.inner.lock().expect("SimNetwork mutex poisoned")
761 }
762
763 /// Hand out the next unique peer address (`sim-peer-N`, in serve order).
764 fn alloc_address(&self) -> String {
765 let mut s = self.lock();
766 let id = s.next_id;
767 s.next_id += 1;
768 format!("sim-peer-{id}")
769 }
770
771 fn register(&self, address: &str, handler: Arc<dyn SyncHandler>) {
772 self.lock().handlers.insert(address.to_string(), handler);
773 }
774
775 fn unregister(&self, address: &str) {
776 self.lock().handlers.remove(address);
777 }
778
779 /// Clone out the handler for `address` (drops the lock before any await).
780 fn handler_for(&self, address: &str) -> Option<Arc<dyn SyncHandler>> {
781 self.lock().handlers.get(address).cloned()
782 }
783
784 fn is_blocked(&self, from: &str, to: &str) -> bool {
785 self.lock()
786 .blocked
787 .contains(&(from.to_string(), to.to_string()))
788 }
789
790 /// If manual-delivery is on, capture `request` as an in-flight message from
791 /// `from` to `to` and return `true` (the caller answers the sender with an
792 /// optimistic `Ack`). Otherwise return `false` and let the caller deliver
793 /// inline. Called only for `SendEntries`.
794 fn capture(&self, from: &str, to: &Address, request: &SyncRequest) -> bool {
795 let mut s = self.lock();
796 if !s.manual_delivery {
797 return false;
798 }
799 let seq = s.next_seq;
800 s.next_seq += 1;
801 s.queue.push(InFlight {
802 seq,
803 from: from.to_string(),
804 to: to.address.clone(),
805 request: request.clone(),
806 });
807 true
808 }
809
810 /// Drop all traffic between `a` and `b` in *both* directions until [`heal`].
811 /// A send across a blocked link fails as a connection error, so an
812 /// auto-sync peer's queued entries stay pending and redeliver after heal —
813 /// a message-level partition, finer than withholding `exchange` calls.
814 ///
815 /// [`heal`]: SimNetwork::heal
816 pub fn partition(&self, a: &Address, b: &Address) {
817 let mut s = self.lock();
818 s.blocked.insert((a.address.clone(), b.address.clone()));
819 s.blocked.insert((b.address.clone(), a.address.clone()));
820 }
821
822 /// Restore traffic between `a` and `b` (both directions).
823 pub fn heal(&self, a: &Address, b: &Address) {
824 let mut s = self.lock();
825 s.blocked.remove(&(a.address.clone(), b.address.clone()));
826 s.blocked.remove(&(b.address.clone(), a.address.clone()));
827 }
828
829 /// Restore every link in the fabric.
830 pub fn heal_all(&self) {
831 self.lock().blocked.clear();
832 }
833
834 // ----- store-and-forward delivery control -----
835 //
836 // By default the fabric delivers every request inline (synchronous, in wired
837 // order) — same as `HttpLoopback`, just without sockets. Turn on
838 // *manual delivery* and `SendEntries` pushes are instead captured as
839 // [`InFlight`] messages, and the test decides when each one reaches its
840 // receiver. The sender still gets an immediate `Ack`, so an undelivered
841 // message looks delivered-from-the-sender's-side — a message that's left the
842 // sender but not yet arrived. This is what makes reorder, duplicate, and
843 // selective drop expressible; a real socket transport can't hold a message
844 // mid-flight under test control.
845 //
846 // Handshake and tree-sync are request/response and carry data the caller
847 // needs back, so they always deliver inline — only the fire-and-forget
848 // `SendEntries` push is deferrable. Bootstrap and `exchange` therefore work
849 // unchanged in manual mode; only auto-sync's entry pushes get captured.
850
851 /// Capture `SendEntries` pushes instead of delivering them inline (`true`),
852 /// or return to inline delivery (`false`). Flip this *after* setup
853 /// (bootstrap / `auto_sync`) so only the entry pushes a test cares about get
854 /// captured. Turning it back off does not flush the queue — already-captured
855 /// messages still need an explicit deliver.
856 ///
857 /// The two fault families compose with **partition taking precedence**: a
858 /// send across a [`partition`](Self::partition)ed link fails as a connection
859 /// error *before* capture is considered, so it parks in the retry queue rather
860 /// than the in-flight queue. Manual delivery only ever captures pushes on
861 /// links that are up. Tests generally use one family or the other, not both at
862 /// once.
863 pub fn set_manual_delivery(&self, manual: bool) {
864 self.lock().manual_delivery = manual;
865 }
866
867 /// The delivery handles of every captured-but-undelivered message, in the
868 /// order they were sent. Pass these to [`deliver`](Self::deliver),
869 /// [`duplicate`](Self::duplicate) or [`drop_message`](Self::drop_message) to
870 /// drive an out-of-order, duplicated, or lossy schedule.
871 pub fn pending(&self) -> Vec<usize> {
872 self.lock().queue.iter().map(|m| m.seq).collect()
873 }
874
875 /// Pop the captured message with handle `seq` (its request, sender, and
876 /// receiver). Returns `None` if no such message is queued.
877 fn take(&self, seq: usize) -> Option<InFlight> {
878 let mut s = self.lock();
879 let idx = s.queue.iter().position(|m| m.seq == seq)?;
880 Some(s.queue.remove(idx))
881 }
882
883 /// Deliver one captured message to its receiver: look up the receiver's
884 /// handler and run the request through it, exactly as an inline send would.
885 /// The response is discarded — the sender already got its optimistic `Ack`.
886 /// Returns `false` if the receiver is no longer serving (the message is
887 /// effectively lost).
888 async fn deliver_inflight(&self, msg: InFlight) -> bool {
889 let Some(handler) = self.handler_for(&msg.to) else {
890 return false;
891 };
892 let context = RequestContext {
893 remote_address: Some(Address::new(SimTransport::TRANSPORT_TYPE, msg.from)),
894 // Only tree-sync carries a pubkey, and that never enters the queue.
895 peer_pubkey: None,
896 };
897 handler.handle_request(&msg.request, &context).await;
898 true
899 }
900
901 /// Deliver the captured message `seq` to its receiver and remove it from the
902 /// queue. Delivering in an order other than [`pending`](Self::pending)
903 /// returned is how a test reorders the wire. Returns `false` if no such
904 /// message is queued (or its receiver has stopped).
905 pub async fn deliver(&self, seq: usize) -> bool {
906 match self.take(seq) {
907 Some(msg) => self.deliver_inflight(msg).await,
908 None => false,
909 }
910 }
911
912 /// Deliver the oldest captured message. Returns `false` when the queue is
913 /// empty.
914 pub async fn deliver_one(&self) -> bool {
915 let seq = match self.lock().queue.first() {
916 Some(m) => m.seq,
917 None => return false,
918 };
919 self.deliver(seq).await
920 }
921
922 /// Deliver every captured message in send order, draining the queue. Returns
923 /// the number delivered. (Delivery never enqueues more — the receiver's
924 /// handler processes entries, it doesn't push back through this fabric.)
925 pub async fn deliver_all(&self) -> usize {
926 let mut n = 0;
927 while self.deliver_one().await {
928 n += 1;
929 }
930 n
931 }
932
933 /// Clone captured message `seq` so it will be delivered a second time,
934 /// modelling a duplicate on the wire. The copy gets a fresh handle and lands
935 /// at the back of the queue; the original stays put. Returns the new handle,
936 /// or `None` if `seq` isn't queued. Idempotent sync must converge regardless.
937 pub fn duplicate(&self, seq: usize) -> Option<usize> {
938 let mut s = self.lock();
939 let original = s.queue.iter().find(|m| m.seq == seq)?;
940 let copy = InFlight {
941 seq: s.next_seq,
942 from: original.from.clone(),
943 to: original.to.clone(),
944 request: original.request.clone(),
945 };
946 let new_seq = copy.seq;
947 s.next_seq += 1;
948 s.queue.push(copy);
949 Some(new_seq)
950 }
951
952 /// Drop captured message `seq` without delivering it — a lost packet.
953 /// Returns `true` if a message was removed.
954 pub fn drop_message(&self, seq: usize) -> bool {
955 self.take(seq).is_some()
956 }
957
958 /// Drop every captured message without delivering. Returns the number lost.
959 pub fn drop_all(&self) -> usize {
960 let mut s = self.lock();
961 let n = s.queue.len();
962 s.queue.clear();
963 n
964 }
965}
966
967/// [`TestTransport`] backed by a [`SimNetwork`]: an in-memory drop-in for
968/// [`HttpLoopback`]. Build a cluster over it with
969/// `Cluster::builder().transport(Arc::new(SimLoopback::new(net.clone())))` and
970/// keep `net` to drive partitions.
971pub struct SimLoopback {
972 network: SimNetwork,
973}
974
975impl SimLoopback {
976 /// Wrap a [`SimNetwork`]. Share one network across the cluster (clone the
977 /// handle) so the test and every peer route through the same fabric.
978 pub fn new(network: SimNetwork) -> Self {
979 Self { network }
980 }
981}
982
983#[async_trait]
984impl TestTransport for SimLoopback {
985 async fn serve(&self, sync: &Sync) -> Result<Address> {
986 let address = self.network.alloc_address();
987 sync.register_transport(
988 "sim",
989 SimTransportBuilder {
990 address: address.clone(),
991 network: self.network.clone(),
992 },
993 )
994 .await?;
995 // accept_connections awaits StartServer, which calls start_server and
996 // registers our handler before returning — no post-serve race.
997 sync.accept_connections().await?;
998 Ok(Address::new(SimTransport::TRANSPORT_TYPE, address))
999 }
1000}
1001
1002/// Builder that hands the peer's address + shared fabric to its [`SimTransport`].
1003struct SimTransportBuilder {
1004 address: String,
1005 network: SimNetwork,
1006}
1007
1008#[async_trait]
1009impl TransportBuilder for SimTransportBuilder {
1010 type Transport = SimTransport;
1011
1012 async fn build(self, _persisted: Doc) -> Result<(Self::Transport, Option<Doc>)> {
1013 Ok((
1014 SimTransport {
1015 address: self.address,
1016 network: self.network,
1017 running: false,
1018 },
1019 None,
1020 ))
1021 }
1022}
1023
1024/// In-memory [`SyncTransport`]. Routes a [`SyncRequest`] straight to the target
1025/// peer's [`SyncHandler`] through the shared [`SimNetwork`] — no sockets, no
1026/// serialization — and honors the network's partition state.
1027pub struct SimTransport {
1028 /// This peer's own sim address (the key its handler is registered under).
1029 address: String,
1030 network: SimNetwork,
1031 running: bool,
1032}
1033
1034impl SimTransport {
1035 const TRANSPORT_TYPE: &'static str = "sim";
1036}
1037
1038#[async_trait]
1039impl SyncTransport for SimTransport {
1040 fn transport_type(&self) -> &'static str {
1041 Self::TRANSPORT_TYPE
1042 }
1043
1044 fn can_handle_address(&self, address: &Address) -> bool {
1045 address.transport_type == Self::TRANSPORT_TYPE
1046 }
1047
1048 async fn start_server(&mut self, handler: Arc<dyn SyncHandler>) -> Result<()> {
1049 self.network.register(&self.address, handler);
1050 self.running = true;
1051 Ok(())
1052 }
1053
1054 async fn stop_server(&mut self) -> Result<()> {
1055 self.network.unregister(&self.address);
1056 self.running = false;
1057 Ok(())
1058 }
1059
1060 async fn send_request(&self, address: &Address, request: &SyncRequest) -> Result<SyncResponse> {
1061 if !self.can_handle_address(address) {
1062 return Err(SyncError::UnsupportedTransport {
1063 transport_type: address.transport_type.clone(),
1064 }
1065 .into());
1066 }
1067 // A partitioned link looks like a connection failure to the sender; the
1068 // background sync layer keeps the entries queued for a later flush.
1069 if self.network.is_blocked(&self.address, &address.address) {
1070 return Err(SyncError::ConnectionFailed {
1071 address: address.address.clone(),
1072 reason: "sim link partitioned".to_string(),
1073 }
1074 .into());
1075 }
1076 // In manual-delivery mode, capture entry pushes as in-flight messages
1077 // and report success to the sender (an optimistic Ack — see
1078 // `SimNetwork::set_manual_delivery`). Request/response traffic falls
1079 // through to inline delivery so its result reaches the caller.
1080 if matches!(request, SyncRequest::SendEntries(_))
1081 && self.network.capture(&self.address, address, request)
1082 {
1083 return Ok(SyncResponse::Ack);
1084 }
1085
1086 let handler = self.network.handler_for(&address.address).ok_or_else(|| {
1087 SyncError::ConnectionFailed {
1088 address: address.address.clone(),
1089 reason: "no sim peer serving this address".to_string(),
1090 }
1091 })?;
1092
1093 // Mirror the HTTP transport's context: only SyncTree carries a pubkey.
1094 let peer_pubkey = match request {
1095 SyncRequest::SyncTree(r) => r.peer_pubkey.clone(),
1096 _ => None,
1097 };
1098 let context = RequestContext {
1099 remote_address: Some(Address::new(Self::TRANSPORT_TYPE, self.address.clone())),
1100 peer_pubkey,
1101 };
1102 Ok(handler.handle_request(request, &context).await)
1103 }
1104
1105 fn is_server_running(&self) -> bool {
1106 self.running
1107 }
1108
1109 fn get_server_address(&self) -> Result<String> {
1110 if self.running {
1111 Ok(self.address.clone())
1112 } else {
1113 Err(SyncError::ServerNotRunning.into())
1114 }
1115 }
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120 use super::*;
1121 use crate::{crdt::Doc, store::DocStore};
1122
1123 async fn write(db: &Database, key: &str, value: &str) -> Result<()> {
1124 let tx = db.new_transaction().await?;
1125 tx.get_store::<DocStore>("data")
1126 .await?
1127 .set_string(key, value)
1128 .await?;
1129 tx.commit().await?;
1130 Ok(())
1131 }
1132
1133 async fn read(db: &Database, key: &str) -> Result<String> {
1134 let tx = db.new_transaction().await?;
1135 tx.get_store::<DocStore>("data")
1136 .await?
1137 .get_string(key)
1138 .await
1139 }
1140
1141 /// Peer 0 creates a database (auth chosen by the test) and serves it; it
1142 /// writes `a`; peer 1 bootstraps and opens it. Returns the room id and both
1143 /// peers' open handles — the shared starting point for the tests below.
1144 async fn shared_room(net: &mut Cluster) -> Result<(ID, Database, Database)> {
1145 let key0 = net.peer(0).key_id().clone();
1146 let device0 = net.peer(0).instance().id();
1147 let mut settings = Doc::new();
1148 settings.set("name", "chat");
1149 let db0 = net
1150 .peer_mut(0)
1151 .user_mut()
1152 .create_database(settings, &key0)
1153 .await?;
1154 let room = db0.root_id().clone();
1155
1156 add_auth_keys(
1157 &db0,
1158 &[
1159 (&key0, AuthKey::active(Some("admin"), Permission::Admin(10))),
1160 (
1161 &device0,
1162 AuthKey::active(Some("device"), Permission::Admin(10)),
1163 ),
1164 ],
1165 )
1166 .await?;
1167 set_global_auth_key(&db0, AuthKey::active(None, Permission::Admin(10))).await?;
1168 net.peer_mut(0).serve(&room).await?;
1169 write(&db0, "a", "from-peer-0").await?;
1170
1171 net.bootstrap(0, 1, &room, Permission::Write(10)).await?;
1172 let db1 = net.peer_mut(1).user_mut().open_database(&room).await?;
1173 assert_eq!(
1174 read(&db1, "a").await?,
1175 "from-peer-0",
1176 "bootstrap carries the write"
1177 );
1178 Ok((room, db0, db1))
1179 }
1180
1181 /// Manual mode: peer 1 writes, an explicit `exchange` brings peer 0 up to
1182 /// date, and both converge. Sync is fully ordered by the test.
1183 #[tokio::test]
1184 async fn exchange_round_trips_and_converges() -> Result<()> {
1185 let mut net = Cluster::builder().peers(2).build().await?;
1186 let (room, db0, db1) = shared_room(&mut net).await?;
1187
1188 write(&db1, "b", "from-peer-1").await?;
1189 net.exchange(1, 0, &room).await?;
1190
1191 assert_eq!(read(&db0, "a").await?, "from-peer-0");
1192 assert_eq!(read(&db0, "b").await?, "from-peer-1");
1193 assert!(net.converged(&[0, 1], &room).await?);
1194 Ok(())
1195 }
1196
1197 /// Background mode: after `auto_sync`, commits propagate on their own — no
1198 /// `exchange` per write. `flush` is the only barrier the test needs.
1199 #[tokio::test]
1200 async fn auto_sync_propagates_on_commit() -> Result<()> {
1201 let mut net = Cluster::builder().peers(2).build().await?;
1202 let (room, db0, db1) = shared_room(&mut net).await?;
1203
1204 net.auto_sync(0, 1, &room).await?;
1205
1206 // Peer 0 commits; no exchange call — auto-sync carries it.
1207 write(&db0, "c", "auto-from-0").await?;
1208 net.flush(0).await?;
1209 assert_eq!(read(&db1, "c").await?, "auto-from-0");
1210
1211 // And the reverse direction.
1212 write(&db1, "d", "auto-from-1").await?;
1213 net.flush(1).await?;
1214 assert_eq!(read(&db0, "d").await?, "auto-from-1");
1215
1216 assert!(net.converged(&[0, 1], &room).await?);
1217 Ok(())
1218 }
1219
1220 /// [`SimTransport`] is a drop-in for [`HttpLoopback`]: the same bootstrap +
1221 /// `exchange` flow converges over the in-memory fabric, no sockets involved.
1222 #[tokio::test]
1223 async fn sim_transport_is_a_drop_in() -> Result<()> {
1224 let mut net = Cluster::builder()
1225 .peers(2)
1226 .transport(Arc::new(SimLoopback::new(SimNetwork::new())))
1227 .build()
1228 .await?;
1229 let (room, db0, db1) = shared_room(&mut net).await?;
1230
1231 write(&db1, "b", "from-peer-1").await?;
1232 net.exchange(1, 0, &room).await?;
1233
1234 assert_eq!(read(&db0, "a").await?, "from-peer-0");
1235 assert_eq!(read(&db0, "b").await?, "from-peer-1");
1236 assert!(net.converged(&[0, 1], &room).await?);
1237 Ok(())
1238 }
1239
1240 /// A partition drops traffic at the message level: with `auto_sync` wired,
1241 /// a commit on peer 0 cannot reach peer 1 while the link is cut, then
1242 /// redelivers from the still-pending queue once the link heals. This is what
1243 /// `HttpLoopback` can't do — there, withholding `exchange` is the only
1244 /// partition, and it can't model "wired but not delivering".
1245 #[tokio::test]
1246 async fn sim_partition_blocks_then_heal_delivers() -> Result<()> {
1247 let fabric = SimNetwork::new();
1248 let mut net = Cluster::builder()
1249 .peers(2)
1250 .transport(Arc::new(SimLoopback::new(fabric.clone())))
1251 .build()
1252 .await?;
1253 let (room, db0, db1) = shared_room(&mut net).await?;
1254 let addr0 = net.peer(0).address().clone();
1255 let addr1 = net.peer(1).address().clone();
1256
1257 net.auto_sync(0, 1, &room).await?;
1258
1259 // Cut the link, then commit on peer 0. The flush attempt cannot reach
1260 // peer 1 (a connection error to the sender), so the entry stays queued.
1261 fabric.partition(&addr0, &addr1);
1262 write(&db0, "c", "during-partition").await?;
1263 let _ = net.flush(0).await; // send fails across the cut; entry remains queued
1264
1265 assert!(
1266 read(&db1, "c").await.is_err(),
1267 "peer 1 must not see the write while partitioned"
1268 );
1269 assert!(
1270 !net.converged(&[0, 1], &room).await?,
1271 "peers must diverge under partition"
1272 );
1273
1274 // Heal and flush again: the queued entry now reaches peer 1.
1275 fabric.heal(&addr0, &addr1);
1276 net.flush(0).await?;
1277
1278 assert_eq!(read(&db1, "c").await?, "during-partition");
1279 assert!(net.converged(&[0, 1], &room).await?);
1280 Ok(())
1281 }
1282
1283 /// Manual delivery captures a flushed push instead of delivering it: after
1284 /// `flush` the sender believes it sent (optimistic Ack), but the receiver
1285 /// sees nothing until the test releases the message. `deliver_all` then
1286 /// drains the wire and the peers converge.
1287 #[tokio::test]
1288 async fn sim_manual_delivery_holds_then_releases() -> Result<()> {
1289 let fabric = SimNetwork::new();
1290 let mut net = Cluster::builder()
1291 .peers(2)
1292 .transport(Arc::new(SimLoopback::new(fabric.clone())))
1293 .build()
1294 .await?;
1295 let (room, db0, db1) = shared_room(&mut net).await?;
1296 net.auto_sync(0, 1, &room).await?;
1297
1298 // Capture pushes from here on, then commit + flush: the entry leaves the
1299 // sender but is held on the wire.
1300 fabric.set_manual_delivery(true);
1301 write(&db0, "c", "held").await?;
1302 net.flush(0).await?;
1303
1304 assert_eq!(fabric.pending().len(), 1, "the push is captured, not lost");
1305 assert!(
1306 read(&db1, "c").await.is_err(),
1307 "receiver sees nothing until the message is delivered"
1308 );
1309
1310 // Release the wire: the held push reaches peer 1 and they converge.
1311 assert_eq!(fabric.deliver_all().await, 1);
1312 assert_eq!(read(&db1, "c").await?, "held");
1313 assert!(net.converged(&[0, 1], &room).await?);
1314 Ok(())
1315 }
1316
1317 /// Two causally-ordered pushes delivered *back to front*. A receiver that
1318 /// gets a child before its parent must still converge once both arrive —
1319 /// this is the order-independence that an in-wired transport can't probe.
1320 #[tokio::test]
1321 async fn sim_reordered_delivery_still_converges() -> Result<()> {
1322 let fabric = SimNetwork::new();
1323 let mut net = Cluster::builder()
1324 .peers(2)
1325 .transport(Arc::new(SimLoopback::new(fabric.clone())))
1326 .build()
1327 .await?;
1328 let (room, db0, db1) = shared_room(&mut net).await?;
1329 net.auto_sync(0, 1, &room).await?;
1330 fabric.set_manual_delivery(true);
1331
1332 // Two separate flushes => two distinct in-flight messages; the second
1333 // entry's parent is the first, so they're causally ordered.
1334 write(&db0, "first", "1").await?;
1335 net.flush(0).await?;
1336 write(&db0, "second", "2").await?;
1337 net.flush(0).await?;
1338
1339 let pending = fabric.pending();
1340 assert_eq!(pending.len(), 2, "two independent pushes on the wire");
1341
1342 // Deliver child-before-parent: reverse send order.
1343 for &seq in pending.iter().rev() {
1344 assert!(fabric.deliver(seq).await, "message {seq} should deliver");
1345 }
1346
1347 assert_eq!(read(&db1, "first").await?, "1");
1348 assert_eq!(read(&db1, "second").await?, "2");
1349 assert!(net.converged(&[0, 1], &room).await?);
1350 Ok(())
1351 }
1352
1353 /// A duplicated push: the same message delivered twice. Sync must be
1354 /// idempotent — the second copy is a no-op, not a corruption or a crash.
1355 #[tokio::test]
1356 async fn sim_duplicate_delivery_is_idempotent() -> Result<()> {
1357 let fabric = SimNetwork::new();
1358 let mut net = Cluster::builder()
1359 .peers(2)
1360 .transport(Arc::new(SimLoopback::new(fabric.clone())))
1361 .build()
1362 .await?;
1363 let (room, db0, db1) = shared_room(&mut net).await?;
1364 net.auto_sync(0, 1, &room).await?;
1365 fabric.set_manual_delivery(true);
1366
1367 write(&db0, "c", "once").await?;
1368 net.flush(0).await?;
1369
1370 let seq = fabric.pending()[0];
1371 fabric.duplicate(seq).expect("message is queued");
1372 assert_eq!(fabric.pending().len(), 2, "original plus its duplicate");
1373
1374 // Both copies delivered; the second is a redelivery of the same entries.
1375 assert_eq!(fabric.deliver_all().await, 2);
1376
1377 assert_eq!(read(&db1, "c").await?, "once");
1378 assert!(net.converged(&[0, 1], &room).await?);
1379 Ok(())
1380 }
1381
1382 /// A dropped push is a genuine loss — the sender got its Ack and won't
1383 /// retry, so the receiver stays behind. A later reconciling `exchange`
1384 /// (tree-sync, which delivers inline) repairs the divergence: lossy delivery
1385 /// doesn't strand the cluster as long as some full sync eventually runs.
1386 #[tokio::test]
1387 async fn sim_dropped_push_is_recovered_by_resync() -> Result<()> {
1388 let fabric = SimNetwork::new();
1389 let mut net = Cluster::builder()
1390 .peers(2)
1391 .transport(Arc::new(SimLoopback::new(fabric.clone())))
1392 .build()
1393 .await?;
1394 let (room, db0, db1) = shared_room(&mut net).await?;
1395 net.auto_sync(0, 1, &room).await?;
1396 fabric.set_manual_delivery(true);
1397
1398 write(&db0, "c", "dropped").await?;
1399 net.flush(0).await?;
1400
1401 let seq = fabric.pending()[0];
1402 assert!(fabric.drop_message(seq), "the push is dropped on the wire");
1403 assert!(
1404 read(&db1, "c").await.is_err(),
1405 "a dropped push never reaches the receiver"
1406 );
1407 assert!(
1408 !net.converged(&[0, 1], &room).await?,
1409 "the cluster diverges after a drop"
1410 );
1411
1412 // A reconciling tree-sync pulls what the dropped push lost.
1413 fabric.set_manual_delivery(false);
1414 net.exchange(1, 0, &room).await?;
1415
1416 assert_eq!(read(&db1, "c").await?, "dropped");
1417 assert!(net.converged(&[0, 1], &room).await?);
1418 Ok(())
1419 }
1420}