Initial commit: ClawSync v0.1.0

8-crate pure-Rust workspace for revision-aware HDF5 sync.

## Crates
- clawhdf5-onion: ClawOnion VFD — page-level versioned HDF5 storage,
  binary format, writer/reader, branch DAG, GC, snapshots, provenance
- clawsync-core: BLAKE3, xxHash3, FastCDC (+ SIMD NEON), zstd/lz4
- clawsync-onion: IBLT sketch, Merkle tree differ, packet differ/merger,
  ClawSyncManifest, SyncSelector
- clawsync-hdf5: dataset-level manifest, differ, patcher, wire payload
  reconstruction (apply_received_payloads)
- clawsync-transport: TCP, QUIC (quinn 0.11/TLS 1.3), SyncPeer abstraction,
  length-prefixed rkyv wire protocol (21 SyncMessage variants)
- clawsync-agent: OnionMemory, SyncScheduler, TcpSyncBackend,
  PeerCapabilities negotiation
- clawsync-fs: CDC-based delta sync for any file type; FsSyncClient/Server,
  W=16 pipelining, atomic writes
- clawsync-cli: push/pull/serve/hdf5-sync/serve-hdf5/sync/serve-fs +
  all local management commands; --quic on all network commands

## Key features
- IBLT pre-flight: O(revision count) vs rsync's O(file size)
- W=16 sliding-window push: 13–15x speedup over stop-and-wait at WAN RTT
- Dataset-granular HDF5 sync: only modified datasets transferred
- CDC delta for any file type: insertion-stable chunk boundaries
- Full revision DAG: branch, merge, rollback, export, snapshot, GC
- QUIC transport: TLS 1.3, per-message streams via quinn 0.11

## Tests
~573 passing (default features); ~589 with --features simd-cdc

