Skip to content

Commit 93ccdf6

Browse files
committed
Bump version to v0.15.1
1 parent dbf2691 commit 93ccdf6

File tree

8 files changed

+163
-140
lines changed

8 files changed

+163
-140
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.15.1] - 2026-01-28
9+
10+
### Fixed
11+
- **TUI lock contention**: Snapshot session data before rendering so no locks are held during draw, fixing UI freezes during rapid target switching (#19)
12+
- **Stat column desync**: Sent counter now updates atomically with Avg/Min/Max/Loss instead of racing ahead (#17)
13+
814
## [0.15.0] - 2026-01-27
915

1016
### Added

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "ttl"
3-
version = "0.15.0"
3+
version = "0.15.1"
44
edition = "2024"
55
rust-version = "1.88"
66
description = "Modern traceroute/mtr-style TUI with hop stats and optional ASN/geo enrichment"

src/state/session.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1229,7 +1229,7 @@ pub struct Session {
12291229
pub config: Config,
12301230
pub complete: bool, // destination reached?
12311231
pub dest_ttl: Option<u8>, // TTL at which destination was reached (actual hop count)
1232-
pub total_sent: u64, // total probes sent across all hops
1232+
pub total_sent: u64, // total completed probes (response or timeout) across all hops
12331233
#[serde(skip)]
12341234
pub paused: bool, // pause probing (TUI only)
12351235
/// PMTUD state (only present when --pmtud is enabled)
@@ -1352,6 +1352,26 @@ impl Session {
13521352
self.hops.iter().find(|h| h.has_nat()).map(|h| h.ttl)
13531353
}
13541354

1355+
/// Clone session data for TUI rendering, excluding events (which can be large).
1356+
/// The events vec is only used for replay export, never during TUI rendering.
1357+
pub fn snapshot_for_render(&self) -> Self {
1358+
Self {
1359+
target: self.target.clone(),
1360+
started_at: self.started_at,
1361+
started_instant: self.started_instant,
1362+
hops: self.hops.clone(),
1363+
config: self.config.clone(),
1364+
complete: self.complete,
1365+
dest_ttl: self.dest_ttl,
1366+
total_sent: self.total_sent,
1367+
paused: self.paused,
1368+
pmtud: self.pmtud.clone(),
1369+
source_ip: self.source_ip,
1370+
gateway: self.gateway,
1371+
events: Vec::new(),
1372+
}
1373+
}
1374+
13551375
/// Record a probe event for animated replay
13561376
pub fn record_event(&mut self, event: ProbeEvent) {
13571377
self.events.push(event);

src/trace/engine.rs

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -269,16 +269,6 @@ impl ProbeEngine {
269269
continue;
270270
}
271271

272-
// Record that we sent a probe
273-
{
274-
let mut state = self.state.write();
275-
if let Some(hop) = state.hop_mut(ttl) {
276-
hop.record_sent();
277-
hop.record_flow_sent(0); // ICMP uses single flow (checksum trick not yet implemented)
278-
}
279-
state.total_sent += 1;
280-
}
281-
282272
// Apply rate limiting if configured
283273
self.apply_rate_limit().await;
284274
}
@@ -425,16 +415,6 @@ impl ProbeEngine {
425415
continue;
426416
}
427417

428-
// Record that we sent a probe
429-
{
430-
let mut state = self.state.write();
431-
if let Some(hop) = state.hop_mut(ttl) {
432-
hop.record_sent();
433-
hop.record_flow_sent(flow_id);
434-
}
435-
state.total_sent += 1;
436-
}
437-
438418
// Apply rate limiting if configured
439419
self.apply_rate_limit().await;
440420
}
@@ -563,16 +543,6 @@ impl ProbeEngine {
563543
continue;
564544
}
565545

566-
// Record that we sent a probe
567-
{
568-
let mut state = self.state.write();
569-
if let Some(hop) = state.hop_mut(ttl) {
570-
hop.record_sent();
571-
hop.record_flow_sent(flow_id);
572-
}
573-
state.total_sent += 1;
574-
}
575-
576546
// Apply rate limiting if configured
577547
self.apply_rate_limit().await;
578548
}
@@ -701,10 +671,7 @@ impl ProbeEngine {
701671
// Send the probe
702672
match send_icmp(socket, &packet, self.target) {
703673
Ok(_) => {
704-
// Don't increment hop.sent for PMTUD probes - they're for MTU discovery,
705-
// not traceroute measurements. Only increment total_sent for diagnostics.
706-
let mut state = self.state.write();
707-
state.total_sent += 1;
674+
// Sent counting deferred to receiver for atomic stat updates
708675
true
709676
}
710677
Err(e) => {
@@ -797,9 +764,12 @@ impl ProbeEngine {
797764

798765
// Update state with parity to receiver behavior
799766
let mut state = self.state.write();
767+
state.total_sent += 1;
800768

801769
// Only record hop stats for normal probes, not PMTUD probes
802770
if !is_pmtud_probe && let Some(hop) = state.hop_mut(parsed.probe_id.ttl) {
771+
hop.record_sent();
772+
hop.record_flow_sent(flow_id);
803773
// Use flap-detecting record for single-flow mode (ICMP is always single-flow)
804774
hop.record_response_detecting_flaps(parsed.responder, rtt, None);
805775
hop.record_flow_response(flow_id, parsed.responder, rtt);

src/trace/receiver.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,10 +295,15 @@ impl Receiver {
295295
let mut state = session.write();
296296
let is_pmtud_probe = resp.packet_size.is_some();
297297

298+
// Increment sent count here (not in engine) so all stats update atomically
299+
state.total_sent += 1;
300+
298301
// Only record hop stats for normal probes, not PMTUD probes
299302
// PMTUD probes are for MTU discovery, not traceroute measurements
300303
if !is_pmtud_probe {
301304
if let Some(hop) = state.hop_mut(resp.probe_id.ttl) {
305+
hop.record_sent();
306+
hop.record_flow_sent(resp.flow_id);
302307
// Record aggregate stats with optional flap detection
303308
// Only detect flaps in single-flow mode (multi-flow expects path changes)
304309
if self.config.num_flows == 1 {
@@ -410,9 +415,14 @@ impl Receiver {
410415
if let Some(session) = sessions.get(target) {
411416
let mut state = session.write();
412417

418+
// Increment sent count here (not in engine) so all stats update atomically
419+
state.total_sent += 1;
420+
413421
// Only record hop timeouts for normal probes, not PMTUD probes
414422
if !is_pmtud_probe {
415423
if let Some(hop) = state.hop_mut(probe_id.ttl) {
424+
hop.record_sent();
425+
hop.record_flow_sent(probe.flow_id);
416426
hop.record_timeout();
417427
hop.record_flow_timeout(probe.flow_id);
418428
}

src/tui/app.rs

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ use crate::state::{ProbeEvent, ProbeOutcome, Session};
2424
use crate::trace::receiver::SessionMap;
2525
use crate::tui::theme::Theme;
2626
use crate::tui::views::{
27-
HelpView, HopDetailView, MainView, SettingsState, SettingsView, TargetListView,
27+
HelpView, HopDetailView, MainView, SettingsState, SettingsView, TargetInfo, TargetListView,
28+
extract_target_infos,
2829
};
2930

3031
/// State for animated replay playback
@@ -99,6 +100,10 @@ pub struct UiState {
99100
pub update_available: Option<String>,
100101
/// Replay animation state (None = live mode or static replay)
101102
pub replay_state: Option<ReplayState>,
103+
/// Cached target list info (populated when overlay is open, refreshed every 30 ticks)
104+
pub target_list_cache: Option<Vec<TargetInfo>>,
105+
/// Tick counter for target list cache refresh
106+
pub target_list_tick: u32,
102107
}
103108

104109
impl UiState {
@@ -310,22 +315,23 @@ where
310315
// Capture target before acquiring locks to prevent race condition
311316
let target_ip = targets[ui_state.selected_target];
312317

313-
// Apply all events up to current adjusted time
314-
while replay.current_index < replay.events.len() {
315-
let event = &replay.events[replay.current_index];
316-
if event.offset_ms <= adjusted_elapsed {
317-
let sessions_read = sessions.read();
318-
if let Some(session_lock) = sessions_read.get(&target_ip) {
319-
let mut session = session_lock.write();
320-
apply_replay_event(
321-
&mut session,
322-
&replay.events[replay.current_index].clone(),
323-
);
318+
// Find all events up to current adjusted time
319+
let start = replay.current_index;
320+
let mut end = start;
321+
while end < replay.events.len() && replay.events[end].offset_ms <= adjusted_elapsed {
322+
end += 1;
323+
}
324+
325+
// Apply all events in a single lock acquisition (no per-event lock churn)
326+
if end > start {
327+
let sessions_read = sessions.read();
328+
if let Some(session_lock) = sessions_read.get(&target_ip) {
329+
let mut session = session_lock.write();
330+
for event in &replay.events[start..end] {
331+
apply_replay_event(&mut session, event);
324332
}
325-
replay.current_index += 1;
326-
} else {
327-
break; // Wait for this event's time
328333
}
334+
replay.current_index = end;
329335
}
330336

331337
// Check if replay is complete
@@ -344,24 +350,41 @@ where
344350
// Get cache status for settings modal
345351
let cache_status = ix_lookup.as_ref().map(|ix| ix.get_cache_status());
346352

347-
// Draw
348-
terminal.draw(|f| {
353+
// Snapshot session data BEFORE draw so no locks are held during rendering.
354+
// Uses snapshot_for_render() to skip cloning the events vec (unbounded, not used in render).
355+
let session_snapshot = {
349356
let sessions_read = sessions.read();
350-
if let Some(state) = sessions_read.get(&current_target) {
351-
let session = state.read();
357+
sessions_read
358+
.get(&current_target)
359+
.map(|state| state.read().snapshot_for_render())
360+
};
361+
362+
// Refresh target list cache (~every 500ms while overlay is open)
363+
if ui_state.show_target_list {
364+
ui_state.target_list_tick += 1;
365+
if ui_state.target_list_cache.is_none() || ui_state.target_list_tick.is_multiple_of(30)
366+
{
367+
ui_state.target_list_cache = Some(extract_target_infos(&sessions, &targets));
368+
}
369+
} else {
370+
ui_state.target_list_cache = None;
371+
ui_state.target_list_tick = 0;
372+
}
373+
374+
// Draw (no locks held — all data is pre-extracted snapshots)
375+
if let Some(ref session) = session_snapshot {
376+
terminal.draw(|f| {
352377
draw_ui(
353378
f,
354-
&session,
379+
session,
355380
ui_state,
356381
&theme,
357382
num_targets,
358-
&sessions,
359-
&targets,
360383
cache_status.clone(),
361384
ix_enabled,
362385
);
363-
}
364-
})?;
386+
})?;
387+
}
365388

366389
// Handle input with timeout
367390
if event::poll(tick_rate)?
@@ -685,6 +708,9 @@ where
685708
if num_targets > 1 {
686709
ui_state.target_list_index = ui_state.selected_target;
687710
ui_state.show_target_list = true;
711+
ui_state.target_list_cache =
712+
Some(extract_target_infos(&sessions, &targets));
713+
ui_state.target_list_tick = 0;
688714
}
689715
}
690716
KeyCode::Char('u') => {
@@ -782,8 +808,6 @@ fn draw_ui(
782808
ui_state: &UiState,
783809
theme: &Theme,
784810
num_targets: usize,
785-
sessions: &SessionMap,
786-
targets: &[IpAddr],
787811
cache_status: Option<crate::lookup::ix::CacheStatus>,
788812
ix_enabled: bool,
789813
) {
@@ -897,9 +921,11 @@ fn draw_ui(
897921
}
898922
}
899923

900-
if ui_state.show_target_list {
924+
if ui_state.show_target_list
925+
&& let Some(ref infos) = ui_state.target_list_cache
926+
{
901927
f.render_widget(
902-
TargetListView::new(theme, sessions, targets, ui_state.target_list_index),
928+
TargetListView::new(theme, infos, ui_state.target_list_index),
903929
area,
904930
);
905931
}

0 commit comments

Comments
 (0)