// Integration-test exemptions (same posture as the tidaldb integration tests): // unwrap/unwrap_err on known-good fixtures and short-lived read guards are // idiomatic here. #![allow(clippy::unwrap_used, clippy::significant_drop_tightening)] //! Transport trait contract tests for `GrpcTransport`. //! //! Mirrors the test patterns from `InProcessTransport` but over gRPC on localhost. use std::{ collections::HashMap, net::SocketAddr, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, thread, time::{Duration, Instant}, }; use tidal_net::{GrpcTransport, config::GrpcTransportConfig}; use tidaldb::replication::{ WalSegmentId, shard::{RegionId, ShardId}, transport::{Transport, TransportError, WalSegmentPayload}, }; /// Get a unique listen address using port 0 (OS-assigned). /// Since tonic doesn't support port 0, we bind a `TcpListener` to find a free port. fn free_addr() -> SocketAddr { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); listener.local_addr().unwrap() } fn make_config( shard: ShardId, listen: SocketAddr, peers: HashMap, ) -> GrpcTransportConfig { GrpcTransportConfig { local_shard: shard, listen_addr: listen, peers, insecure: true, ..Default::default() } } fn make_payload(shard: ShardId, seqno: u64) -> WalSegmentPayload { WalSegmentPayload { id: WalSegmentId::new(RegionId::SINGLE, shard, seqno), bytes: vec![0xAB; 100], event_count: 5, leader_last_seq: seqno, } } /// Build two `GrpcTransports` on localhost that can talk to each other. fn build_pair() -> (GrpcTransport, GrpcTransport) { let addr0 = free_addr(); let addr1 = free_addr(); let config0 = make_config(ShardId(0), addr0, HashMap::from([(ShardId(1), addr1)])); let config1 = make_config(ShardId(1), addr1, HashMap::from([(ShardId(0), addr0)])); let t0 = GrpcTransport::new(config0).expect("transport 0"); let t1 = GrpcTransport::new(config1).expect("transport 1"); // Give servers a moment to start. thread::sleep(Duration::from_millis(100)); (t0, t1) } #[test] fn send_and_receive_between_shards() { let (t0, t1) = build_pair(); // Shard 0 sends to Shard 1. let payload = make_payload(ShardId(0), 42); t0.send_segment(ShardId(1), payload).unwrap(); // Shard 1 receives it. let received = t1.recv_segment().unwrap(); assert_eq!(received.id.seqno, 42); assert_eq!(received.event_count, 5); assert_eq!(received.bytes.len(), 100); } #[test] fn send_to_unknown_peer_fails() { let addr0 = free_addr(); let config0 = make_config(ShardId(0), addr0, HashMap::new()); let t0 = GrpcTransport::new(config0).expect("transport 0"); thread::sleep(Duration::from_millis(50)); let result = t0.send_segment(ShardId(99), make_payload(ShardId(0), 1)); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), TransportError::UnknownPeer(_) )); } #[test] fn payload_too_large_rejected() { let addr0 = free_addr(); let addr1 = free_addr(); let config0 = make_config(ShardId(0), addr0, HashMap::from([(ShardId(1), addr1)])); let t0 = GrpcTransport::new(config0).expect("transport 0"); thread::sleep(Duration::from_millis(50)); let payload = WalSegmentPayload { id: WalSegmentId::new(RegionId::SINGLE, ShardId(0), 1), bytes: vec![0u8; 64 * 1024 * 1024 + 1], event_count: 0, leader_last_seq: 1, }; let result = t0.send_segment(ShardId(1), payload); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), TransportError::PayloadTooLarge { .. } )); } #[test] fn local_shard_returns_correct_id() { let addr = free_addr(); let config = make_config(ShardId(7), addr, HashMap::new()); let t = GrpcTransport::new(config).expect("transport"); assert_eq!(t.local_shard(), ShardId(7)); } #[test] fn shutdown_unparks_recv_segment_and_thread_exits() { // Models the real receiver wiring: a worker thread holds its own // Arc clone and parks in recv_segment. Without a cancellation // path that thread never returns (recv_segment only yields None when every // server-side inbound_tx sender drops), so it would leak until process exit // AND its Arc would keep GrpcTransport::drop from ever running. shutdown_receivers // breaks the cycle: a parked recv_segment returns None deterministically. let addr = free_addr(); let config = make_config(ShardId(0), addr, HashMap::new()); let transport = Arc::new(GrpcTransport::new(config).expect("transport")); thread::sleep(Duration::from_millis(50)); let returned_none = Arc::new(AtomicBool::new(false)); let worker_transport = Arc::clone(&transport); let worker_flag = Arc::clone(&returned_none); let receiver = thread::spawn(move || { // No segment will ever arrive (no peers); this parks until shutdown. let segment = worker_transport.recv_segment(); worker_flag.store(segment.is_none(), Ordering::SeqCst); // Drop the worker's Arc clone on return so only the test's Arc remains. }); // Let the worker reach the parked recv_segment. thread::sleep(Duration::from_millis(100)); assert!( !receiver.is_finished(), "receiver should still be parked in recv_segment before shutdown" ); // Trip the cancellation path. transport.shutdown_receivers(); // The parked recv_segment must return None promptly and the thread must exit. let deadline = Instant::now() + Duration::from_secs(5); while !receiver.is_finished() && Instant::now() < deadline { thread::sleep(Duration::from_millis(10)); } assert!( receiver.is_finished(), "recv_segment must return after shutdown_receivers so the thread can exit" ); receiver.join().expect("receiver thread joins cleanly"); assert!( returned_none.load(Ordering::SeqCst), "recv_segment must return None on shutdown (the receiver's exit condition)" ); // Only the test's Arc remains; dropping it runs GrpcTransport::drop without leak. assert_eq!(Arc::strong_count(&transport), 1); drop(transport); } #[test] fn multiple_segments_fifo_order() { let (t0, t1) = build_pair(); for seq in 1..=5u64 { t0.send_segment(ShardId(1), make_payload(ShardId(0), seq)) .unwrap(); } // Brief pause to let segments flow through gRPC. thread::sleep(Duration::from_millis(50)); for expected_seq in 1..=5u64 { let received = t1.recv_segment().unwrap(); assert_eq!(received.id.seqno, expected_seq); } }