## Performance (Apple Silicon)
- Reconstruct rev=100: 68 µs (target ≤ 1 ms)
- BLAKE3 Rayon 1 MB: 10.3 GiB/s (target ≥ 5 GB/s)
- GC 500 revisions: 20.6 µs (target ≤ 2 s)
- W=16 vs W=1 at 5 ms RTT: 14.8x speedup
- No-op pre-flight at 16 MB: 4 ms vs rsync 35 ms (7.8x)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
osobh
2026-04-04 18:41:22 -05:00
co-authored by Claude Sonnet 4.6
commit 260e15f5b6
102 changed files with 30098 additions and 0 deletions
@@ -0,0 +1,418 @@
//! End-to-end QUIC sync integration test.
//!
//! Mirrors `e2e_sync.rs` but uses `QuicServer` / `QuicConnection` /
//! `quic_connect` instead of the TCP transport. The same direction-aware
//! push protocol applies; only the framing layer changes.
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use clawhdf5_onion::format::NO_PARENT;
use clawhdf5_onion::writer::OnionFile;
use clawsync_onion::{differ::diff_revisions, manifest::ClawSyncManifest, merger::merge_packets};
use clawsync_transport::{
protocol::SyncMessage,
quic::{quic_connect, QuicConfig, QuicServer},
};
use tempfile::NamedTempFile;
fn localhost_any() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)
}
const H5_BASE: &[u8] = b"\x89HDF\r\n\x1a\n";
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
/// Create an `OnionFile` with `n` committed revisions.
fn make_onion(n: u8) -> (NamedTempFile, OnionFile) {
let tmp = NamedTempFile::new().unwrap();
let h5_path = tmp.path().with_extension("h5");
std::fs::write(&h5_path, H5_BASE).unwrap();
let mut onion = OnionFile::create(&h5_path, 4096).unwrap();
for i in 0..n {
let mut s = onion.begin_session(None).unwrap();
s.record_page(0, &vec![i; 4096]);
onion.commit_session(s, Some(&format!("rev {i}"))).unwrap();
}
(tmp, onion)
}
/// Build a pair of self-signed QuicConfigs. The server config uses one cert;
/// the client config trusts that cert. The client's QuicConfig also needs a
/// valid (but unused) server_config, so we generate a separate cert for it.
fn make_quic_configs() -> (QuicConfig, QuicConfig) {
fn gen_cert() -> (
rustls::pki_types::CertificateDer<'static>,
rustls::pki_types::PrivateKeyDer<'static>,
) {
let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
let cert_der = rustls::pki_types::CertificateDer::from(cert.cert.der().to_vec());
let key_der =
rustls::pki_types::PrivateKeyDer::try_from(cert.key_pair.serialize_der()).unwrap();
(cert_der, key_der)
}
// Server cert: used for TLS handshake; client must trust it.
let (srv_cert_der, srv_key_der) = gen_cert();
let srv_server_config =
quinn::ServerConfig::with_single_cert(vec![srv_cert_der.clone()], srv_key_der).unwrap();
let mut roots = rustls::RootCertStore::empty();
roots.add(srv_cert_der).unwrap();
let client_config =
quinn::ClientConfig::with_root_certificates(Arc::new(roots)).unwrap();
// Client-side QuicConfig requires a server_config field; generate a fresh
// cert+key pair so there is no key/cert mismatch.
let (cli_cert_der, cli_key_der) = gen_cert();
let cli_server_config =
quinn::ServerConfig::with_single_cert(vec![cli_cert_der], cli_key_der).unwrap();
let srv_cfg = QuicConfig { server_config: srv_server_config, client_config: client_config.clone() };
let cli_cfg = QuicConfig { server_config: cli_server_config, client_config };
(srv_cfg, cli_cfg)
}
// ─────────────────────────────────────────────────────────────────────────────
// Core helper: run a full push from `src` → `dst` over QUIC loopback.
// Returns (packets_transferred, final_dst).
// ─────────────────────────────────────────────────────────────────────────────
async fn run_quic_push(src: OnionFile, mut dst: OnionFile) -> (u64, OnionFile) {
let (srv_cfg, cli_cfg) = make_quic_configs();
let server = QuicServer::bind(localhost_any(), srv_cfg).await.unwrap();
let server_addr = server.local_addr;
let src_manifest = ClawSyncManifest::from_onion("src", &src, H5_BASE);
// ── Server task ──────────────────────────────────────────────────────────
let server_handle = tokio::spawn(async move {
let conn = server.accept().await.unwrap();
let mut packets_received: u64 = 0;
loop {
let msg = conn.recv().await.unwrap();
match msg {
SyncMessage::ManifestRequest { .. } => {
let dst_manifest = ClawSyncManifest::from_onion("dst", &dst, H5_BASE);
conn.send(&SyncMessage::ManifestResponse { manifest: dst_manifest })
.await
.unwrap();
}
SyncMessage::LayerPacket { packet } => {
let rev = packet.revision;
merge_packets(&mut dst, vec![packet], false).unwrap();
packets_received += 1;
conn.send(&SyncMessage::Ack { revision: rev }).await.unwrap();
}
SyncMessage::SyncComplete { .. } => {
dst.flush().unwrap();
conn.close();
break;
}
other => panic!("server: unexpected {other:?}"),
}
}
(packets_received, dst)
});
// ── Client logic ─────────────────────────────────────────────────────────
let client = quic_connect(server_addr, "localhost", cli_cfg).await.unwrap();
client
.send(&SyncMessage::ManifestRequest {
agent_id: "src".to_string(),
head_revision: src_manifest.head_revision,
revision_count: src_manifest.revision_count,
})
.await
.unwrap();
let remote_head = match client.recv().await.unwrap() {
SyncMessage::ManifestResponse { manifest } => {
if manifest.revision_count == 0 {
NO_PARENT
} else {
manifest.head_revision
}
}
other => panic!("client: expected ManifestResponse, got {other:?}"),
};
let packets = diff_revisions(&src, remote_head).unwrap();
let total_sent = packets.len() as u64;
for packet in packets {
let rev = packet.revision;
client.send(&SyncMessage::LayerPacket { packet }).await.unwrap();
match client.recv().await.unwrap() {
SyncMessage::Ack { revision } => assert_eq!(revision, rev),
other => panic!("client: expected Ack, got {other:?}"),
}
}
client
.send(&SyncMessage::SyncComplete {
revisions_transferred: total_sent,
bytes_transferred: 0,
})
.await
.unwrap();
let (server_received, dst_final) = server_handle.await.unwrap();
assert_eq!(server_received, total_sent);
(total_sent, dst_final)
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
/// Push 5 revisions to an empty destination over QUIC — all 5 must arrive.
#[tokio::test]
async fn quic_push_all_to_empty() {
let (_src_tmp, src) = make_onion(5);
let (_dst_tmp, dst) = make_onion(0);
let (transferred, dst_final) = run_quic_push(src, dst).await;
assert_eq!(transferred, 5);
assert_eq!(dst_final.revision_count(), 5);
}
/// Push when already in sync — zero packets over QUIC.
#[tokio::test]
async fn quic_push_noop_when_in_sync() {
let (_src_tmp, src) = make_onion(3);
let (_dst_tmp, dst) = make_onion(3);
let (transferred, dst_final) = run_quic_push(src, dst).await;
assert_eq!(transferred, 0);
assert_eq!(dst_final.revision_count(), 3);
}
/// Partial delta over QUIC: N=8, K=3 → 5 packets.
#[tokio::test]
async fn quic_push_partial_delta() {
const K: u8 = 3;
const N: u8 = 8;
let (_src_tmp, src) = make_onion(N);
let (_dst_tmp, dst) = make_onion(K);
let (transferred, dst_final) = run_quic_push(src, dst).await;
assert_eq!(transferred, (N - K) as u64);
assert_eq!(dst_final.revision_count(), N as u64);
}
/// BLAKE3 hashes must survive the QUIC wire intact.
#[tokio::test]
async fn quic_revision_hashes_match_after_sync() {
let (_src_tmp, src) = make_onion(4);
let (_dst_tmp, dst) = make_onion(0);
let src_revisions = src.list_revisions();
let (_, dst_final) = run_quic_push(src, dst).await;
let dst_revisions = dst_final.list_revisions();
assert_eq!(src_revisions.len(), dst_revisions.len());
for (s, d) in src_revisions.iter().zip(dst_revisions.iter()) {
assert_eq!(s.blake3_hex, d.blake3_hex, "rev {} hash mismatch", s.revision);
assert_eq!(s.annotation, d.annotation);
}
}
/// Large push over QUIC: 20→7 = 13 packets.
#[tokio::test]
async fn quic_large_push_correct_count() {
const K: u8 = 7;
const N: u8 = 20;
let (_src_tmp, src) = make_onion(N);
let (_dst_tmp, dst) = make_onion(K);
let (transferred, dst_final) = run_quic_push(src, dst).await;
assert_eq!(transferred, (N - K) as u64);
assert_eq!(dst_final.revision_count(), N as u64);
}
// ─────────────────────────────────────────────────────────────────────────────
// Pull helper: server has `src`; client starts with `dst` and pulls from it.
// Returns (packets_transferred, final_client_onion).
// ─────────────────────────────────────────────────────────────────────────────
async fn run_quic_pull(src: OnionFile, mut dst: OnionFile) -> (u64, OnionFile) {
let (srv_cfg, cli_cfg) = make_quic_configs();
let server = QuicServer::bind(localhost_any(), srv_cfg).await.unwrap();
let server_addr = server.local_addr;
// ── Server task ──────────────────────────────────────────────────────────
let server_handle = tokio::spawn(async move {
let conn = server.accept().await.unwrap();
let client_rev_count = match conn.recv().await.unwrap() {
SyncMessage::ManifestRequest { revision_count, .. } => revision_count,
other => panic!("server: expected ManifestRequest, got {other:?}"),
};
let srv_manifest = ClawSyncManifest::from_onion("srv", &src, H5_BASE);
conn.send(&SyncMessage::ManifestResponse { manifest: srv_manifest })
.await
.unwrap();
if src.revision_count() > client_rev_count {
let remote_head = if client_rev_count == 0 {
NO_PARENT
} else {
client_rev_count - 1
};
let packets = diff_revisions(&src, remote_head).unwrap();
let total = packets.len() as u64;
let mut bytes_sent = 0u64;
for packet in packets {
bytes_sent += packet.page_data_size() as u64;
conn.send(&SyncMessage::LayerPacket { packet }).await.unwrap();
match conn.recv().await.unwrap() {
SyncMessage::Ack { .. } => {}
other => panic!("server: expected Ack, got {other:?}"),
}
}
conn.send(&SyncMessage::SyncComplete {
revisions_transferred: total,
bytes_transferred: bytes_sent,
})
.await
.unwrap();
} else {
match conn.recv().await.unwrap() {
SyncMessage::SyncComplete { .. } => {}
other => panic!("server: expected SyncComplete, got {other:?}"),
}
}
// Quinn resets pending streams when a Connection is dropped. Give the
// client a moment to accept the final SyncComplete stream before this
// task (and thus `conn`) is dropped. This mirrors the pattern used in
// the quic_ack_roundtrip unit test.
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
});
// ── Client logic ─────────────────────────────────────────────────────────
let client = quic_connect(server_addr, "localhost", cli_cfg).await.unwrap();
let local_rev_count = dst.revision_count();
client
.send(&SyncMessage::ManifestRequest {
agent_id: "client".to_string(),
head_revision: local_rev_count.saturating_sub(1),
revision_count: local_rev_count,
})
.await
.unwrap();
let server_rev_count = match client.recv().await.unwrap() {
SyncMessage::ManifestResponse { manifest } => manifest.revision_count,
other => panic!("client: expected ManifestResponse, got {other:?}"),
};
if server_rev_count <= local_rev_count {
client
.send(&SyncMessage::SyncComplete {
revisions_transferred: 0,
bytes_transferred: 0,
})
.await
.unwrap();
server_handle.await.unwrap();
return (0, dst);
}
let mut packets = Vec::new();
loop {
match client.recv().await.unwrap() {
SyncMessage::LayerPacket { packet } => {
let rev = packet.revision;
client.send(&SyncMessage::Ack { revision: rev }).await.unwrap();
packets.push(packet);
}
SyncMessage::SyncComplete { revisions_transferred, .. } => {
merge_packets(&mut dst, packets, true).unwrap();
server_handle.await.unwrap();
return (revisions_transferred, dst);
}
other => panic!("client: unexpected {other:?}"),
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Pull tests
// ─────────────────────────────────────────────────────────────────────────────
/// Pull all revisions from a full QUIC server to an empty client.
#[tokio::test]
async fn quic_pull_all_from_server() {
let (_srv_tmp, srv) = make_onion(5);
let (_cli_tmp, cli) = make_onion(0);
let (transferred, cli_final) = run_quic_pull(srv, cli).await;
assert_eq!(transferred, 5);
assert_eq!(cli_final.revision_count(), 5);
}
/// Pull when already in sync over QUIC — zero packets.
#[tokio::test]
async fn quic_pull_noop_when_already_in_sync() {
let (_srv_tmp, srv) = make_onion(4);
let (_cli_tmp, cli) = make_onion(4);
let (transferred, cli_final) = run_quic_pull(srv, cli).await;
assert_eq!(transferred, 0);
assert_eq!(cli_final.revision_count(), 4);
}
/// Partial pull over QUIC: server has N, client has K → N-K packets.
#[tokio::test]
async fn quic_pull_partial_delta() {
const K: u8 = 2;
const N: u8 = 7;
let (_srv_tmp, srv) = make_onion(N);
let (_cli_tmp, cli) = make_onion(K);
let (transferred, cli_final) = run_quic_pull(srv, cli).await;
assert_eq!(transferred, (N - K) as u64);
assert_eq!(cli_final.revision_count(), N as u64);
}
/// BLAKE3 hashes must survive the QUIC pull wire path intact.
#[tokio::test]
async fn quic_pull_revision_hashes_match() {
let (_srv_tmp, srv) = make_onion(4);
let (_cli_tmp, cli) = make_onion(0);
let srv_revisions = srv.list_revisions();
let (_, cli_final) = run_quic_pull(srv, cli).await;
let cli_revisions = cli_final.list_revisions();
assert_eq!(srv_revisions.len(), cli_revisions.len());
for (s, c) in srv_revisions.iter().zip(cli_revisions.iter()) {
assert_eq!(s.blake3_hex, c.blake3_hex, "rev {} hash mismatch", s.revision);
assert_eq!(s.annotation, c.annotation);
}
}
+455
View File
@@ -0,0 +1,455 @@
//! End-to-end TCP sync integration test.
//!
//! Scenario: a source node has N revisions; a destination node already has K
//! revisions. The test runs the full wire protocol over a real TCP loopback
//! socket and asserts that exactly N-K layer packets are transferred.
//!
//! Protocol flow (client = source, server = destination):
//! ```text
//! Client Server
//! │── ManifestRequest ─────────▶│
//! │◀─ ManifestResponse ─────────│
//! │── LayerPacket (rev K) ────▶ │ (for each missing revision)
//! │◀─ Ack { revision: K } ──────│
//! │ … │
//! │── SyncComplete ─────────────▶│
//! ```
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use clawhdf5_onion::writer::OnionFile;
use clawsync_onion::{
differ::diff_revisions,
manifest::ClawSyncManifest,
merger::merge_packets,
};
use clawsync_transport::{
protocol::SyncMessage,
tcp::{TcpConnection, TcpServer},
};
use tempfile::NamedTempFile;
fn localhost_any() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)
}
const H5_BASE: &[u8] = b"\x89HDF\r\n\x1a\n";
/// Create an `OnionFile` with `n` committed revisions.
/// Each revision writes a single page (4 KiB) at offset 0, filled with `i`.
fn make_onion(n: u8) -> (NamedTempFile, OnionFile) {
let tmp = NamedTempFile::new().unwrap();
let h5_path = tmp.path().with_extension("h5");
std::fs::write(&h5_path, H5_BASE).unwrap();
let mut onion = OnionFile::create(&h5_path, 4096).unwrap();
for i in 0..n {
let mut s = onion.begin_session(None).unwrap();
s.record_page(0, &vec![i; 4096]);
onion
.commit_session(s, Some(&format!("rev {i}")))
.unwrap();
}
(tmp, onion)
}
// ─────────────────────────────────────────────────────────────────────────────
// Core helper: run a full push from `src` → `dst` over TCP and return the
// number of LayerPackets actually transferred.
// ─────────────────────────────────────────────────────────────────────────────
async fn run_push(
src: OnionFile,
mut dst: OnionFile,
) -> (u64, OnionFile) {
// Bind the server; the OS picks a free port.
let server = TcpServer::bind(localhost_any()).await.unwrap();
let server_addr = server.local_addr;
// Build the client-side manifest now (before moving `src` into the task).
let src_manifest = ClawSyncManifest::from_onion("src", &src, H5_BASE);
// ── Server task ──────────────────────────────────────────────────────────
// Returns (packets_received, dst) so the caller can inspect the result.
let server_handle = tokio::spawn(async move {
let (mut conn, _) = server.accept().await.unwrap();
let mut packets_received: u64 = 0;
loop {
let msg = conn.recv().await.unwrap();
match msg {
SyncMessage::ManifestRequest { head_revision, .. } => {
// Build our local manifest and send it back.
let dst_manifest =
ClawSyncManifest::from_onion("dst", &dst, H5_BASE);
conn.send(&SyncMessage::ManifestResponse {
manifest: dst_manifest,
})
.await
.unwrap();
let _ = head_revision; // used for negotiation by client
}
SyncMessage::LayerPacket { packet } => {
let rev = packet.revision;
merge_packets(&mut dst, vec![packet], false).unwrap();
packets_received += 1;
conn.send(&SyncMessage::Ack { revision: rev })
.await
.unwrap();
}
SyncMessage::SyncComplete { .. } => {
// Flush and stop accepting.
dst.flush().unwrap();
break;
}
other => panic!("server: unexpected message: {other:?}"),
}
}
(packets_received, dst)
});
// ── Client logic ─────────────────────────────────────────────────────────
let mut client = TcpConnection::connect(server_addr).await.unwrap();
// 1. Send manifest request.
client
.send(&SyncMessage::ManifestRequest {
agent_id: "src".to_string(),
head_revision: src_manifest.head_revision,
revision_count: src_manifest.revision_count,
})
.await
.unwrap();
// 2. Receive remote manifest; determine what to push.
let remote_head = match client.recv().await.unwrap() {
SyncMessage::ManifestResponse { manifest } => {
if manifest.revision_count == 0 {
clawhdf5_onion::format::NO_PARENT
} else {
manifest.head_revision
}
}
other => panic!("client: expected ManifestResponse, got {other:?}"),
};
// 3. Compute and send missing revisions.
let packets = diff_revisions(&src, remote_head).unwrap();
let total_sent = packets.len() as u64;
for packet in packets {
let rev = packet.revision;
client
.send(&SyncMessage::LayerPacket { packet })
.await
.unwrap();
// Wait for ack before sending next packet (ordered, reliable).
match client.recv().await.unwrap() {
SyncMessage::Ack { revision } => assert_eq!(revision, rev),
other => panic!("client: expected Ack, got {other:?}"),
}
}
// 4. Signal completion.
client
.send(&SyncMessage::SyncComplete {
revisions_transferred: total_sent,
bytes_transferred: 0,
})
.await
.unwrap();
let (server_received, dst_final) = server_handle.await.unwrap();
assert_eq!(server_received, total_sent, "server count must match client send count");
(total_sent, dst_final)
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
/// Push 5 revisions to an empty destination — all 5 must be transferred.
#[tokio::test]
async fn push_all_revisions_to_empty_destination() {
let (_src_tmp, src) = make_onion(5);
let (_dst_tmp, dst) = make_onion(0);
let (transferred, dst_final) = run_push(src, dst).await;
assert_eq!(transferred, 5, "all 5 revisions should be transferred");
assert_eq!(dst_final.revision_count(), 5);
}
/// Push when destination is already at the same HEAD — nothing to transfer.
#[tokio::test]
async fn push_when_already_in_sync() {
let (_src_tmp, src) = make_onion(3);
let (_dst_tmp, dst) = make_onion(3);
let (transferred, dst_final) = run_push(src, dst).await;
assert_eq!(transferred, 0, "nothing should be transferred when in sync");
assert_eq!(dst_final.revision_count(), 3);
}
/// Destination has K revisions, source has N; exactly N-K should transfer.
#[tokio::test]
async fn push_partial_delta_transfers_exactly_n_minus_k() {
const K: u8 = 3;
const N: u8 = 8;
let (_src_tmp, src) = make_onion(N);
let (_dst_tmp, dst) = make_onion(K);
let (transferred, dst_final) = run_push(src, dst).await;
let expected = (N - K) as u64;
assert_eq!(
transferred, expected,
"exactly N-K={expected} revisions should be transferred"
);
assert_eq!(dst_final.revision_count(), N as u64);
}
/// After sync the destination can reconstruct every revision that the source has.
#[tokio::test]
async fn destination_revisions_match_source_after_sync() {
let (_src_tmp, src) = make_onion(4);
let (_dst_tmp, dst) = make_onion(0);
// Capture source revision metadata before the move.
let src_revisions = src.list_revisions();
let (_transferred, dst_final) = run_push(src, dst).await;
let dst_revisions = dst_final.list_revisions();
assert_eq!(src_revisions.len(), dst_revisions.len());
for (s, d) in src_revisions.iter().zip(dst_revisions.iter()) {
assert_eq!(s.revision, d.revision, "revision numbers must match");
assert_eq!(
s.annotation, d.annotation,
"annotations must survive the wire"
);
assert_eq!(
s.blake3_hex, d.blake3_hex,
"BLAKE3 hashes must be identical — page data is intact"
);
}
}
/// Verify idempotency: pushing the same revisions twice leaves the destination
/// with exactly the same number of revisions (no duplicates).
#[tokio::test]
async fn push_twice_is_idempotent() {
let (_src_tmp1, src1) = make_onion(3);
let (_src_tmp2, _src2) = make_onion(3);
let (_dst_tmp, dst) = make_onion(0);
// First push.
let (_t1, dst_after_first) = run_push(src1, dst).await;
assert_eq!(dst_after_first.revision_count(), 3);
// Second push of the identical 3 revisions.
// `run_push` moves `dst_after_first`, so we need to rebuild one.
// Re-open the same .onion file from disk.
let (_src_tmp3, src3) = make_onion(3);
let (_dst_tmp2, dst2) = make_onion(3); // simulates "already has all"
let (t2, dst_final) = run_push(src3, dst2).await;
assert_eq!(t2, 0, "second push should transfer nothing");
assert_eq!(dst_final.revision_count(), 3, "no duplicate revisions");
let _ = dst_after_first; // keep alive until here
}
/// Large push: 20 revisions, destination has 7 — assert 13 packets.
#[tokio::test]
async fn large_push_correct_packet_count() {
const K: u8 = 7;
const N: u8 = 20;
let (_src_tmp, src) = make_onion(N);
let (_dst_tmp, dst) = make_onion(K);
let (transferred, dst_final) = run_push(src, dst).await;
assert_eq!(transferred, (N - K) as u64);
assert_eq!(dst_final.revision_count(), N as u64);
}
// ─────────────────────────────────────────────────────────────────────────────
// Pull helper: server has `src`; client starts with `dst` and pulls from it.
// Returns (packets_transferred, final_client_onion).
// ─────────────────────────────────────────────────────────────────────────────
async fn run_pull(
src: OnionFile, // server-side onion (richer)
mut dst: OnionFile, // client-side onion (missing revisions)
) -> (u64, OnionFile) {
let server = TcpServer::bind(localhost_any()).await.unwrap();
let server_addr = server.local_addr;
// ── Server task ──────────────────────────────────────────────────────────
let server_handle = tokio::spawn(async move {
let (mut conn, _) = server.accept().await.unwrap();
// 1. Receive client's ManifestRequest.
let client_rev_count = match conn.recv().await.unwrap() {
SyncMessage::ManifestRequest { revision_count, .. } => revision_count,
other => panic!("server: expected ManifestRequest, got {other:?}"),
};
// 2. Send back the server manifest.
let srv_manifest = ClawSyncManifest::from_onion("srv", &src, H5_BASE);
conn.send(&SyncMessage::ManifestResponse { manifest: srv_manifest })
.await
.unwrap();
// 3. Server has more revisions — push the delta to the client.
if src.revision_count() > client_rev_count {
let remote_head = if client_rev_count == 0 {
clawhdf5_onion::format::NO_PARENT
} else {
client_rev_count - 1
};
let packets = diff_revisions(&src, remote_head).unwrap();
let total = packets.len() as u64;
let mut bytes_sent = 0u64;
for packet in packets {
bytes_sent += packet.page_data_size() as u64;
conn.send(&SyncMessage::LayerPacket { packet }).await.unwrap();
match conn.recv().await.unwrap() {
SyncMessage::Ack { .. } => {}
other => panic!("server: expected Ack, got {other:?}"),
}
}
conn.send(&SyncMessage::SyncComplete {
revisions_transferred: total,
bytes_transferred: bytes_sent,
})
.await
.unwrap();
} else {
// Already in sync: wait for client's SyncComplete.
match conn.recv().await.unwrap() {
SyncMessage::SyncComplete { .. } => {}
other => panic!("server: expected SyncComplete, got {other:?}"),
}
}
});
// ── Client logic ─────────────────────────────────────────────────────────
let mut client = TcpConnection::connect(server_addr).await.unwrap();
let local_rev_count = dst.revision_count();
// 1. Send manifest request.
client
.send(&SyncMessage::ManifestRequest {
agent_id: "client".to_string(),
head_revision: local_rev_count.saturating_sub(1),
revision_count: local_rev_count,
})
.await
.unwrap();
// 2. Receive server manifest; check if there is anything to pull.
let server_rev_count = match client.recv().await.unwrap() {
SyncMessage::ManifestResponse { manifest } => manifest.revision_count,
other => panic!("client: expected ManifestResponse, got {other:?}"),
};
if server_rev_count <= local_rev_count {
// Nothing to pull — tell server we're done.
client
.send(&SyncMessage::SyncComplete {
revisions_transferred: 0,
bytes_transferred: 0,
})
.await
.unwrap();
server_handle.await.unwrap();
return (0, dst);
}
// 3. Receive packets from server.
let mut packets = Vec::new();
loop {
match client.recv().await.unwrap() {
SyncMessage::LayerPacket { packet } => {
let rev = packet.revision;
client.send(&SyncMessage::Ack { revision: rev }).await.unwrap();
packets.push(packet);
}
SyncMessage::SyncComplete { revisions_transferred, .. } => {
merge_packets(&mut dst, packets, true).unwrap();
server_handle.await.unwrap();
return (revisions_transferred, dst);
}
other => panic!("client: unexpected {other:?}"),
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Pull tests
// ─────────────────────────────────────────────────────────────────────────────
/// Pull all revisions from a full server to an empty client.
#[tokio::test]
async fn pull_all_from_server() {
let (_srv_tmp, srv) = make_onion(5);
let (_cli_tmp, cli) = make_onion(0);
let (transferred, cli_final) = run_pull(srv, cli).await;
assert_eq!(transferred, 5);
assert_eq!(cli_final.revision_count(), 5);
}
/// Pull when client and server are already in sync — zero packets.
#[tokio::test]
async fn pull_noop_when_already_in_sync() {
let (_srv_tmp, srv) = make_onion(4);
let (_cli_tmp, cli) = make_onion(4);
let (transferred, cli_final) = run_pull(srv, cli).await;
assert_eq!(transferred, 0, "nothing to pull when already in sync");
assert_eq!(cli_final.revision_count(), 4);
}
/// Pull partial delta: server has N, client has K — client receives N-K packets.
#[tokio::test]
async fn pull_partial_delta() {
const K: u8 = 2;
const N: u8 = 7;
let (_srv_tmp, srv) = make_onion(N);
let (_cli_tmp, cli) = make_onion(K);
let (transferred, cli_final) = run_pull(srv, cli).await;
assert_eq!(transferred, (N - K) as u64);
assert_eq!(cli_final.revision_count(), N as u64);
}
/// BLAKE3 hashes must survive the pull wire path intact.
#[tokio::test]
async fn pull_revision_hashes_match() {
let (_srv_tmp, srv) = make_onion(4);
let (_cli_tmp, cli) = make_onion(0);
let srv_revisions = srv.list_revisions();
let (_, cli_final) = run_pull(srv, cli).await;
let cli_revisions = cli_final.list_revisions();
assert_eq!(srv_revisions.len(), cli_revisions.len());
for (s, c) in srv_revisions.iter().zip(cli_revisions.iter()) {
assert_eq!(s.blake3_hex, c.blake3_hex, "rev {} hash mismatch", s.revision);
assert_eq!(s.annotation, c.annotation);
}
}