Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package ttl for openSUSE:Factory checked in at 2026-03-16 14:16:33 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/ttl (Old) and /work/SRC/openSUSE:Factory/.ttl.new.8177 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "ttl" Mon Mar 16 14:16:33 2026 rev:4 rq:1339096 version:0.19.0 Changes: -------- --- /work/SRC/openSUSE:Factory/ttl/ttl.changes 2026-03-11 21:00:16.654186253 +0100 +++ /work/SRC/openSUSE:Factory/.ttl.new.8177/ttl.changes 2026-03-16 14:19:25.898522299 +0100 @@ -1,0 +2,7 @@ +Sun Mar 15 13:36:37 UTC 2026 - Martin Hauke <[email protected]> + +- Update to version 0.19.0: + * Update docs for interactive replay controls. + * Add interactive replay controls with progress bar and seek. + +------------------------------------------------------------------- Old: ---- ttl-0.18.2.obscpio New: ---- ttl-0.19.0.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ ttl.spec ++++++ --- /var/tmp/diff_new_pack.v2gBHA/_old 2026-03-16 14:19:27.326581580 +0100 +++ /var/tmp/diff_new_pack.v2gBHA/_new 2026-03-16 14:19:27.330581747 +0100 @@ -17,7 +17,7 @@ Name: ttl -Version: 0.18.2 +Version: 0.19.0 Release: 0 Summary: Modern traceroute/mtr-style TUI License: Apache-2.0 OR MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.v2gBHA/_old 2026-03-16 14:19:27.366583241 +0100 +++ /var/tmp/diff_new_pack.v2gBHA/_new 2026-03-16 14:19:27.370583407 +0100 @@ -2,7 +2,7 @@ <service name="obs_scm" mode="manual"> <param name="url">https://github.com/lance0/ttl</param> <param name="scm">git</param> - <param name="revision">v0.18.2</param> + <param name="revision">v0.19.0</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(\d+\.\d+\.\d+)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.v2gBHA/_old 2026-03-16 14:19:27.390584237 +0100 +++ /var/tmp/diff_new_pack.v2gBHA/_new 2026-03-16 14:19:27.394584404 +0100 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/lance0/ttl</param> - <param name="changesrevision">6980786653f89865a65975d9b17bdd69fbeac0ff</param></service></servicedata> + <param name="changesrevision">154accbe7696b9f7ac32bdd730f746d968b8947e</param></service></servicedata> (No newline at EOF) ++++++ ttl-0.18.2.obscpio -> ttl-0.19.0.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.18.2/CHANGELOG.md new/ttl-0.19.0/CHANGELOG.md --- old/ttl-0.18.2/CHANGELOG.md 2026-02-23 15:19:50.000000000 +0100 +++ new/ttl-0.19.0/CHANGELOG.md 2026-02-26 20:56:58.000000000 +0100 @@ -5,6 +5,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.19.0] - 2026-02-26 + +### Added +- **Interactive replay controls**: Seek (Left/Right ±500ms, [/] ±5s), speed adjust (+/- ±0.5x, 0.5x–5.0x range), Home/End jump to start/end +- **Replay progress bar**: Shows play state, event count, timeline position, and speed multiplier +- **Replay help section**: Help overlay (`?`) now shows replay-specific keybindings when in replay mode + +### Fixed +- **Replay timing precision**: Switched from f32 to f64 for elapsed time calculations to prevent drift on long replays +- **Replay seek safety**: Saturating arithmetic prevents overflow on relative seek; `Instant::checked_sub` prevents underflow on corrupted replay files + ## [0.18.2] - 2026-02-23 ### Fixed diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.18.2/Cargo.lock new/ttl-0.19.0/Cargo.lock --- old/ttl-0.18.2/Cargo.lock 2026-02-23 15:19:50.000000000 +0100 +++ new/ttl-0.19.0/Cargo.lock 2026-02-26 20:56:58.000000000 +0100 @@ -290,9 +290,9 @@ [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -3393,7 +3393,7 @@ [[package]] name = "ttl" -version = "0.18.2" +version = "0.19.0" dependencies = [ "anyhow", "chrono", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.18.2/Cargo.toml new/ttl-0.19.0/Cargo.toml --- old/ttl-0.18.2/Cargo.toml 2026-02-23 15:19:50.000000000 +0100 +++ new/ttl-0.19.0/Cargo.toml 2026-02-26 20:56:58.000000000 +0100 @@ -1,6 +1,6 @@ [package] name = "ttl" -version = "0.18.2" +version = "0.19.0" edition = "2024" rust-version = "1.88" description = "Modern traceroute/mtr-style TUI with hop stats and optional ASN/geo enrichment" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.18.2/README.md new/ttl-0.19.0/README.md --- old/ttl-0.18.2/README.md 2026-02-23 15:19:50.000000000 +0100 +++ new/ttl-0.19.0/README.md 2026-02-26 20:56:58.000000000 +0100 @@ -81,6 +81,14 @@ emerge net-analyzer/ttl ``` +### NetBSD (pkgsrc) + +```bash +pkgin install ttl +``` + +Or from source: `cd /usr/pkgsrc/net/ttl && make install` + ### Pre-built Binaries Download from [GitHub Releases](https://github.com/lance0/ttl/releases): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.18.2/ROADMAP.md new/ttl-0.19.0/ROADMAP.md --- old/ttl-0.18.2/ROADMAP.md 2026-02-23 15:19:50.000000000 +0100 +++ new/ttl-0.19.0/ROADMAP.md 2026-02-26 20:56:58.000000000 +0100 @@ -60,7 +60,7 @@ - [x] Update notifications (checks GitHub releases, install-method-aware) - [x] FreeBSD support (experimental, raw sockets) -## Completed (v0.15.x - v0.18.2) +## Completed (v0.15.x - v0.19.0) - [x] Animated replay (`--replay file --animate`) with speed control - [x] Probe event recording for replay accuracy @@ -144,8 +144,8 @@ *Prioritized by effort vs user impact. Quick wins first, then bigger lifts.* ### Quick Wins (low effort, high impact) -- [ ] **Progress indicator in replay** — show position in timeline during animated replay -- [ ] **Interactive replay** — step through events, jump to time +- [x] **Progress indicator in replay** — show position in timeline during animated replay +- [x] **Interactive replay** — step through events, jump to time, speed control - [x] **Last metric semantics** — documented as primary-responder-most-recent; TUI/CSV aligned - [x] **IPv6 RAW payload fallback tests** — unit tests for IPv6 Echo Reply and Time Exceeded parsing - [x] **Main table layout tests** — verify header/cell/width count parity across Auto/Compact/Wide × single-flow/multi-flow modes @@ -182,8 +182,8 @@ - [x] Integration tests for probe-receive-state pipeline - [x] Property-based/fuzz tests for packet parsing (correlate.rs) - [x] RAW payload fallback unit tests (IPv4) -- [ ] IPv6 RAW payload fallback unit tests -- [ ] Concurrent multi-target stress tests +- [x] IPv6 RAW payload fallback unit tests +- [x] Concurrent multi-target stress tests --- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.18.2/docs/COMPARISON.md new/ttl-0.19.0/docs/COMPARISON.md --- old/ttl-0.18.2/docs/COMPARISON.md 2026-02-23 15:19:50.000000000 +0100 +++ new/ttl-0.19.0/docs/COMPARISON.md 2026-02-26 20:56:58.000000000 +0100 @@ -34,7 +34,7 @@ | **Export** |||||| | JSON | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | | CSV | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | -| Session replay | :white_check_mark: | :x: | :x: | :x: | :x: | +| Session replay (interactive) | :white_check_mark: | :x: | :x: | :x: | :x: | | **Advanced** |||||| | Multiple targets | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: | | PMTUD | :white_check_mark: | :x: | :x: | :x: | :x: | diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.18.2/docs/FEATURES.md new/ttl-0.19.0/docs/FEATURES.md --- old/ttl-0.18.2/docs/FEATURES.md 2026-02-23 15:19:50.000000000 +0100 +++ new/ttl-0.19.0/docs/FEATURES.md 2026-02-26 20:56:58.000000000 +0100 @@ -393,6 +393,7 @@ | `Down` / `j` | Move selection down | | `Enter` | Expand selected hop details | | `Esc` | Close popup / Deselect | +| *Replay* | *See [Replay Controls](#replay-controls) for seek, speed, and position keys* | ## Settings Modal @@ -501,8 +502,23 @@ ``` Load a previously saved JSON session for review. Use `--animate` to replay -the session showing hop-by-hop discovery as it happened. Press Space to -pause/resume during animated replay. +the session showing hop-by-hop discovery as it happened. + +#### Replay Controls + +During animated replay, a progress bar shows the current position, speed, and event count. The following controls are available: + +| Key | Action | +|-----|--------| +| `p` / `Space` | Pause/resume | +| `Left` / `Right` | Seek ±0.5s | +| `[` / `]` | Seek ±5s | +| `+` / `-` | Speed ±0.5x (0.5x–5.0x) | +| `Home` | Seek to start | +| `End` | Seek to end | +| `?` | Help (shows replay controls) | + +Backward seeking rebuilds session state from scratch for correctness — fast even on long traces. ## CLI Reference diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.18.2/docs/SCRIPTING.md new/ttl-0.19.0/docs/SCRIPTING.md --- old/ttl-0.18.2/docs/SCRIPTING.md 2026-02-23 15:19:50.000000000 +0100 +++ new/ttl-0.19.0/docs/SCRIPTING.md 2026-02-26 20:56:58.000000000 +0100 @@ -246,7 +246,7 @@ ttl --replay trace.json --csv > trace.csv ``` -During animated replay, press Space to pause/resume. +During animated replay, use `p`/`Space` to pause, `Left`/`Right` to seek ±0.5s, `[`/`]` to seek ±5s, `+`/`-` to adjust speed, and `Home`/`End` to jump to start/end. A progress bar shows position, speed, and event count. Press `?` for the full keybinding list. ## Exit Codes diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.18.2/src/state/session.rs new/ttl-0.19.0/src/state/session.rs --- old/ttl-0.18.2/src/state/session.rs 2026-02-23 15:19:50.000000000 +0100 +++ new/ttl-0.19.0/src/state/session.rs 2026-02-26 20:56:58.000000000 +0100 @@ -1383,6 +1383,54 @@ } } + /// Apply a recorded replay event to this session. + /// + /// This is used by the TUI replay engine and tests to ensure replay + /// behavior stays consistent with runtime event application. + pub fn apply_replay_event(&mut self, event: &ProbeEvent) { + let target_ip = self.target.resolved; + let single_flow = self.config.flows == 1; + + if let Some(hop) = self.hop_mut(event.ttl) { + match &event.outcome { + ProbeOutcome::Reply { addr, rtt_us } => { + hop.record_sent(); + let rtt = Duration::from_micros(*rtt_us); + if single_flow { + hop.record_response_detecting_flaps(*addr, rtt, None); + } else { + hop.record_response_with_mpls(*addr, rtt, None); + } + hop.record_flow_response(event.flow_id, *addr, rtt); + + if *addr == target_ip { + self.complete = true; + if self.dest_ttl.is_none_or(|d| event.ttl < d) { + self.dest_ttl = Some(event.ttl); + } + } + self.total_sent += 1; + } + ProbeOutcome::Timeout => { + hop.record_sent(); + hop.record_timeout(); + hop.record_flow_timeout(event.flow_id); + self.total_sent += 1; + } + ProbeOutcome::LateReply { addr, rtt_us } => { + // Late reply arrives after timeout accounting already happened. + let rtt = Duration::from_micros(*rtt_us); + if single_flow { + hop.record_response_detecting_flaps(*addr, rtt, None); + } else { + hop.record_response_with_mpls(*addr, rtt, None); + } + hop.record_flow_response(event.flow_id, *addr, rtt); + } + } + } + } + /// Get discovered hops (those that have received at least one response) #[allow(dead_code)] pub fn discovered_hops(&self) -> impl Iterator<Item = &Hop> { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.18.2/src/trace/engine.rs new/ttl-0.19.0/src/trace/engine.rs --- old/ttl-0.18.2/src/trace/engine.rs 2026-02-23 15:19:50.000000000 +0100 +++ new/ttl-0.19.0/src/trace/engine.rs 2026-02-26 20:56:58.000000000 +0100 @@ -903,8 +903,10 @@ use std::net::{IpAddr, Ipv4Addr}; fn make_test_engine(pmtud: bool) -> ProbeEngine { - let mut config = Config::default(); - config.pmtud = pmtud; + let config = Config { + pmtud, + ..Config::default() + }; let target = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); let session = Arc::new(RwLock::new(Session::new( Target::new("test".to_string(), target), diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.18.2/src/trace/pending.rs new/ttl-0.19.0/src/trace/pending.rs --- old/ttl-0.18.2/src/trace/pending.rs 2026-02-23 15:19:50.000000000 +0100 +++ new/ttl-0.19.0/src/trace/pending.rs 2026-02-26 20:56:58.000000000 +0100 @@ -46,3 +46,111 @@ pub fn new_pending_map() -> PendingMap { Arc::new(RwLock::new(HashMap::new())) } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + #[test] + fn test_pending_map_multi_target_isolation() { + let pending = new_pending_map(); + let probe_id = ProbeId::new(5, 0); + let target1 = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)); + let target2 = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); + + { + let mut map = pending.write(); + for target in [target1, target2] { + map.insert( + (probe_id, 0, target, false), + PendingProbe { + sent_at: Instant::now(), + target, + flow_id: 0, + original_src_port: None, + packet_size: None, + }, + ); + } + } + + // Both targets coexist with same ProbeId + let map = pending.read(); + assert_eq!(map.len(), 2); + assert!(map.contains_key(&(probe_id, 0, target1, false))); + assert!(map.contains_key(&(probe_id, 0, target2, false))); + drop(map); + + // Removing one doesn't affect the other + let mut map = pending.write(); + map.remove(&(probe_id, 0, target1, false)); + assert_eq!(map.len(), 1); + assert!(map.contains_key(&(probe_id, 0, target2, false))); + } + + #[test] + fn test_pending_map_flow_isolation() { + let pending = new_pending_map(); + let probe_id = ProbeId::new(3, 0); + let target = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)); + + { + let mut map = pending.write(); + for flow_id in 0..4u8 { + map.insert( + (probe_id, flow_id, target, false), + PendingProbe { + sent_at: Instant::now(), + target, + flow_id, + original_src_port: None, + packet_size: None, + }, + ); + } + } + + let map = pending.read(); + assert_eq!(map.len(), 4); + for flow_id in 0..4u8 { + assert!(map.contains_key(&(probe_id, flow_id, target, false))); + } + } + + #[test] + fn test_pending_map_pmtud_isolation() { + let pending = new_pending_map(); + let probe_id = ProbeId::new(7, 1); + let target = IpAddr::V4(Ipv4Addr::new(8, 8, 4, 4)); + + { + let mut map = pending.write(); + map.insert( + (probe_id, 0, target, false), + PendingProbe { + sent_at: Instant::now(), + target, + flow_id: 0, + original_src_port: None, + packet_size: None, + }, + ); + map.insert( + (probe_id, 0, target, true), + PendingProbe { + sent_at: Instant::now(), + target, + flow_id: 0, + original_src_port: None, + packet_size: Some(1400), + }, + ); + } + + let map = pending.read(); + assert_eq!(map.len(), 2); + assert!(map.contains_key(&(probe_id, 0, target, false))); + assert!(map.contains_key(&(probe_id, 0, target, true))); + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.18.2/src/tui/app.rs new/ttl-0.19.0/src/tui/app.rs --- old/ttl-0.18.2/src/tui/app.rs 2026-02-23 15:19:50.000000000 +0100 +++ new/ttl-0.19.0/src/tui/app.rs 2026-02-26 20:56:58.000000000 +0100 @@ -8,7 +8,7 @@ use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::style::{Color, Style}; -use ratatui::widgets::Paragraph; +use ratatui::widgets::{LineGauge, Paragraph}; use scopeguard::defer; use std::borrow::Cow; use std::io::stdout; @@ -20,7 +20,9 @@ use crate::export::export_json_file; use crate::lookup::ix::IxLookup; use crate::prefs::{DisplayMode, Prefs}; -use crate::state::{ProbeEvent, ProbeOutcome, Session}; +#[cfg(test)] +use crate::state::ProbeOutcome; +use crate::state::{ProbeEvent, Session}; use crate::trace::receiver::SessionMap; use crate::tui::theme::Theme; use crate::tui::views::{ @@ -216,67 +218,255 @@ if let Some(ref mut replay) = ui_state.replay_state { if replay.paused { // RESUMING: Restore start time from the elapsed time we recorded at pause - let offset_duration = std::time::Duration::from_millis( - (replay.paused_at_elapsed_ms as f32 / replay.speed_multiplier) as u64, - ); - replay.replay_started_at = std::time::Instant::now() - offset_duration; + rebase_replay_start(replay, replay.paused_at_elapsed_ms); replay.paused = false; ui_state.set_status("Replay resumed"); } else { // PAUSING: Record exactly how far into the replay we are (in adjusted ms) - let elapsed_ms = replay.replay_started_at.elapsed().as_millis() as u64; - replay.paused_at_elapsed_ms = (elapsed_ms as f32 * replay.speed_multiplier) as u64; + replay.paused_at_elapsed_ms = replay_current_ms(replay); replay.paused = true; - ui_state.set_status("Replay paused - press p or Space to resume"); + ui_state.set_status("Replay paused. Press p or Space to resume."); } } } -/// Apply a replay event to the session state -fn apply_replay_event(session: &mut Session, event: &ProbeEvent) { - let target_ip = session.target.resolved; - let single_flow = session.config.flows == 1; +/// Format milliseconds as human-readable time (e.g., "1:23.4" or "5.2s") +fn format_replay_time(ms: u64) -> String { + let secs = ms / 1000; + let frac = (ms % 1000) / 100; + let mins = secs / 60; + let secs_rem = secs % 60; + if mins > 0 { + format!("{}:{:02}.{}", mins, secs_rem, frac) + } else { + format!("{}.{}s", secs_rem, frac) + } +} - if let Some(hop) = session.hop_mut(event.ttl) { - match &event.outcome { - ProbeOutcome::Reply { addr, rtt_us } => { - hop.record_sent(); - let rtt = Duration::from_micros(*rtt_us); - if single_flow { - hop.record_response_detecting_flaps(*addr, rtt, None); - } else { - hop.record_response_with_mpls(*addr, rtt, None); - } - hop.record_flow_response(event.flow_id, *addr, rtt); +/// Get the current adjusted elapsed time in replay milliseconds +fn replay_current_ms(replay: &ReplayState) -> u64 { + if replay.paused { + replay.paused_at_elapsed_ms + } else if replay.finished { + replay.events.last().map_or(0, |e| e.offset_ms) + } else { + let elapsed_ms = replay.replay_started_at.elapsed().as_secs_f64() * 1000.0; + let adjusted = elapsed_ms * replay.speed_multiplier as f64; + adjusted.clamp(0.0, u64::MAX as f64) as u64 + } +} - // Check if we reached the destination - if *addr == target_ip { - session.complete = true; - if session.dest_ttl.is_none_or(|d| event.ttl < d) { - session.dest_ttl = Some(event.ttl); - } - } - session.total_sent += 1; +/// Convert replay timeline ms into wall-clock duration at the current speed. +fn replay_wall_offset(replay_ms: u64, speed_multiplier: f32) -> Duration { + let speed = speed_multiplier.max(0.1) as f64; + let wall_ms = (replay_ms as f64 / speed).clamp(0.0, u64::MAX as f64); + Duration::from_millis(wall_ms as u64) +} + +/// Rebase replay start instant so replay_current_ms() resolves to target replay ms. +fn rebase_replay_start(replay: &mut ReplayState, replay_ms: u64) { + let now = std::time::Instant::now(); + let offset = replay_wall_offset(replay_ms, replay.speed_multiplier); + // Guard against malformed/very-large replay timestamps. + replay.replay_started_at = now.checked_sub(offset).unwrap_or(now); +} + +/// Compute replay event index for a requested timeline position. +/// target_ms=0 maps to index 0 (pre-first-event) for Home key semantics. +fn replay_event_index_for_time(events: &[ProbeEvent], target_ms: u64) -> usize { + if target_ms == 0 { + 0 + } else { + events.partition_point(|e| e.offset_ms <= target_ms) + } +} + +/// Human-friendly replay position label for status messages. +fn format_replay_position( + target_ms: u64, + total_duration: u64, + target_index: usize, + total_events: usize, +) -> String { + if total_events == 0 { + "empty replay (0/0 events)".to_string() + } else if target_index == 0 { + format!("start (event 0/{})", total_events) + } else if target_index >= total_events { + if total_duration == 0 { + format!( + "end (event {}/{}, instant timeline)", + target_index, total_events + ) + } else { + format!( + "end {} (event {}/{})", + format_replay_time(total_duration), + target_index, + total_events + ) + } + } else { + format!( + "{} (event {}/{})", + format_replay_time(target_ms), + target_index, + total_events + ) + } +} + +/// Seek replay to an absolute time position (in replay milliseconds). +/// Rebuilds session state from scratch for backward seeks. +fn seek_replay_to( + ui_state: &mut UiState, + sessions: &SessionMap, + target_ip: IpAddr, + target_ms: u64, +) { + // Extract what we need from replay_state, then release the borrow + let (total_duration, target_index, current_index, total_events) = { + let replay = match ui_state.replay_state.as_ref() { + Some(r) => r, + None => return, + }; + let total_duration = replay.events.last().map_or(0, |e| e.offset_ms); + let target_ms = target_ms.min(total_duration); + let target_index = replay_event_index_for_time(&replay.events, target_ms); + ( + total_duration, + target_index, + replay.current_index, + replay.events.len(), + ) + }; + + let target_ms = target_ms.min(total_duration); + + if target_index == current_index { + let replay = ui_state.replay_state.as_mut().unwrap(); + replay.paused = true; + replay.paused_at_elapsed_ms = target_ms; + replay.finished = target_index >= total_events; + ui_state.set_status(format!( + "Already at {}", + format_replay_position(target_ms, total_duration, target_index, total_events) + )); + return; + } + + if target_index <= current_index { + // Backward seek: rebuild session from scratch + let replay = ui_state.replay_state.as_ref().unwrap(); + let sessions_read = sessions.read(); + if let Some(session_lock) = sessions_read.get(&target_ip) { + let mut session = session_lock.write(); + let target = session.target.clone(); + let config = session.config.clone(); + *session = Session::new(target, config); + for event in &replay.events[..target_index] { + session.apply_replay_event(event); } - ProbeOutcome::Timeout => { - hop.record_sent(); - hop.record_timeout(); - hop.record_flow_timeout(event.flow_id); - session.total_sent += 1; + } + } else { + // Forward seek: apply events incrementally + let replay = ui_state.replay_state.as_ref().unwrap(); + let sessions_read = sessions.read(); + if let Some(session_lock) = sessions_read.get(&target_ip) { + let mut session = session_lock.write(); + for event in &replay.events[current_index..target_index] { + session.apply_replay_event(event); } - ProbeOutcome::LateReply { addr, rtt_us } => { - // Late reply arrived after timeout was already recorded. - // Don't increment sent/total_sent - the Timeout event already did that. - let rtt = Duration::from_micros(*rtt_us); - if single_flow { - hop.record_response_detecting_flaps(*addr, rtt, None); - } else { - hop.record_response_with_mpls(*addr, rtt, None); - } - hop.record_flow_response(event.flow_id, *addr, rtt); + } + } + + // Now mutate replay state + let replay = ui_state.replay_state.as_mut().unwrap(); + replay.paused = true; + replay.paused_at_elapsed_ms = target_ms; + replay.finished = target_index >= total_events; + replay.current_index = target_index; + + ui_state.set_status(format!( + "Moved to {}", + format_replay_position(target_ms, total_duration, target_index, total_events) + )); +} + +/// Seek replay to the final event position (all events applied). +fn seek_replay_to_end(ui_state: &mut UiState, sessions: &SessionMap, target_ip: IpAddr) { + let (total_duration, target_index, current_index) = { + let replay = match ui_state.replay_state.as_ref() { + Some(r) => r, + None => return, + }; + ( + replay.events.last().map_or(0, |e| e.offset_ms), + replay.events.len(), + replay.current_index, + ) + }; + + if target_index > current_index { + let replay = ui_state.replay_state.as_ref().unwrap(); + let sessions_read = sessions.read(); + if let Some(session_lock) = sessions_read.get(&target_ip) { + let mut session = session_lock.write(); + for event in &replay.events[current_index..target_index] { + session.apply_replay_event(event); } } } + + let replay = ui_state.replay_state.as_mut().unwrap(); + replay.paused = true; + replay.paused_at_elapsed_ms = total_duration; + replay.finished = true; + replay.current_index = target_index; + + ui_state.set_status(format!( + "Moved to {}", + format_replay_position(total_duration, total_duration, target_index, target_index) + )); +} + +/// Compute a replay target time from current position + signed delta using saturation. +fn replay_target_ms_from_delta(current_ms: u64, delta_ms: i64) -> u64 { + if delta_ms >= 0 { + current_ms.saturating_add(delta_ms as u64) + } else { + current_ms.saturating_sub(delta_ms.unsigned_abs()) + } +} + +/// Seek replay by a relative delta (in replay milliseconds). Negative values seek backward. +fn seek_replay(ui_state: &mut UiState, sessions: &SessionMap, target_ip: IpAddr, delta_ms: i64) { + let current_ms = match ui_state.replay_state.as_ref() { + Some(r) => replay_current_ms(r), + None => return, + }; + let target_ms = replay_target_ms_from_delta(current_ms, delta_ms); + seek_replay_to(ui_state, sessions, target_ip, target_ms); +} + +/// Adjust replay speed by delta, preserving current position +fn adjust_replay_speed(ui_state: &mut UiState, delta: f32) { + let new_speed = { + let replay = match ui_state.replay_state.as_mut() { + Some(r) => r, + None => return, + }; + let current_ms = replay_current_ms(replay); + replay.speed_multiplier = (replay.speed_multiplier + delta).clamp(0.1, 1000.0); + + if !replay.paused { + rebase_replay_start(replay, current_ms); + } + + replay.speed_multiplier + }; + + ui_state.set_status(format!("Speed: {:.1}x", new_speed)); } async fn run_app<B>( @@ -326,8 +516,7 @@ && !replay.finished { // Calculate elapsed replay time (adjusted for speed) - let elapsed_ms = replay.replay_started_at.elapsed().as_millis() as u64; - let adjusted_elapsed = (elapsed_ms as f32 * replay.speed_multiplier) as u64; + let adjusted_elapsed = replay_current_ms(replay); // Capture target before acquiring locks to prevent race condition let target_ip = targets[ui_state.selected_target]; @@ -345,7 +534,7 @@ if let Some(session_lock) = sessions_read.get(&target_ip) { let mut session = session_lock.write(); for event in &replay.events[start..end] { - apply_replay_event(&mut session, event); + session.apply_replay_event(event); } } replay.current_index = end; @@ -810,6 +999,31 @@ KeyCode::Esc => { ui_state.selected = None; } + // Replay controls + KeyCode::Left if ui_state.replay_state.is_some() => { + seek_replay(ui_state, &sessions, current_target, -500); + } + KeyCode::Right if ui_state.replay_state.is_some() => { + seek_replay(ui_state, &sessions, current_target, 500); + } + KeyCode::Char('[') if ui_state.replay_state.is_some() => { + seek_replay(ui_state, &sessions, current_target, -5000); + } + KeyCode::Char(']') if ui_state.replay_state.is_some() => { + seek_replay(ui_state, &sessions, current_target, 5000); + } + KeyCode::Char('+') | KeyCode::Char('>') if ui_state.replay_state.is_some() => { + adjust_replay_speed(ui_state, 0.5); + } + KeyCode::Char('-') | KeyCode::Char('<') if ui_state.replay_state.is_some() => { + adjust_replay_speed(ui_state, -0.5); + } + KeyCode::Home if ui_state.replay_state.is_some() => { + seek_replay_to(ui_state, &sessions, current_target, 0); + } + KeyCode::End if ui_state.replay_state.is_some() => { + seek_replay_to_end(ui_state, &sessions, current_target); + } _ => {} } } @@ -830,28 +1044,29 @@ ) { let area = f.area(); - // Layout: optional update banner + main view + status bar + // Layout: optional update banner + main view + optional replay progress + status bar let has_update = ui_state.update_available.is_some(); - let constraints = if has_update { - vec![ - Constraint::Length(1), // Update banner - Constraint::Min(0), // Main view - Constraint::Length(1), // Status bar - ] - } else { - vec![ - Constraint::Min(0), // Main view - Constraint::Length(1), // Status bar - ] - }; + let has_replay = ui_state.replay_state.is_some(); + let mut constraints = Vec::new(); + if has_update { + constraints.push(Constraint::Length(1)); // Update banner + } + constraints.push(Constraint::Min(0)); // Main view + if has_replay { + constraints.push(Constraint::Length(1)); // Replay progress bar + } + constraints.push(Constraint::Length(1)); // Status bar let chunks = Layout::default() .direction(Direction::Vertical) .constraints(constraints) .split(area); + // Assign chunk indices based on which optional rows are present + let mut idx = 0; + // Update notification banner (if available) - let (main_chunk, status_chunk) = if has_update { + if has_update { if let Some(ref version) = ui_state.update_available { let update_text = format!( " Update available: v{} -> {} | Press 'u' to dismiss ", @@ -860,19 +1075,68 @@ ); let update_bar = Paragraph::new(update_text) .style(Style::default().fg(Color::Black).bg(Color::Yellow)); - f.render_widget(update_bar, chunks[0]); + f.render_widget(update_bar, chunks[idx]); } - (chunks[1], chunks[2]) + idx += 1; + } + + let main_chunk = chunks[idx]; + idx += 1; + + let progress_chunk = if has_replay { + let chunk = chunks[idx]; + idx += 1; + Some(chunk) } else { - (chunks[0], chunks[1]) + None }; + let status_chunk = chunks[idx]; + // Main view (with target indicator and display mode) let main_view = MainView::new(session, ui_state.selected, ui_state.paused, theme) .with_target_info(ui_state.selected_target + 1, num_targets) .with_display_mode(ui_state.display_mode); f.render_widget(main_view, main_chunk); + // Replay progress bar + if let (Some(chunk), Some(replay)) = (progress_chunk, &ui_state.replay_state) { + let current_ms = replay_current_ms(replay); + let total_ms = replay.events.last().map_or(0, |e| e.offset_ms); + let icon = if replay.finished { + "--" + } else if replay.paused { + "||" + } else { + ">>" + }; + let instant_suffix = if total_ms == 0 && !replay.events.is_empty() { + " instant" + } else { + "" + }; + let label = format!( + " {} {}/{} {} / {} {:.1}x{} ", + icon, + replay.current_index, + replay.events.len(), + format_replay_time(current_ms), + format_replay_time(total_ms), + replay.speed_multiplier, + instant_suffix, + ); + let ratio = if total_ms > 0 { + (current_ms as f64 / total_ms as f64).clamp(0.0, 1.0) + } else { + 0.0 + }; + let gauge = LineGauge::default() + .filled_style(Style::default().fg(theme.success).bg(theme.highlight_bg)) + .label(label) + .ratio(ratio); + f.render_widget(gauge, chunk); + } + // Status bar (use Cow to avoid allocation for static strings) // Update notification takes priority over normal status let (status_text, status_style): (Cow<'_, str>, Style) = if let Some(ref version) = @@ -891,6 +1155,13 @@ Cow::Borrowed(msg.as_str()), Style::default().fg(theme.text_dim), ) + } else if ui_state.replay_state.is_some() { + ( + Cow::Borrowed( + "q quit | p pause | Left/Right seek | [/] seek 5s | +/- speed | Home/End | ? help", + ), + Style::default().fg(theme.text_dim), + ) } else if num_targets > 1 { ( Cow::Borrowed( @@ -912,7 +1183,10 @@ // Overlays if ui_state.show_help { - f.render_widget(HelpView::new(theme), area); + f.render_widget( + HelpView::new(theme).with_replay(ui_state.replay_state.is_some()), + area, + ); } if ui_state.show_settings { @@ -947,3 +1221,140 @@ ); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::state::Target; + use std::collections::HashMap; + use std::net::{IpAddr, Ipv4Addr}; + use std::sync::Arc; + + fn make_single_session_map(target: IpAddr, session: Session) -> SessionMap { + let mut map = HashMap::new(); + map.insert(target, Arc::new(parking_lot::RwLock::new(session))); + Arc::new(parking_lot::RwLock::new(map)) + } + + fn make_timeout_event(offset_ms: u64, ttl: u8, seq: u8) -> ProbeEvent { + ProbeEvent { + offset_ms, + ttl, + seq, + flow_id: 0, + outcome: ProbeOutcome::Timeout, + } + } + + #[test] + fn test_replay_event_index_for_time_home_is_empty() { + let events = vec![make_timeout_event(0, 1, 0), make_timeout_event(100, 1, 1)]; + assert_eq!(replay_event_index_for_time(&events, 0), 0); + } + + #[test] + fn test_replay_event_index_for_time_end_includes_all() { + let events = vec![make_timeout_event(0, 1, 0), make_timeout_event(100, 1, 1)]; + assert_eq!(replay_event_index_for_time(&events, 100), 2); + } + + #[test] + fn test_adjust_replay_speed_clamps() { + let mut ui_state = UiState { + replay_state: Some(ReplayState::new(Vec::new(), 0.1)), + ..UiState::default() + }; + adjust_replay_speed(&mut ui_state, -0.5); + assert_eq!( + ui_state.replay_state.as_ref().unwrap().speed_multiplier, + 0.1 + ); + + ui_state.replay_state.as_mut().unwrap().speed_multiplier = 1000.0; + adjust_replay_speed(&mut ui_state, 0.5); + assert_eq!( + ui_state.replay_state.as_ref().unwrap().speed_multiplier, + 1000.0 + ); + } + + #[test] + fn test_rebase_replay_start_handles_large_timestamps() { + let mut replay = ReplayState::new(Vec::new(), 1.0); + rebase_replay_start(&mut replay, u64::MAX); + } + + #[test] + fn test_replay_target_ms_from_delta_saturates() { + assert_eq!(replay_target_ms_from_delta(1000, -500), 500); + assert_eq!(replay_target_ms_from_delta(1000, -5000), 0); + assert_eq!(replay_target_ms_from_delta(u64::MAX - 10, 100), u64::MAX); + assert_eq!(replay_target_ms_from_delta(u64::MAX, i64::MAX), u64::MAX); + } + + #[test] + fn test_seek_replay_to_end_applies_all_zero_offset_events() { + let target = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)); + let session = Session::new(Target::new("t".to_string(), target), Config::default()); + let sessions = make_single_session_map(target, session); + let events = vec![make_timeout_event(0, 1, 0), make_timeout_event(0, 2, 1)]; + + let mut ui_state = UiState { + replay_state: Some(ReplayState { + events, + current_index: 0, + replay_started_at: std::time::Instant::now(), + speed_multiplier: 1.0, + paused: false, + finished: false, + paused_at_elapsed_ms: 0, + }), + ..UiState::default() + }; + + seek_replay_to_end(&mut ui_state, &sessions, target); + + let session_lock = sessions.read().get(&target).cloned().unwrap(); + let session = session_lock.read(); + assert_eq!(session.total_sent, 2); + assert_eq!(session.hop(1).unwrap().timeouts, 1); + assert_eq!(session.hop(2).unwrap().timeouts, 1); + + let replay = ui_state.replay_state.as_ref().unwrap(); + assert!(replay.finished); + assert_eq!(replay.current_index, 2); + } + + #[test] + fn test_seek_replay_to_same_position_is_noop_for_session_state() { + let target = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); + let session = Session::new(Target::new("t".to_string(), target), Config::default()); + let sessions = make_single_session_map(target, session); + let events = vec![make_timeout_event(100, 1, 0)]; + + let mut ui_state = UiState { + replay_state: Some(ReplayState { + events, + current_index: 0, + replay_started_at: std::time::Instant::now(), + speed_multiplier: 1.0, + paused: false, + finished: false, + paused_at_elapsed_ms: 0, + }), + ..UiState::default() + }; + + seek_replay_to(&mut ui_state, &sessions, target, 0); + + let session_lock = sessions.read().get(&target).cloned().unwrap(); + let session = session_lock.read(); + assert_eq!(session.total_sent, 0); + assert_eq!(session.hop(1).unwrap().sent, 0); + + let replay = ui_state.replay_state.as_ref().unwrap(); + assert_eq!(replay.current_index, 0); + assert!(replay.paused); + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.18.2/src/tui/views/help.rs new/ttl-0.19.0/src/tui/views/help.rs --- old/ttl-0.18.2/src/tui/views/help.rs 2026-02-23 15:19:50.000000000 +0100 +++ new/ttl-0.19.0/src/tui/views/help.rs 2026-02-26 20:56:58.000000000 +0100 @@ -10,11 +10,20 @@ /// Help overlay pub struct HelpView<'a> { theme: &'a Theme, + is_replay: bool, } impl<'a> HelpView<'a> { pub fn new(theme: &'a Theme) -> Self { - Self { theme } + Self { + theme, + is_replay: false, + } + } + + pub fn with_replay(mut self, is_replay: bool) -> Self { + self.is_replay = is_replay; + self } } @@ -22,7 +31,8 @@ fn render(self, area: Rect, buf: &mut Buffer) { // Calculate centered popup area let popup_width = 50.min(area.width.saturating_sub(4)); - let popup_height = 24.min(area.height.saturating_sub(4)); + let base_height: u16 = if self.is_replay { 31 } else { 24 }; + let popup_height = base_height.min(area.height.saturating_sub(4)); let popup_x = (area.width - popup_width) / 2 + area.x; let popup_y = (area.height - popup_height) / 2 + area.y; let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); @@ -38,7 +48,7 @@ let inner = block.inner(popup_area); block.render(popup_area, buf); - let lines = vec![ + let mut lines = vec![ Line::from(""), Line::from(vec![ Span::styled(" q ", Style::default().fg(self.theme.shortcut)), @@ -106,16 +116,41 @@ Span::raw("Close popup / Deselect"), ]), Line::from(""), - Line::from(vec![Span::styled( - format!(" Update: {}", InstallMethod::cached().update_command()), - Style::default().fg(self.theme.text_dim), - )]), - Line::from(vec![Span::styled( - " Press any key to close", - Style::default().fg(self.theme.text_dim), - )]), ]; + if self.is_replay { + lines.push(Line::from(vec![Span::styled( + " Replay Controls:", + Style::default().fg(self.theme.header), + )])); + lines.push(Line::from(vec![ + Span::styled(" Left/Right ", Style::default().fg(self.theme.shortcut)), + Span::raw("Seek 0.5s"), + ])); + lines.push(Line::from(vec![ + Span::styled(" [ / ] ", Style::default().fg(self.theme.shortcut)), + Span::raw("Seek 5s"), + ])); + lines.push(Line::from(vec![ + Span::styled(" + / - ", Style::default().fg(self.theme.shortcut)), + Span::raw("Speed up / slow down"), + ])); + lines.push(Line::from(vec![ + Span::styled(" Home / End ", Style::default().fg(self.theme.shortcut)), + Span::raw("Seek to start / end"), + ])); + lines.push(Line::from("")); + } + + lines.push(Line::from(vec![Span::styled( + format!(" Update: {}", InstallMethod::cached().update_command()), + Style::default().fg(self.theme.text_dim), + )])); + lines.push(Line::from(vec![Span::styled( + " Press any key to close", + Style::default().fg(self.theme.text_dim), + )])); + let paragraph = Paragraph::new(lines); paragraph.render(inner, buf); } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.18.2/tests/integration.rs new/ttl-0.19.0/tests/integration.rs --- old/ttl-0.18.2/tests/integration.rs 2026-02-23 15:19:50.000000000 +0100 +++ new/ttl-0.19.0/tests/integration.rs 2026-02-26 20:56:58.000000000 +0100 @@ -8,7 +8,7 @@ use std::time::Duration; use ttl::config::Config; -use ttl::state::session::{PmtudPhase, PmtudState, Session, Target}; +use ttl::state::session::{PmtudPhase, PmtudState, ProbeEvent, ProbeOutcome, Session, Target}; /// Create a test session for 8.8.8.8 with default config fn test_session() -> Session { @@ -804,3 +804,233 @@ // Cleanup let _ = fs::remove_file(&temp_path); } + +#[test] +fn test_concurrent_multi_target_state_isolation() { + // Create 5 sessions with different targets + let targets: Vec<(IpAddr, IpAddr)> = vec![ + ( + IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + ), + ( + IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), + IpAddr::V4(Ipv4Addr::new(10, 0, 1, 1)), + ), + ( + IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), + IpAddr::V4(Ipv4Addr::new(10, 0, 2, 1)), + ), + ( + IpAddr::V4(Ipv4Addr::new(208, 67, 222, 222)), + IpAddr::V4(Ipv4Addr::new(10, 0, 3, 1)), + ), + ( + IpAddr::V4(Ipv4Addr::new(4, 2, 2, 1)), + IpAddr::V4(Ipv4Addr::new(10, 0, 4, 1)), + ), + ]; + + let mut sessions: Vec<Session> = targets + .iter() + .map(|(target_ip, _)| { + let target = Target::new(target_ip.to_string(), *target_ip); + Session::new(target, Config::default()) + }) + .collect(); + + // Record different amounts of data per session + for (i, (session, (target_ip, router_ip))) in + sessions.iter_mut().zip(targets.iter()).enumerate() + { + let num_probes = (i + 1) * 3; // 3, 6, 9, 12, 15 + for seq in 0..num_probes { + if let Some(hop) = session.hop_mut(1) { + hop.record_sent(); + hop.record_response(*router_ip, Duration::from_micros(1000 * (i as u64 + 1))); + hop.record_flow_response( + 0, + *router_ip, + Duration::from_micros(1000 * (i as u64 + 1)), + ); + } + if let Some(hop) = session.hop_mut(2) { + hop.record_sent(); + if seq % 3 == 0 { + hop.record_timeout(); + } else { + hop.record_response(*target_ip, Duration::from_micros(5000 * (i as u64 + 1))); + } + } + session.total_sent += 2; + } + } + + // Verify complete isolation + for (i, (session, (target_ip, router_ip))) in sessions.iter().zip(targets.iter()).enumerate() { + let num_probes = (i + 1) * 3; + let expected_timeouts = (num_probes / 3) as u64; + let expected_received = num_probes as u64 - expected_timeouts; + + // Correct sent count per session + assert_eq!( + session.total_sent, + num_probes as u64 * 2, + "Session {} total_sent", + i + ); + + // Hop 1: all responses from this session's router + let hop1 = session.hop(1).unwrap(); + assert_eq!(hop1.sent, num_probes as u64, "Session {} hop1 sent", i); + assert_eq!( + hop1.received, num_probes as u64, + "Session {} hop1 received", + i + ); + assert_eq!( + hop1.primary, + Some(*router_ip), + "Session {} hop1 primary responder", + i + ); + + // Hop 2: correct timeout ratio + let hop2 = session.hop(2).unwrap(); + assert_eq!(hop2.sent, num_probes as u64, "Session {} hop2 sent", i); + assert_eq!( + hop2.timeouts, expected_timeouts, + "Session {} hop2 timeouts", + i + ); + assert_eq!( + hop2.received, expected_received, + "Session {} hop2 received", + i + ); + assert_eq!( + hop2.primary, + Some(*target_ip), + "Session {} hop2 primary responder", + i + ); + } +} + +#[test] +fn test_replay_event_roundtrip() { + let mut session = test_session(); + let target_ip = session.target.resolved; + let router = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)); + + let events = vec![ + ProbeEvent { + offset_ms: 0, + ttl: 1, + seq: 0, + flow_id: 0, + outcome: ProbeOutcome::Reply { + addr: router, + rtt_us: 5000, + }, + }, + ProbeEvent { + offset_ms: 100, + ttl: 2, + seq: 0, + flow_id: 0, + outcome: ProbeOutcome::Timeout, + }, + ProbeEvent { + offset_ms: 200, + ttl: 3, + seq: 0, + flow_id: 0, + outcome: ProbeOutcome::Reply { + addr: target_ip, + rtt_us: 15000, + }, + }, + ProbeEvent { + offset_ms: 1000, + ttl: 1, + seq: 1, + flow_id: 0, + outcome: ProbeOutcome::Reply { + addr: router, + rtt_us: 4800, + }, + }, + ProbeEvent { + offset_ms: 1100, + ttl: 2, + seq: 1, + flow_id: 0, + outcome: ProbeOutcome::Timeout, + }, + ]; + + // Apply replay events using production replay logic + for event in &events { + session.apply_replay_event(event); + } + + // Verify state matches expectations + assert_eq!(session.total_sent, 5); + assert!(session.complete); + assert_eq!(session.dest_ttl, Some(3)); + + // Hop 1: 2 replies from router + let hop1 = session.hop(1).unwrap(); + assert_eq!(hop1.sent, 2); + assert_eq!(hop1.received, 2); + assert_eq!(hop1.primary, Some(router)); + + // Hop 2: 2 timeouts + let hop2 = session.hop(2).unwrap(); + assert_eq!(hop2.sent, 2); + assert_eq!(hop2.timeouts, 2); + assert_eq!(hop2.received, 0); + + // Hop 3: 1 reply from destination + let hop3 = session.hop(3).unwrap(); + assert_eq!(hop3.sent, 1); + assert_eq!(hop3.received, 1); + assert_eq!(hop3.primary, Some(target_ip)); +} + +#[test] +fn test_replay_late_reply_does_not_increment_total_sent_or_timeouts() { + let mut session = test_session(); + let hop_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)); + + let timeout = ProbeEvent { + offset_ms: 0, + ttl: 2, + seq: 0, + flow_id: 0, + outcome: ProbeOutcome::Timeout, + }; + session.apply_replay_event(&timeout); + + let late = ProbeEvent { + offset_ms: 100, + ttl: 2, + seq: 0, + flow_id: 0, + outcome: ProbeOutcome::LateReply { + addr: hop_ip, + rtt_us: 12_000, + }, + }; + session.apply_replay_event(&late); + + assert_eq!(session.total_sent, 1, "LateReply must not bump total_sent"); + let hop = session.hop(2).unwrap(); + assert_eq!(hop.sent, 1); + assert_eq!(hop.timeouts, 1); + assert_eq!( + hop.received, 1, + "LateReply should count as a received response" + ); +} ++++++ ttl.obsinfo ++++++ --- /var/tmp/diff_new_pack.v2gBHA/_old 2026-03-16 14:19:27.626594035 +0100 +++ /var/tmp/diff_new_pack.v2gBHA/_new 2026-03-16 14:19:27.630594201 +0100 @@ -1,5 +1,5 @@ name: ttl -version: 0.18.2 -mtime: 1771856390 -commit: 6980786653f89865a65975d9b17bdd69fbeac0ff +version: 0.19.0 +mtime: 1772135818 +commit: 154accbe7696b9f7ac32bdd730f746d968b8947e ++++++ vendor.tar.zst ++++++ /work/SRC/openSUSE:Factory/ttl/vendor.tar.zst /work/SRC/openSUSE:Factory/.ttl.new.8177/vendor.tar.zst differ: char 7, line 1
