Skip to main content

manycastr/orchestrator/
trace.rs

1use crate::custom_module::manycastr::reply::ReplyData;
2use crate::custom_module::manycastr::{Address, Reply, ReplyBatch, Task, Trace, TraceReply, task};
3use crate::orchestrator::{CliHandle, MeasurementHandle, TracerouteConfig};
4use log::warn;
5use std::collections::{HashMap, VecDeque};
6use std::thread;
7use std::time::{Duration, Instant};
8
9/// Session Tracker for fast lookups (based on expiration queue)
10#[derive(Debug)]
11pub struct SessionTracker {
12    pub sessions: HashMap<TraceIdentifier, TraceSession>,
13    pub expiration_queue: VecDeque<(TraceIdentifier, Instant)>,
14}
15
16impl SessionTracker {
17    pub fn new() -> Self {
18        Self {
19            sessions: HashMap::new(),
20            expiration_queue: VecDeque::new(),
21        }
22    }
23}
24
25#[derive(Debug)]
26pub struct TraceSession {
27    /// Worker from which the traceroute is being performed
28    pub worker_id: u32,
29    /// Target destination address to which the traceroute is being performed
30    pub target: Option<Address>,
31    /// Origin used for the traceroute (source address, port mappings) [None if a single origin is used]
32    pub origin_id: u32,
33    /// How this session advances through TTLs
34    pub progress: TraceProgress,
35    /// Time at which last trace was performed
36    pub last_updated: Instant,
37}
38
39/// TTL advancement strategy of a trace session
40#[derive(Debug)]
41pub enum TraceProgress {
42    /// Hop-by-hop walk from initial_hop upward (anycast-traceroute)
43    Linear {
44        /// Current TTL being traced
45        current_ttl: u8,
46        /// Consecutive failures counter
47        consecutive_failures: u8,
48    },
49    /// Binary search for the deepest hop that replies with Time Exceeded (tracemap)
50    Binary {
51        /// Lower search bound: every responding hop found so far is below this TTL
52        lo: u8,
53        /// Upper search bound: the deepest TTL that may still respond
54        hi: u8,
55        /// Midpoint TTL at which the current confirmation window started
56        mid: u8,
57        /// TTL of the probe currently in flight (mid, or a confirmation probe above it)
58        probing_ttl: u8,
59        /// Remaining confirmation probes before concluding the silent tail starts at mid
60        window_left: u8,
61    },
62}
63
64/// Midpoint of two TTLs without u8 overflow
65#[inline]
66pub fn ttl_midpoint(lo: u8, hi: u8) -> u8 {
67    ((lo as u16 + hi as u16) / 2) as u8
68}
69
70/// First-probe TTL for the tracemap binary search.
71const TRACEMAP_FIRST_TTL: u8 = 12;
72
73/// Create tracemap binary-search sessions for a batch of (unresponsive) targets and
74/// return the initial `Trace` tasks (probing [`TRACEMAP_FIRST_TTL`]) for the probing worker.
75///
76/// # Arguments
77/// * `targets` - Target addresses to map
78/// * `worker_id` - Worker that will probe these targets (with the anycast source)
79/// * `config` - Traceroute parameters (including the seed origin) and session tracker
80pub fn seed_tracemap_sessions(
81    targets: Vec<Address>,
82    worker_id: u32,
83    config: &mut TracerouteConfig,
84) -> Vec<Task> {
85    let origin_id = config.origin_id;
86    let lo = config.initial_hop as u8;
87    let hi = config.max_hops as u8;
88    let mid = TRACEMAP_FIRST_TTL.clamp(lo, hi);
89    let now = Instant::now();
90    let deadline = now + Duration::from_secs(config.timeout);
91
92    targets
93        .into_iter()
94        .map(|target| {
95            let identifier = TraceIdentifier {
96                worker_id,
97                target,
98                origin_id,
99            };
100
101            config.session_tracker.sessions.insert(
102                identifier.clone(),
103                TraceSession {
104                    worker_id,
105                    target: Some(target),
106                    origin_id,
107                    progress: TraceProgress::Binary {
108                        lo,
109                        hi,
110                        mid,
111                        probing_ttl: mid,
112                        window_left: config.max_failures as u8,
113                    },
114                    last_updated: now,
115                },
116            );
117            config
118                .session_tracker
119                .expiration_queue
120                .push_back((identifier, deadline));
121
122            Task {
123                task_type: Some(task::TaskType::Trace(Trace {
124                    dst: Some(target),
125                    ttl: mid as u32,
126                })),
127                origin_id,
128                nprobes: 0,    // single send
129                session_id: 0, // trace probes carry no session
130            }
131        })
132        .collect()
133}
134
135/// Identify unique TraceSession
136#[derive(Hash, PartialEq, Eq, Clone, Debug)]
137pub struct TraceIdentifier {
138    pub worker_id: u32,
139    pub target: Address,
140    pub origin_id: u32,
141}
142
143/// Check ongoing Trace tasks that have timed out (i.e., a hop didn't respond within the timeout)
144///
145/// - **Linear** sessions follow up with TTL + 1, terminating after `max_failures`
146///   consecutive unresponsive hops
147/// - **Binary** sessions (tracemap) first extend the confirmation window past the
148///   silent midpoint (to rule out an interior unresponsive hop); once the window is
149///   exhausted the silent tail is assumed to start at the midpoint and the search
150///   continues in the lower half
151///
152/// # Arguments
153/// * `measurement` - Shared measurement state containing worker_stacks and trace_config
154/// * `cli_sender` - Sender handle for forwarding '*' hops to the CLI
155pub fn check_trace_timeouts(measurement: MeasurementHandle, cli_sender: CliHandle) {
156    // Get traceroute parameters (read once at start — they don't change during a measurement)
157    let (timeout, max_hops, max_failures, star_unresponsive) = {
158        let lock = measurement.read().unwrap();
159        let Some(config) = lock.as_ref().and_then(|state| state.trace_config.as_ref()) else {
160            // The measurement was torn down before this thread started
161            warn!("[Orchestrator] No active traceroute measurement, stopping timeout checker");
162            return;
163        };
164        (
165            config.timeout,
166            config.max_hops,
167            config.max_failures,
168            config.star_unresponsive,
169        )
170    };
171
172    loop {
173        // Check if measurement is finished
174        if measurement.read().unwrap().is_none() {
175            break;
176        }
177
178        // Keep track of tasks to send to the workers
179        let mut tasks_to_send = Vec::new();
180        // `*` (no-reply) hops to forward to the CLI for timed-out hops: (rx_id, origin_id, reply)
181        let mut star_replies: Vec<(u32, u32, TraceReply)> = Vec::new();
182        let now = Instant::now();
183
184        {
185            // Lock measurement state
186            let mut lock = measurement.write().unwrap();
187            if let Some(ref mut state) = *lock
188                && let Some(ref mut config) = state.trace_config
189            {
190                let session_tracker = &mut config.session_tracker;
191
192                // Iteratively check top of the stack (oldest sessions) to see if they timed out
193                while let Some((_id, deadline)) = session_tracker.expiration_queue.front() {
194                    // Deadline is in the future
195                    if *deadline > now {
196                        break;
197                    }
198                    // Pop candidate
199                    let (id, _old_deadline) = session_tracker.expiration_queue.pop_front().unwrap();
200
201                    // The session may have ended in the meantime (drop from the queue)
202                    let Some(session) = session_tracker.sessions.get_mut(&id) else {
203                        continue;
204                    };
205
206                    // Verify the session is still timed out (might have been updated)
207                    let expiration = session.last_updated + Duration::from_secs(timeout);
208                    if expiration > now {
209                        // Still alive (received update during check) -> re-queue with its new deadline
210                        session_tracker.expiration_queue.push_back((id, expiration));
211                        continue;
212                    }
213
214                    // Hop timed out: emit a '*' hop to the CLI for it, if enabled
215                    if star_unresponsive {
216                        let timed_out_ttl = match &session.progress {
217                            TraceProgress::Linear { current_ttl, .. } => *current_ttl,
218                            TraceProgress::Binary { probing_ttl, .. } => *probing_ttl,
219                        };
220                        star_replies.push((
221                            session.worker_id,
222                            session.origin_id,
223                            TraceReply {
224                                tx_id: session.worker_id,
225                                trace_dst: session.target,
226                                hop_count: timed_out_ttl as u32,
227                                ..Default::default() // unresponsive -> None fields
228                            },
229                        ));
230                    }
231
232                    session.last_updated = now;
233
234                    // Advance the session; None means it is finished
235                    let next_ttl = match &mut session.progress {
236                        TraceProgress::Linear {
237                            current_ttl,
238                            consecutive_failures,
239                        } => {
240                            *consecutive_failures += 1;
241                            *current_ttl += 1;
242
243                            if *consecutive_failures > max_failures as u8
244                                || *current_ttl > max_hops as u8
245                            {
246                                None
247                            } else {
248                                Some(*current_ttl)
249                            }
250                        }
251                        TraceProgress::Binary {
252                            lo,
253                            hi,
254                            mid,
255                            probing_ttl,
256                            window_left,
257                        } => {
258                            if *window_left > 0 && *probing_ttl < *hi {
259                                // Probe the next TTL to rule out an interior unresponsive hop
260                                *probing_ttl += 1;
261                                *window_left -= 1;
262                                Some(*probing_ttl)
263                            } else {
264                                // Window exhausted: [mid, probing_ttl] is silent → the tail starts at or before mid
265                                *hi = mid.saturating_sub(1);
266                                if *hi < *lo {
267                                    None // Search converged: deepest responder found
268                                } else {
269                                    *mid = ttl_midpoint(*lo, *hi);
270                                    *probing_ttl = *mid;
271                                    *window_left = max_failures as u8;
272                                    Some(*probing_ttl)
273                                }
274                            }
275                        }
276                    };
277
278                    let Some(next_ttl) = next_ttl else {
279                        session_tracker.sessions.remove(&id);
280                        continue;
281                    };
282
283                    // Measure the next hop and re-queue the session with a fresh deadline
284                    tasks_to_send.push((
285                        session.worker_id,
286                        Task {
287                            task_type: Some(task::TaskType::Trace(Trace {
288                                dst: session.target,
289                                ttl: next_ttl as u32,
290                            })),
291                            origin_id: session.origin_id,
292                            nprobes: 0,    // single send
293                            session_id: 0, // trace probes carry no session
294                        },
295                    ));
296                    session_tracker
297                        .expiration_queue
298                        .push_back((id, now + Duration::from_secs(timeout)));
299                }
300
301                // Put tasks in worker stacks (while we still hold the write lock)
302                for (worker_id, task_to_send) in tasks_to_send {
303                    state
304                        .worker_stacks
305                        .entry(worker_id)
306                        .or_default()
307                        .push_back(task_to_send);
308                }
309            }
310        }
311
312        // Forward '*' hops for timed-out hops to the CLI
313        if !star_replies.is_empty() {
314            let tx_opt = cli_sender.lock().unwrap().clone();
315            if let Some(tx) = tx_opt {
316                for (rx_id, origin_id, reply) in star_replies {
317                    let _ = tx.blocking_send(Ok(ReplyBatch {
318                        rx_id,
319                        results: vec![Reply {
320                            reply_data: Some(ReplyData::Trace(reply)),
321                        }],
322                        origin_id,
323                    }));
324                }
325            }
326        }
327
328        // Sleep for the timeout interval before checking timeouts again
329        thread::sleep(Duration::from_secs(timeout));
330    }
331}