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

Reply via email to