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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user