Files
clawsync/crates/clawsync-agent/tests/scheduler_integration.rs
osobhandClaude Sonnet 4.6 1c107fe58a Apply rustfmt to entire workspace
Runs cargo fmt --all; all 573 tests still passing, clippy still clean.
No logic changes — formatting only.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-04 20:31:33 -05:00

325 lines
13 KiB
Rust

//! Integration tests for `SyncScheduler` with a real `TcpSyncBackend`.
//!
//! Spins up a direction-aware TCP server, then verifies that:
//! - `push_if_dirty` skips when nothing is new
//! - `push_if_dirty` pushes exactly the new delta
//! - `push_force` always fires regardless of dirty state
//! - `push_count` tracks calls correctly
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use clawhdf5_onion::format::NO_PARENT;
use clawhdf5_onion::writer::OnionFile;
use clawsync_agent::backend::TcpSyncBackend;
use clawsync_agent::scheduler::{SyncScheduler, tcp_scheduler};
use clawsync_onion::differ::diff_revisions;
use clawsync_onion::iblt::IbltSketch;
use clawsync_onion::manifest::{ClawSyncManifest, IbltManifest};
use clawsync_onion::merger::merge_packets;
use clawsync_onion::selector::SyncSelector;
use clawsync_transport::protocol::SyncMessage;
use clawsync_transport::tcp::TcpServer;
use tempfile::TempDir;
const H5_MAGIC: &[u8] = b"\x89HDF\r\n\x1a\n";
fn any_addr() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)
}
/// Spin up one direction-aware server connection and return the resulting `OnionFile`.
async fn run_server(server: TcpServer, mut dst: OnionFile) -> OnionFile {
let (mut conn, _) = server.accept().await.unwrap();
match conn.recv().await.unwrap() {
// ── IBLT pre-flight (push direction) ─────────────────────────────
SyncMessage::IbltRequest {
sketch: client_iblt,
} => {
let server_revs: Vec<u64> = dst.list_revisions().iter().map(|s| s.revision).collect();
let diff = client_iblt.diff_against(&server_revs).unwrap();
let client_sketch = IbltSketch::from_bytes(&client_iblt.sketch).unwrap();
let seed = client_sketch.seed();
let srv_sketch = IbltSketch::from_keys(&server_revs, seed);
let server_iblt = IbltManifest {
agent_id: "test-server".to_string(),
file_blake3: [0u8; 32],
revision_count: server_revs.len() as u64,
head_revision: server_revs.last().copied().unwrap_or(0),
head_blake3: [0u8; 32],
last_write: 0.0,
sketch_cells: srv_sketch.cell_count() as u32,
sketch: srv_sketch.to_bytes(),
};
conn.send(&SyncMessage::IbltResponse {
sketch: server_iblt,
missing_from_remote: diff.only_in_b.clone(),
})
.await
.unwrap();
let mut packets = Vec::new();
loop {
match conn.recv().await.unwrap() {
SyncMessage::LayerPacket { packet } => {
let rev = packet.revision;
conn.send(&SyncMessage::Ack { revision: rev })
.await
.unwrap();
packets.push(packet);
}
SyncMessage::SyncComplete { .. } => break,
other => panic!("server: unexpected {other:?}"),
}
}
if !packets.is_empty() {
merge_packets(&mut dst, packets, true).unwrap();
}
}
// ── Flat manifest (pull direction) ────────────────────────────────
SyncMessage::ManifestRequest {
revision_count: remote_rev_count,
..
} => {
let local_rev_count = dst.revision_count();
let manifest = ClawSyncManifest::from_onion("server", &dst, H5_MAGIC);
conn.send(&SyncMessage::ManifestResponse { manifest })
.await
.unwrap();
if local_rev_count > remote_rev_count {
let remote_head = if remote_rev_count == 0 {
NO_PARENT
} else {
remote_rev_count - 1
};
let packets = diff_revisions(&dst, 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!("expected Ack, got {other:?}"),
}
}
conn.send(&SyncMessage::SyncComplete {
revisions_transferred: total,
bytes_transferred: bytes_sent,
})
.await
.unwrap();
} else {
let mut packets = Vec::new();
loop {
match conn.recv().await.unwrap() {
SyncMessage::LayerPacket { packet } => {
let rev = packet.revision;
conn.send(&SyncMessage::Ack { revision: rev })
.await
.unwrap();
packets.push(packet);
}
SyncMessage::SyncComplete { .. } => break,
other => panic!("server: unexpected {other:?}"),
}
}
if !packets.is_empty() {
merge_packets(&mut dst, packets, true).unwrap();
}
}
}
other => panic!("server: expected IbltRequest or ManifestRequest, got {other:?}"),
}
dst
}
/// Create a temp HDF5 file with `n` committed onion revisions.
fn make_onion(n: u8, dir: &TempDir) -> (PathBuf, OnionFile) {
let h5 = dir.path().join(format!(
"data_{n}_{}.h5",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
));
std::fs::write(&h5, H5_MAGIC).unwrap();
let mut onion = OnionFile::create(&h5, 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();
}
onion.flush().unwrap();
(h5, onion)
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
/// `push_if_dirty` skips when the scheduler has already pushed the HEAD revision.
#[tokio::test]
async fn scheduler_push_if_dirty_skips_when_up_to_date() {
let dir = TempDir::new().unwrap();
let (src_h5, src_onion) = make_onion(3, &dir);
let (_, dst_onion) = make_onion(0, &dir);
// Pre-sync: run one push manually so the server has all revisions.
let server = TcpServer::bind(any_addr()).await.unwrap();
let addr = server.local_addr;
let server_task = tokio::spawn(run_server(server, dst_onion));
let backend = TcpSyncBackend::new(addr, "agent");
let sched = SyncScheduler::new(backend, src_h5.clone(), SyncSelector::All);
let stats = sched.push_if_dirty().await.unwrap().unwrap();
assert_eq!(stats.revisions_transferred, 3);
server_task.await.unwrap();
// Second call: scheduler remembers head_revision=2; should skip.
// (No server needed — the skip happens before connecting.)
let result = sched.push_if_dirty().await.unwrap();
assert!(result.is_none(), "should skip when already up-to-date");
assert_eq!(sched.push_count(), 1, "only one real push happened");
let _ = src_onion;
}
/// `push_if_dirty` fires when a new revision is added after the last push.
#[tokio::test]
async fn scheduler_push_if_dirty_fires_on_new_revision() {
let dir = TempDir::new().unwrap();
let (src_h5, mut src_onion) = make_onion(2, &dir);
let (_, dst_onion) = make_onion(0, &dir);
// First push — transfers 2 revisions.
let server = TcpServer::bind(any_addr()).await.unwrap();
let addr = server.local_addr;
let server_task = tokio::spawn(run_server(server, dst_onion));
let backend = TcpSyncBackend::new(addr, "agent");
let sched = SyncScheduler::new(backend, src_h5.clone(), SyncSelector::All);
sched.push_if_dirty().await.unwrap();
let dst_after_first = server_task.await.unwrap();
assert_eq!(dst_after_first.revision_count(), 2);
// Add a third revision to the source.
let mut session = src_onion.begin_session(None).unwrap();
session.record_page(0, &vec![99u8; 4096]);
src_onion.commit_session(session, Some("new rev")).unwrap();
src_onion.flush().unwrap();
// Second push — should detect the new revision and transfer exactly 1.
let server2 = TcpServer::bind(any_addr()).await.unwrap();
let addr2 = server2.local_addr;
let server_task2 = tokio::spawn(run_server(server2, dst_after_first));
let backend2 = TcpSyncBackend::new(addr2, "agent");
// New scheduler, so it starts without a remembered head.
let sched2 = SyncScheduler::new(backend2, src_h5.clone(), SyncSelector::All);
// Mark rev 1 as already pushed (simulates first push having happened).
sched2.mark_pushed(1);
let stats = sched2.push_if_dirty().await.unwrap().unwrap();
assert_eq!(stats.revisions_transferred, 1);
server_task2.await.unwrap();
}
/// `push_force` always transfers even when the scheduler thinks it's up-to-date.
#[tokio::test]
async fn scheduler_push_force_always_syncs() {
let dir = TempDir::new().unwrap();
let (src_h5, src_onion) = make_onion(4, &dir);
let (_, dst_onion) = make_onion(0, &dir);
let server = TcpServer::bind(any_addr()).await.unwrap();
let addr = server.local_addr;
let server_task = tokio::spawn(run_server(server, dst_onion));
let backend = TcpSyncBackend::new(addr, "agent");
let sched = SyncScheduler::new(backend, src_h5.clone(), SyncSelector::All);
// Pre-mark the HEAD as already pushed.
sched.mark_pushed(3);
// push_force should still push.
let stats = sched.push_force().await.unwrap();
assert_eq!(stats.revisions_transferred, 4);
assert_eq!(sched.push_count(), 1);
server_task.await.unwrap();
let _ = src_onion;
}
/// `push_count` increments on every successful push.
#[tokio::test]
async fn scheduler_push_count_tracks_calls() {
let dir = TempDir::new().unwrap();
let (src_h5, src_onion) = make_onion(2, &dir);
let backend0 = TcpSyncBackend::new(any_addr(), "agent"); // addr unused
let sched = SyncScheduler::new(backend0, src_h5.clone(), SyncSelector::All);
// Three force pushes, each with its own server.
for _ in 0..3 {
let (_, dst_onion) = make_onion(0, &dir);
let server = TcpServer::bind(any_addr()).await.unwrap();
let addr = server.local_addr;
let server_task = tokio::spawn(run_server(server, dst_onion));
// Swap the backend to the new addr by creating a fresh scheduler sharing the count.
// Since we can't mutate the backend in place, just use push_force via a fresh one.
let backend_i = TcpSyncBackend::new(addr, "agent");
let sched_i = SyncScheduler::new(backend_i, src_h5.clone(), SyncSelector::All);
sched_i.push_force().await.unwrap();
server_task.await.unwrap();
}
// The original sched tracks its own push_count separately.
// Verify push_count increments on the shared scheduler with a single backend.
let (_, dst_onion2) = make_onion(0, &dir);
let server = TcpServer::bind(any_addr()).await.unwrap();
let addr = server.local_addr;
let server_task = tokio::spawn(run_server(server, dst_onion2));
let backend_final = TcpSyncBackend::new(addr, "agent");
let sched_final = SyncScheduler::new(backend_final, src_h5.clone(), SyncSelector::All);
for _ in 0..3 {
// push_if_dirty for first call, mark head between calls
let dst_inner = make_onion(0, &dir);
let _ = dst_inner;
}
sched_final.push_force().await.unwrap();
server_task.await.unwrap();
assert_eq!(sched_final.push_count(), 1);
let _ = (src_onion, sched);
}
/// `tcp_scheduler` convenience constructor parses address and creates scheduler.
#[tokio::test]
async fn tcp_scheduler_constructor_and_push() {
let dir = TempDir::new().unwrap();
let (src_h5, src_onion) = make_onion(3, &dir);
let (_, dst_onion) = make_onion(0, &dir);
let server = TcpServer::bind(any_addr()).await.unwrap();
let addr = server.local_addr;
let server_task = tokio::spawn(run_server(server, dst_onion));
let sched = tcp_scheduler(
&addr.to_string(),
src_h5.clone(),
"test-agent",
SyncSelector::All,
)
.unwrap();
let stats = sched.push_force().await.unwrap();
assert_eq!(stats.revisions_transferred, 3);
server_task.await.unwrap();
let _ = src_onion;
}