Skip to main content

multiprobe/
bidirectional.rs

1//! Bidirectional path probing for detecting reverse-path asymmetry
2//!
3//! This module provides tools to measure both forward and reverse network paths,
4//! which is essential for diagnosing ECMP asymmetry where packets take different
5//! paths in each direction.
6//!
7//! # Architecture
8//!
9//! ```text
10//! [Client]  ──forward probes──>  [Server]
11//!           <──reverse probes──
12//! ```
13//!
14//! The server echoes probes with embedded timestamps, allowing the client
15//! to measure latency in both directions independently.
16
17use std::net::{IpAddr, SocketAddr};
18use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
19use std::sync::Arc;
20use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
21
22use tokio::io::{AsyncReadExt, AsyncWriteExt};
23use tokio::net::{TcpListener, TcpStream};
24use tokio::time::timeout;
25
26use crate::dns;
27use crate::error::Error;
28
29/// Default port for bidirectional probing
30pub const DEFAULT_PORT: u16 = 33435;
31
32/// Magic bytes to identify multiprobe bidirectional packets
33const MAGIC: [u8; 4] = [0x4D, 0x50, 0x42, 0x44]; // "MPBD"
34
35/// Protocol version
36const VERSION: u8 = 1;
37
38/// Message types
39#[repr(u8)]
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum MessageType {
42    Probe = 1,
43    Echo = 2,
44    Ping = 3,
45    Pong = 4,
46}
47
48impl TryFrom<u8> for MessageType {
49    type Error = ();
50
51    fn try_from(value: u8) -> Result<Self, Self::Error> {
52        match value {
53            1 => Ok(MessageType::Probe),
54            2 => Ok(MessageType::Echo),
55            3 => Ok(MessageType::Ping),
56            4 => Ok(MessageType::Pong),
57            _ => Err(()),
58        }
59    }
60}
61
62/// Probe message sent from client to server
63#[derive(Debug, Clone)]
64struct ProbeMessage {
65    sequence: u32,
66    client_send_time: u64,
67}
68
69impl ProbeMessage {
70    fn new(sequence: u32) -> Self {
71        let client_send_time = SystemTime::now()
72            .duration_since(UNIX_EPOCH)
73            .unwrap_or_default()
74            .as_micros() as u64;
75
76        Self {
77            sequence,
78            client_send_time,
79        }
80    }
81
82    fn to_bytes(&self) -> Vec<u8> {
83        let mut buf = Vec::with_capacity(17);
84        buf.extend_from_slice(&MAGIC);
85        buf.push(VERSION);
86        buf.push(MessageType::Probe as u8);
87        buf.extend_from_slice(&self.sequence.to_be_bytes());
88        buf.extend_from_slice(&self.client_send_time.to_be_bytes());
89        buf
90    }
91
92    fn from_bytes(data: &[u8]) -> Option<Self> {
93        if data.len() < 18 || data[0..4] != MAGIC || data[4] != VERSION {
94            return None;
95        }
96
97        let msg_type = MessageType::try_from(data[5]).ok()?;
98        if msg_type != MessageType::Probe {
99            return None;
100        }
101
102        let sequence = u32::from_be_bytes([data[6], data[7], data[8], data[9]]);
103        let client_send_time = u64::from_be_bytes([
104            data[10], data[11], data[12], data[13],
105            data[14], data[15], data[16], data[17],
106        ]);
107
108        Some(Self { sequence, client_send_time })
109    }
110}
111
112/// Echo message sent from server back to client
113#[derive(Debug, Clone)]
114struct EchoMessage {
115    sequence: u32,
116    client_send_time: u64,
117    server_recv_time: u64,
118    server_send_time: u64,
119}
120
121impl EchoMessage {
122    fn from_probe(probe: &ProbeMessage) -> Self {
123        let now = SystemTime::now()
124            .duration_since(UNIX_EPOCH)
125            .unwrap_or_default()
126            .as_micros() as u64;
127
128        Self {
129            sequence: probe.sequence,
130            client_send_time: probe.client_send_time,
131            server_recv_time: now,
132            server_send_time: now,
133        }
134    }
135
136    fn to_bytes(&self) -> Vec<u8> {
137        let mut buf = Vec::with_capacity(34);
138        buf.extend_from_slice(&MAGIC);
139        buf.push(VERSION);
140        buf.push(MessageType::Echo as u8);
141        buf.extend_from_slice(&self.sequence.to_be_bytes());
142        buf.extend_from_slice(&self.client_send_time.to_be_bytes());
143        buf.extend_from_slice(&self.server_recv_time.to_be_bytes());
144        buf.extend_from_slice(&self.server_send_time.to_be_bytes());
145        buf
146    }
147
148    fn from_bytes(data: &[u8]) -> Option<Self> {
149        if data.len() < 34 || data[0..4] != MAGIC || data[4] != VERSION {
150            return None;
151        }
152
153        let msg_type = MessageType::try_from(data[5]).ok()?;
154        if msg_type != MessageType::Echo {
155            return None;
156        }
157
158        let sequence = u32::from_be_bytes([data[6], data[7], data[8], data[9]]);
159        let client_send_time = u64::from_be_bytes([
160            data[10], data[11], data[12], data[13],
161            data[14], data[15], data[16], data[17],
162        ]);
163        let server_recv_time = u64::from_be_bytes([
164            data[18], data[19], data[20], data[21],
165            data[22], data[23], data[24], data[25],
166        ]);
167        let server_send_time = u64::from_be_bytes([
168            data[26], data[27], data[28], data[29],
169            data[30], data[31], data[32], data[33],
170        ]);
171
172        Some(Self {
173            sequence,
174            client_send_time,
175            server_recv_time,
176            server_send_time,
177        })
178    }
179}
180
181/// Statistics for one direction of the path
182#[derive(Debug, Clone)]
183pub struct DirectionStats {
184    /// Number of probes sent
185    pub sent: u32,
186    /// Number of responses received
187    pub received: u32,
188    /// Minimum latency in this direction
189    pub min_ms: f64,
190    /// Maximum latency in this direction
191    pub max_ms: f64,
192    /// Mean latency in this direction
193    pub mean_ms: f64,
194    /// Jitter (mean absolute deviation)
195    pub jitter_ms: f64,
196    /// Individual samples (microseconds)
197    samples: Vec<u64>,
198}
199
200impl DirectionStats {
201    fn new() -> Self {
202        Self {
203            sent: 0,
204            received: 0,
205            min_ms: f64::MAX,
206            max_ms: 0.0,
207            mean_ms: 0.0,
208            jitter_ms: 0.0,
209            samples: Vec::new(),
210        }
211    }
212
213    fn add_sample(&mut self, latency_us: u64) {
214        self.received += 1;
215        self.samples.push(latency_us);
216
217        let latency_ms = latency_us as f64 / 1000.0;
218
219        if latency_ms < self.min_ms {
220            self.min_ms = latency_ms;
221        }
222        if latency_ms > self.max_ms {
223            self.max_ms = latency_ms;
224        }
225    }
226
227    fn finalize(&mut self) {
228        if self.samples.is_empty() {
229            self.min_ms = 0.0;
230            return;
231        }
232
233        let sum: u64 = self.samples.iter().sum();
234        self.mean_ms = (sum as f64 / self.samples.len() as f64) / 1000.0;
235
236        if self.samples.len() > 1 {
237            let mut jitter_sum = 0.0;
238            for i in 1..self.samples.len() {
239                jitter_sum += (self.samples[i] as f64 - self.samples[i-1] as f64).abs();
240            }
241            self.jitter_ms = (jitter_sum / (self.samples.len() - 1) as f64) / 1000.0;
242        }
243    }
244
245    /// Calculate packet loss percentage
246    pub fn loss_percent(&self) -> f64 {
247        if self.sent == 0 {
248            0.0
249        } else {
250            ((self.sent - self.received) as f64 / self.sent as f64) * 100.0
251        }
252    }
253}
254
255/// Result of bidirectional path probing
256#[derive(Debug, Clone)]
257pub struct BidirectionalResult {
258    /// Target hostname
259    pub target: String,
260    /// Resolved IP address
261    pub target_ip: IpAddr,
262    /// Server port
263    pub port: u16,
264    /// Forward path statistics (client → server)
265    pub forward: DirectionStats,
266    /// Reverse path statistics (server → client)
267    pub reverse: DirectionStats,
268    /// Round-trip statistics (client → server → client)
269    pub round_trip: DirectionStats,
270    /// Asymmetry score (0.0 = symmetric, 1.0 = highly asymmetric)
271    pub asymmetry_score: f64,
272    /// Whether significant asymmetry was detected
273    pub asymmetric: bool,
274    /// Total probes attempted
275    pub probe_count: u32,
276    /// Test duration
277    pub duration: Duration,
278}
279
280impl BidirectionalResult {
281    /// Interpret the asymmetry
282    pub fn interpretation(&self) -> &'static str {
283        if self.asymmetry_score < 0.1 {
284            "Symmetric path"
285        } else if self.asymmetry_score < 0.3 {
286            "Slight asymmetry"
287        } else if self.asymmetry_score < 0.5 {
288            "Moderate asymmetry"
289        } else {
290            "Significant asymmetry - likely different ECMP paths"
291        }
292    }
293}
294
295/// Options for bidirectional probing
296#[derive(Debug, Clone)]
297pub struct BidirectionalOptions {
298    /// Port to connect to (default: 33435)
299    pub port: u16,
300    /// Number of probes to send
301    pub probe_count: u32,
302    /// Interval between probes
303    pub interval: Duration,
304    /// Timeout for each probe
305    pub timeout: Duration,
306}
307
308impl Default for BidirectionalOptions {
309    fn default() -> Self {
310        Self {
311            port: DEFAULT_PORT,
312            probe_count: 10,
313            interval: Duration::from_millis(100),
314            timeout: Duration::from_secs(5),
315        }
316    }
317}
318
319/// Server for bidirectional probing
320pub struct BidirectionalServer {
321    listener: TcpListener,
322    running: Arc<AtomicBool>,
323    probes_handled: Arc<AtomicU64>,
324}
325
326impl BidirectionalServer {
327    /// Create a new bidirectional probe server
328    pub async fn bind(addr: &str) -> crate::Result<Self> {
329        let listener = TcpListener::bind(addr).await
330            .map_err(Error::SocketCreation)?;
331
332        Ok(Self {
333            listener,
334            running: Arc::new(AtomicBool::new(false)),
335            probes_handled: Arc::new(AtomicU64::new(0)),
336        })
337    }
338
339    /// Get the local address the server is bound to
340    pub fn local_addr(&self) -> crate::Result<SocketAddr> {
341        self.listener.local_addr()
342            .map_err(Error::SocketCreation)
343    }
344
345    /// Get the number of probes handled
346    pub fn probes_handled(&self) -> u64 {
347        self.probes_handled.load(Ordering::Relaxed)
348    }
349
350    /// Check if the server is running
351    pub fn is_running(&self) -> bool {
352        self.running.load(Ordering::Relaxed)
353    }
354
355    /// Stop the server
356    pub fn stop(&self) {
357        self.running.store(false, Ordering::Relaxed);
358    }
359
360    /// Run the server, handling incoming connections
361    pub async fn run(&self) -> crate::Result<()> {
362        self.running.store(true, Ordering::Relaxed);
363
364        while self.running.load(Ordering::Relaxed) {
365            let accept_result = timeout(
366                Duration::from_millis(100),
367                self.listener.accept()
368            ).await;
369
370            match accept_result {
371                Ok(Ok((stream, _addr))) => {
372                    let probes_handled = self.probes_handled.clone();
373                    tokio::spawn(async move {
374                        let _ = Self::handle_connection(stream, probes_handled).await;
375                    });
376                }
377                Ok(Err(e)) => {
378                    if self.running.load(Ordering::Relaxed) {
379                        return Err(Error::ConnectionFailed {
380                            target: "server".to_string(),
381                            message: e.to_string(),
382                        });
383                    }
384                }
385                Err(_) => {
386                    // Timeout - check if we should keep running
387                    continue;
388                }
389            }
390        }
391
392        Ok(())
393    }
394
395    async fn handle_connection(
396        mut stream: TcpStream,
397        probes_handled: Arc<AtomicU64>,
398    ) -> crate::Result<()> {
399        let mut buf = [0u8; 64];
400
401        loop {
402            match stream.read(&mut buf).await {
403                Ok(0) => break, // Connection closed
404                Ok(n) => {
405                    if let Some(probe) = ProbeMessage::from_bytes(&buf[..n]) {
406                        let echo = EchoMessage::from_probe(&probe);
407                        let echo_bytes = echo.to_bytes();
408
409                        if stream.write_all(&echo_bytes).await.is_ok() {
410                            probes_handled.fetch_add(1, Ordering::Relaxed);
411                        }
412                    }
413                }
414                Err(_) => break,
415            }
416        }
417
418        Ok(())
419    }
420}
421
422/// Perform bidirectional probing to a target running a multiprobe server
423pub async fn probe_bidirectional(
424    target: &str,
425    options: &BidirectionalOptions,
426) -> crate::Result<BidirectionalResult> {
427    let start = Instant::now();
428
429    // DNS resolution
430    let dns_result = dns::resolve_ipv4(target).await?;
431    let target_ip = dns_result.ip;
432    let addr = SocketAddr::new(target_ip, options.port);
433
434    // Connect to server
435    let mut stream = timeout(options.timeout, TcpStream::connect(addr))
436        .await
437        .map_err(|_| Error::Timeout { timeout_ms: options.timeout.as_millis() as u64 })?
438        .map_err(|e| Error::ConnectionFailed {
439            target: target.to_string(),
440            message: e.to_string(),
441        })?;
442
443    let mut forward = DirectionStats::new();
444    let mut reverse = DirectionStats::new();
445    let mut round_trip = DirectionStats::new();
446
447    let mut buf = [0u8; 64];
448
449    for seq in 0..options.probe_count {
450        forward.sent += 1;
451        reverse.sent += 1;
452        round_trip.sent += 1;
453
454        let probe = ProbeMessage::new(seq);
455        let probe_bytes = probe.to_bytes();
456        let send_time = Instant::now();
457
458        // Send probe
459        if stream.write_all(&probe_bytes).await.is_err() {
460            continue;
461        }
462
463        // Wait for echo
464        let read_result = timeout(options.timeout, stream.read(&mut buf)).await;
465        let recv_time = Instant::now();
466
467        match read_result {
468            Ok(Ok(n)) if n > 0 => {
469                if let Some(echo) = EchoMessage::from_bytes(&buf[..n]) {
470                    let client_recv_time = SystemTime::now()
471                        .duration_since(UNIX_EPOCH)
472                        .unwrap_or_default()
473                        .as_micros() as u64;
474
475                    // Forward latency: client_send → server_recv
476                    let forward_latency = echo.server_recv_time.saturating_sub(echo.client_send_time);
477                    forward.add_sample(forward_latency);
478
479                    // Reverse latency: server_send → client_recv
480                    let reverse_latency = client_recv_time.saturating_sub(echo.server_send_time);
481                    reverse.add_sample(reverse_latency);
482
483                    // Round-trip measured locally
484                    let rtt = recv_time.duration_since(send_time).as_micros() as u64;
485                    round_trip.add_sample(rtt);
486                }
487            }
488            _ => {
489                // Timeout or error - packet lost
490            }
491        }
492
493        // Wait for interval
494        if seq < options.probe_count - 1 {
495            tokio::time::sleep(options.interval).await;
496        }
497    }
498
499    // Finalize statistics
500    forward.finalize();
501    reverse.finalize();
502    round_trip.finalize();
503
504    // Calculate asymmetry score
505    let asymmetry_score = if forward.received > 0 && reverse.received > 0 {
506        let diff = (forward.mean_ms - reverse.mean_ms).abs();
507        let avg = (forward.mean_ms + reverse.mean_ms) / 2.0;
508        if avg > 0.0 {
509            (diff / avg).min(1.0)
510        } else {
511            0.0
512        }
513    } else {
514        0.0
515    };
516
517    let duration = start.elapsed();
518
519    Ok(BidirectionalResult {
520        target: target.to_string(),
521        target_ip,
522        port: options.port,
523        forward,
524        reverse,
525        round_trip,
526        asymmetry_score,
527        asymmetric: asymmetry_score > 0.3,
528        probe_count: options.probe_count,
529        duration,
530    })
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use std::net::Ipv4Addr;
537
538    #[test]
539    fn test_probe_message_serialization() {
540        let probe = ProbeMessage::new(42);
541        let bytes = probe.to_bytes();
542
543        assert_eq!(&bytes[0..4], &MAGIC);
544        assert_eq!(bytes[4], VERSION);
545        assert_eq!(bytes[5], MessageType::Probe as u8);
546
547        let parsed = ProbeMessage::from_bytes(&bytes).unwrap();
548        assert_eq!(parsed.sequence, 42);
549        assert_eq!(parsed.client_send_time, probe.client_send_time);
550    }
551
552    #[test]
553    fn test_echo_message_serialization() {
554        let probe = ProbeMessage::new(123);
555        let echo = EchoMessage::from_probe(&probe);
556        let bytes = echo.to_bytes();
557
558        assert_eq!(&bytes[0..4], &MAGIC);
559        assert_eq!(bytes[4], VERSION);
560        assert_eq!(bytes[5], MessageType::Echo as u8);
561
562        let parsed = EchoMessage::from_bytes(&bytes).unwrap();
563        assert_eq!(parsed.sequence, 123);
564        assert_eq!(parsed.client_send_time, probe.client_send_time);
565    }
566
567    #[test]
568    fn test_invalid_message() {
569        let invalid = [0u8; 10];
570        assert!(ProbeMessage::from_bytes(&invalid).is_none());
571        assert!(EchoMessage::from_bytes(&invalid).is_none());
572    }
573
574    #[test]
575    fn test_direction_stats() {
576        let mut stats = DirectionStats::new();
577
578        stats.sent = 5;
579        stats.add_sample(10_000); // 10ms
580        stats.add_sample(15_000); // 15ms
581        stats.add_sample(12_000); // 12ms
582
583        stats.finalize();
584
585        assert_eq!(stats.received, 3);
586        assert!((stats.min_ms - 10.0).abs() < 0.01);
587        assert!((stats.max_ms - 15.0).abs() < 0.01);
588        assert!((stats.mean_ms - 12.33).abs() < 0.1);
589        assert!(stats.jitter_ms > 0.0);
590        assert!((stats.loss_percent() - 40.0).abs() < 0.01);
591    }
592
593    #[test]
594    fn test_direction_stats_empty() {
595        let mut stats = DirectionStats::new();
596        stats.sent = 3;
597        stats.finalize();
598
599        assert_eq!(stats.received, 0);
600        assert_eq!(stats.min_ms, 0.0);
601        assert_eq!(stats.mean_ms, 0.0);
602        assert!((stats.loss_percent() - 100.0).abs() < 0.01);
603    }
604
605    #[test]
606    fn test_direction_stats_single_sample() {
607        let mut stats = DirectionStats::new();
608        stats.sent = 1;
609        stats.add_sample(5_000);
610        stats.finalize();
611
612        assert_eq!(stats.received, 1);
613        assert!((stats.min_ms - 5.0).abs() < 0.01);
614        assert!((stats.max_ms - 5.0).abs() < 0.01);
615        assert!((stats.mean_ms - 5.0).abs() < 0.01);
616        assert_eq!(stats.jitter_ms, 0.0);
617    }
618
619    #[test]
620    fn test_asymmetry_interpretation() {
621        let result = BidirectionalResult {
622            target: "test".to_string(),
623            target_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
624            port: DEFAULT_PORT,
625            forward: DirectionStats::new(),
626            reverse: DirectionStats::new(),
627            round_trip: DirectionStats::new(),
628            asymmetry_score: 0.05,
629            asymmetric: false,
630            probe_count: 10,
631            duration: Duration::from_secs(1),
632        };
633
634        assert_eq!(result.interpretation(), "Symmetric path");
635
636        let result2 = BidirectionalResult {
637            asymmetry_score: 0.6,
638            asymmetric: true,
639            ..result.clone()
640        };
641
642        assert!(result2.interpretation().contains("Significant"));
643    }
644
645    #[test]
646    fn test_options_default() {
647        let opts = BidirectionalOptions::default();
648
649        assert_eq!(opts.port, DEFAULT_PORT);
650        assert_eq!(opts.probe_count, 10);
651        assert_eq!(opts.interval, Duration::from_millis(100));
652        assert_eq!(opts.timeout, Duration::from_secs(5));
653    }
654
655    #[tokio::test]
656    async fn test_server_client_integration() {
657        // Start server on random port
658        let server = BidirectionalServer::bind("127.0.0.1:0").await.unwrap();
659        let addr = server.local_addr().unwrap();
660        let port = addr.port();
661
662        // Run server in background
663        let server_running = server.running.clone();
664        let server_handle = tokio::spawn(async move {
665            let _ = server.run().await;
666        });
667
668        // Give server time to start
669        tokio::time::sleep(Duration::from_millis(50)).await;
670
671        // Run client
672        let options = BidirectionalOptions {
673            port,
674            probe_count: 5,
675            interval: Duration::from_millis(10),
676            timeout: Duration::from_secs(2),
677        };
678
679        let result = probe_bidirectional("127.0.0.1", &options).await.unwrap();
680
681        assert_eq!(result.target, "127.0.0.1");
682        assert_eq!(result.port, port);
683        assert_eq!(result.probe_count, 5);
684        assert!(result.forward.received > 0);
685        assert!(result.reverse.received > 0);
686        assert!(result.round_trip.received > 0);
687
688        // Localhost latencies are very small (sub-millisecond), so percentage-based
689        // asymmetry can be high due to timing jitter. Just verify we got valid data.
690        assert!(result.round_trip.mean_ms < 50.0); // Should be fast on localhost
691        // Note: We don't assert on asymmetry_score for localhost since tiny latencies
692        // can produce high percentage variations
693
694        // Stop server
695        server_running.store(false, Ordering::Relaxed);
696        let _ = server_handle.await;
697    }
698
699    #[tokio::test]
700    async fn test_server_handles_multiple_probes() {
701        let server = BidirectionalServer::bind("127.0.0.1:0").await.unwrap();
702        let addr = server.local_addr().unwrap();
703        let port = addr.port();
704        let probes_counter = server.probes_handled.clone();
705        let server_running = server.running.clone();
706
707        let server_handle = tokio::spawn(async move {
708            let _ = server.run().await;
709        });
710
711        tokio::time::sleep(Duration::from_millis(50)).await;
712
713        let options = BidirectionalOptions {
714            port,
715            probe_count: 20,
716            interval: Duration::from_millis(5),
717            timeout: Duration::from_secs(2),
718        };
719
720        let result = probe_bidirectional("127.0.0.1", &options).await.unwrap();
721
722        assert!(result.round_trip.received >= 15); // Allow some loss
723        assert!(probes_counter.load(Ordering::Relaxed) >= 15);
724
725        server_running.store(false, Ordering::Relaxed);
726        let _ = server_handle.await;
727    }
728
729    #[tokio::test]
730    async fn test_connection_to_nonexistent_server() {
731        let options = BidirectionalOptions {
732            port: 59999, // Unlikely to be in use
733            probe_count: 1,
734            timeout: Duration::from_millis(500),
735            ..Default::default()
736        };
737
738        let result = probe_bidirectional("127.0.0.1", &options).await;
739        assert!(result.is_err());
740    }
741
742    #[tokio::test]
743    async fn test_server_bind_address() {
744        let server = BidirectionalServer::bind("127.0.0.1:0").await.unwrap();
745        let addr = server.local_addr().unwrap();
746
747        assert_eq!(addr.ip(), std::net::IpAddr::V4(Ipv4Addr::LOCALHOST));
748        assert!(addr.port() > 0);
749    }
750
751    #[test]
752    fn test_message_type_conversion() {
753        assert_eq!(MessageType::try_from(1), Ok(MessageType::Probe));
754        assert_eq!(MessageType::try_from(2), Ok(MessageType::Echo));
755        assert_eq!(MessageType::try_from(3), Ok(MessageType::Ping));
756        assert_eq!(MessageType::try_from(4), Ok(MessageType::Pong));
757        assert!(MessageType::try_from(99).is_err());
758    }
759
760    #[test]
761    fn test_probe_sequence_numbers() {
762        for seq in [0, 1, 100, u32::MAX] {
763            let probe = ProbeMessage {
764                sequence: seq,
765                client_send_time: 12345,
766            };
767            let bytes = probe.to_bytes();
768            let parsed = ProbeMessage::from_bytes(&bytes).unwrap();
769            assert_eq!(parsed.sequence, seq);
770        }
771    }
772
773    #[test]
774    fn test_timestamp_preservation() {
775        let probe = ProbeMessage {
776            sequence: 1,
777            client_send_time: 1234567890123456,
778        };
779
780        let echo = EchoMessage::from_probe(&probe);
781        assert_eq!(echo.client_send_time, probe.client_send_time);
782        assert!(echo.server_recv_time > 0);
783        assert!(echo.server_send_time >= echo.server_recv_time);
784
785        let bytes = echo.to_bytes();
786        let parsed = EchoMessage::from_bytes(&bytes).unwrap();
787        assert_eq!(parsed.client_send_time, echo.client_send_time);
788        assert_eq!(parsed.server_recv_time, echo.server_recv_time);
789        assert_eq!(parsed.server_send_time, echo.server_send_time);
790    }
791}