Skip to main content

scion_stack/path/
manager.rs

1// Copyright 2025 Anapaya Systems
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Multipath manager for SCION path selection.
16//!
17//! Runs one task per (src,dst) pair. Each task fetches paths, filters them, applies issue
18//! penalties, ranks candidates, and picks an active path.
19//!
20//! Tasks track expiry, refetch intervals, backoff after failures, and drop entries that go
21//! idle. The Active path for a (src,dst) pair is exposed lock-free via `ArcSwap`.
22//!
23//! All path data comes from the provided `PathFetcher`. Issue reports feed
24//! into reliability scoring and can trigger immediate re-ranking.
25//!
26//! ## Issue Handling & Penalties
27//!
28//! Incoming issues are applied to cached paths immediately and can trigger an active-path
29//! switch. Issues are cached with a timestamp and applied to newly fetched paths.
30//!
31//! Penalties on individual paths and individual cached issues decay over time. Allowing paths
32//! to recover.
33//!
34//! ## Active Path Switching
35//!
36//! If no active path exists, the highest-ranked valid path is selected.
37//! Active path is replaced when it expires, nears expiry, or falls behind the best candidate
38//! by a configured score margin.
39
40// Internal:
41//
42// ## Core components
43//
44// MultiPathManager: Central entry point. Holds configuration, the global issue manager, and a
45// concurrent map from (src, dst) to worker. Spawns a worker on first access and provides lock-free
46// reads to workers.
47//
48// PathSet: Per-tuple worker. Fetches paths, filters them, applies issue penalties, ranks
49// candidates, and maintains an active path. Runs a periodic maintenance loop handling refetch,
50// backoff, and idle shutdown.
51//
52// PathIssueManager:  Global issue cache and broadcast system. Deduplicates issues and notifies all
53// workers of incoming issues.
54//
55// IssueKind / IssueMarker: Describe concrete path problems (SCMP, socket errors). Compute the
56// affected hop or full path, assign a penalty, and support deduplication and decay.
57
58use std::{
59    collections::{HashMap, VecDeque, hash_map},
60    sync::{Arc, Mutex, Weak},
61    time::{Duration, SystemTime},
62};
63
64use scc::HashIndex;
65use scion_sdk_utils::backoff::BackoffConfig;
66use sciparse::{
67    dataplane_path::view::ScionDpPathViewRef, identifier::isd_asn::IsdAsn, path::ScionPath,
68    payload::scmp::model::ScmpErrorMessage,
69};
70use tokio::sync::broadcast::{self};
71
72use crate::{
73    path::{
74        PathStrategy,
75        fetcher::{
76            PathFetcherImpl,
77            traits::{PathFetchError, PathFetcher},
78        },
79        manager::{
80            issues::{IssueKind, IssueMarker, IssueMarkerTarget, SendError},
81            pathset::{PathSet, PathSetHandle, PathSetTask},
82            traits::{PathManager, PathPrefetcher, PathWaitError, SyncPathManager},
83        },
84        types::PathManagerPath,
85    },
86    stack::{ScionSocketSendError, scmp_handler::ScmpErrorReceiver, socket::SendErrorReceiver},
87};
88
89mod algo;
90/// Path issue definitions, including mapping issues to affected targets and their respective
91/// penalties
92mod issues;
93/// Pathsets manage paths for a specific src-dst pair.
94mod pathset;
95/// Path reliability tracking
96pub(crate) mod reliability;
97/// Path fetcher traits and types.
98pub mod traits;
99
100/// Configuration for the `MultiPathManager`.
101#[derive(Debug, Clone, Copy)]
102pub struct MultiPathManagerConfig {
103    /// Maximum number of cached paths per src-dst pair.
104    max_cached_paths_per_pair: usize,
105    /// Interval between path refetches
106    refetch_interval: Duration,
107    /// Minimum duration between path refetches.
108    min_refetch_delay: Duration,
109    /// Minimum remaining expiry before refetching paths.
110    min_expiry_threshold: Duration,
111    /// Maximum idle period before the managed paths are removed.
112    max_idle_period: Duration,
113    /// Backoff configuration for path fetch failures.
114    fetch_failure_backoff: BackoffConfig,
115    /// Count of issues to be cached
116    issue_cache_size: usize,
117    /// Size of the issue cache broadcast channel
118    issue_broadcast_size: usize,
119    /// Time window to ignore duplicate issues
120    issue_deduplication_window: Duration,
121    /// Score difference after which active path should be replaced
122    path_swap_score_threshold: f32,
123}
124
125impl Default for MultiPathManagerConfig {
126    fn default() -> Self {
127        MultiPathManagerConfig {
128            max_cached_paths_per_pair: 50,
129            refetch_interval: Duration::from_secs(60 * 30), // 30 minutes
130            min_refetch_delay: Duration::from_secs(60),
131            min_expiry_threshold: Duration::from_secs(60 * 5), // 5 minutes
132            max_idle_period: Duration::from_secs(60 * 2),      // 2 minutes
133            fetch_failure_backoff: BackoffConfig {
134                minimum_delay_secs: 60.0,
135                maximum_delay_secs: 300.0,
136                factor: 1.5,
137                jitter_secs: 5.0,
138            },
139            issue_cache_size: 100,
140            issue_broadcast_size: 10,
141            // Same issue within 10s is duplicate
142            issue_deduplication_window: Duration::from_secs(10),
143            path_swap_score_threshold: 0.5,
144        }
145    }
146}
147
148impl MultiPathManagerConfig {
149    /// Sets the maximum number of cached paths per src-dst pair.
150    #[must_use]
151    pub fn with_max_cached_paths_per_pair(mut self, max: usize) -> Self {
152        self.max_cached_paths_per_pair = max;
153        self
154    }
155
156    /// Sets the interval between path refetches.
157    #[must_use]
158    pub fn with_refetch_interval(mut self, interval: Duration) -> Self {
159        self.refetch_interval = interval;
160        self
161    }
162
163    /// Sets the minimum duration between path refetches.
164    #[must_use]
165    pub fn with_min_refetch_delay(mut self, delay: Duration) -> Self {
166        self.min_refetch_delay = delay;
167        self
168    }
169
170    /// Sets the minimum remaining expiry before refetching paths.
171    #[must_use]
172    pub fn with_min_expiry_threshold(mut self, threshold: Duration) -> Self {
173        self.min_expiry_threshold = threshold;
174        self
175    }
176
177    /// Sets the maximum idle period before managed paths are removed.
178    #[must_use]
179    pub fn with_max_idle_period(mut self, period: Duration) -> Self {
180        self.max_idle_period = period;
181        self
182    }
183
184    /// Sets the time window during which duplicate issues are ignored.
185    #[must_use]
186    pub fn with_issue_deduplication_window(mut self, window: Duration) -> Self {
187        self.issue_deduplication_window = window;
188        self
189    }
190
191    /// Sets the score difference after which the active path should be replaced.
192    #[must_use]
193    pub fn with_path_swap_score_threshold(mut self, threshold: f32) -> Self {
194        self.path_swap_score_threshold = threshold;
195        self
196    }
197
198    /// Validates the configuration.
199    fn validate(&self) -> Result<(), MultiPathManagerConfigError> {
200        if self.min_refetch_delay > self.refetch_interval {
201            // Otherwise, refetch interval makes no sense.
202            return Err(MultiPathManagerConfigError(
203                "min_refetch_delay must be smaller than refetch_interval",
204            ));
205        }
206
207        if self.min_refetch_delay > self.min_expiry_threshold {
208            // Otherwise, very unlikely, we have paths expiring before we can refetch.
209            return Err(MultiPathManagerConfigError(
210                "min_refetch_delay must be smaller than min_expiry_threshold",
211            ));
212        }
213
214        Ok(())
215    }
216}
217
218/// Error returned when a [`MultiPathManagerConfig`] is invalid.
219#[derive(Debug, thiserror::Error)]
220#[error("invalid path manager configuration: {0}")]
221#[non_exhaustive]
222pub struct MultiPathManagerConfigError(&'static str);
223
224/// Path manager managing multiple paths per src-dst pair.
225pub struct MultiPathManager<F: PathFetcher = PathFetcherImpl>(Arc<MultiPathManagerInner<F>>);
226
227impl<F> Clone for MultiPathManager<F>
228where
229    F: PathFetcher,
230{
231    fn clone(&self) -> Self {
232        MultiPathManager(self.0.clone())
233    }
234}
235
236struct MultiPathManagerInner<F: PathFetcher> {
237    config: MultiPathManagerConfig,
238    fetcher: F,
239    path_strategy: PathStrategy,
240    issue_manager: Mutex<PathIssueManager>,
241    managed_paths: HashIndex<(IsdAsn, IsdAsn), (PathSetHandle, PathSetTask)>,
242}
243
244impl<F: PathFetcher> MultiPathManager<F> {
245    /// Creates a new [`MultiPathManager`].
246    ///
247    /// # Errors
248    ///
249    /// Returns [`MultiPathManagerConfigError`] if `config` is invalid.
250    pub fn new(
251        config: MultiPathManagerConfig,
252        fetcher: F,
253        path_strategy: PathStrategy,
254    ) -> Result<Self, MultiPathManagerConfigError> {
255        config.validate()?;
256
257        let issue_manager = Mutex::new(PathIssueManager::new(
258            config.issue_cache_size,
259            config.issue_broadcast_size,
260            config.issue_deduplication_window,
261        ));
262
263        Ok(MultiPathManager(Arc::new(MultiPathManagerInner {
264            config,
265            fetcher,
266            issue_manager,
267            path_strategy,
268            managed_paths: HashIndex::new(),
269        })))
270    }
271
272    /// Returns the cached active path for the given src-dst pair, if one is available.
273    ///
274    /// If no active path is cached, returns `None`.
275    ///
276    /// If the src-dst pair is not yet managed, starts managing it.
277    pub fn cached_path(&self, src: IsdAsn, dst: IsdAsn, now: SystemTime) -> Option<ScionPath> {
278        let try_path = self
279            .0
280            .managed_paths
281            .peek_with(&(src, dst), |_, (handle, _)| {
282                handle.try_active_path().as_deref().map(|p| p.0.clone())
283            })
284            .flatten();
285
286        match try_path {
287            Some(active) => {
288                // XXX(ake): Since the Paths are actively managed, they should never be expired
289                // here.
290                let timestamp = now
291                    .duration_since(SystemTime::UNIX_EPOCH)
292                    .unwrap_or_default()
293                    .as_secs() as u32;
294
295                let expired = active.is_expired(timestamp).unwrap_or(false);
296
297                debug_assert!(!expired, "Returned expired path from try_get_path");
298
299                Some(active)
300            }
301            None => {
302                // Start managing paths for the src-dst pair
303                self.fast_ensure_managed_paths(src, dst);
304                None
305            }
306        }
307    }
308
309    /// Gets the active path for the given src-dst pair.
310    ///
311    /// If the src-dst pair is not yet managed, starts managing it, possibly waiting for the first
312    /// path fetch.
313    ///
314    /// Returns an error if no path is available after waiting.
315    pub async fn path(
316        &self,
317        src: IsdAsn,
318        dst: IsdAsn,
319        now: SystemTime,
320    ) -> Result<ScionPath, Arc<PathFetchError>> {
321        if src.is_wildcard() || dst.is_wildcard() {
322            return Err(Arc::new(PathFetchError::InternalError(
323                "Wildcard src or dst is not supported".into(),
324            )));
325        }
326
327        if src == dst {
328            return Ok(ScionPath::local(src).expect("Checked for wildcard above"));
329        }
330
331        let try_path = self
332            .0
333            .managed_paths
334            .peek_with(&(src, dst), |_, (handle, _)| {
335                handle.try_active_path().as_deref().map(|p| p.0.clone())
336            })
337            .flatten();
338
339        let res = match try_path {
340            Some(active) => Ok(active),
341            None => {
342                // Ensure paths are being managed
343                let path_set = self.ensure_managed_paths(src, dst);
344
345                // Try to get active path, possibly waiting for initialization/update
346                let active = path_set.active_path().await.as_ref().map(|p| p.0.clone());
347
348                // Check active path after waiting
349                match active {
350                    Some(active) => Ok(active),
351                    None => {
352                        // No active path even after waiting, return last error if any
353                        let last_error = path_set.current_error();
354                        match last_error {
355                            Some(e) => Err(e),
356                            None => {
357                                // There is a chance for a race here, where the error was cleared
358                                // between the wait and now. In that case, we assume no paths were
359                                // found.
360                                Err(Arc::new(PathFetchError::NoPathsFound))
361                            }
362                        }
363                    }
364                }
365            }
366        };
367
368        if let Ok(active) = &res {
369            let timestamp = now
370                .duration_since(SystemTime::UNIX_EPOCH)
371                .unwrap_or_default()
372                .as_secs() as u32;
373
374            // XXX(ake): Since the Paths are actively managed, they should never be expired
375            // here.
376            let expired = active.is_expired(timestamp).unwrap_or(false);
377            debug_assert!(!expired, "Returned expired path from get_path");
378        }
379
380        res
381    }
382
383    /// Creates a weak reference to this [`MultiPathManager`].
384    ///
385    /// Upgrade it back to a strong handle with [`MultiPathManagerRef::upgrade`].
386    pub fn weak_ref(&self) -> MultiPathManagerRef<F> {
387        MultiPathManagerRef(Arc::downgrade(&self.0))
388    }
389
390    /// Quickly ensures that paths are being managed for the given src-dst pair.
391    ///
392    /// Does nothing if paths are already being managed.
393    fn fast_ensure_managed_paths(&self, src: IsdAsn, dst: IsdAsn) {
394        if self.0.managed_paths.contains(&(src, dst)) {
395            return;
396        }
397
398        self.ensure_managed_paths(src, dst);
399    }
400
401    /// Starts managing paths for the given src-dst pair.
402    ///
403    /// Returns a reference to the managed paths.
404    fn ensure_managed_paths(&self, src: IsdAsn, dst: IsdAsn) -> PathSetHandle {
405        let entry = match self.0.managed_paths.entry_sync((src, dst)) {
406            scc::hash_index::Entry::Occupied(occupied) => {
407                tracing::trace!(%src, %dst, "Already managing paths for src-dst pair");
408                occupied
409            }
410            scc::hash_index::Entry::Vacant(vacant) => {
411                tracing::info!(%src, %dst, "Starting to manage paths for src-dst pair");
412                let managed = PathSet::new(
413                    src,
414                    dst,
415                    self.weak_ref(),
416                    self.0.config,
417                    self.0
418                        .issue_manager
419                        .lock()
420                        .expect("lock poisoned")
421                        .issues_subscriber(),
422                );
423
424                vacant.insert_entry(managed.manage())
425            }
426        };
427
428        entry.get().0.clone()
429    }
430
431    /// Stops managing paths for the given src-dst pair.
432    pub fn stop_managing_paths(&self, src: IsdAsn, dst: IsdAsn) {
433        if self.0.managed_paths.remove_sync(&(src, dst)) {
434            tracing::info!(%src, %dst, "Stopped managing paths for src-dst pair");
435        }
436    }
437
438    /// Reports a path issue to the issue manager.
439    pub(crate) fn report_path_issue(&self, timestamp: SystemTime, issue: IssueKind) {
440        let Some(applies_to) = issue.target_type() else {
441            // Not a path issue we care about
442            return;
443        };
444
445        if matches!(applies_to, IssueMarkerTarget::DestinationNetwork { .. }) {
446            // We can't handle dst network issues in a global path manager
447            return;
448        }
449
450        tracing::debug!(%issue, "New path issue");
451
452        let issue_marker = IssueMarker {
453            target: applies_to,
454            timestamp,
455            penalty: issue.penalty(),
456        };
457
458        // Push to issues cache
459        {
460            let mut issues_guard = self.0.issue_manager.lock().expect("lock poisoned");
461            issues_guard.add_issue(issue, issue_marker.clone());
462        }
463    }
464}
465
466impl<F: PathFetcher> ScmpErrorReceiver for MultiPathManager<F> {
467    fn report_scmp_error(&self, scmp_error: ScmpErrorMessage, _path: ScionDpPathViewRef) {
468        self.report_path_issue(SystemTime::now(), IssueKind::Scmp { error: scmp_error });
469    }
470}
471
472impl<F: PathFetcher> SendErrorReceiver for MultiPathManager<F> {
473    fn report_send_error(&self, error: &ScionSocketSendError) {
474        if let Some(send_error) = SendError::from_socket_send_error(error) {
475            self.report_path_issue(SystemTime::now(), IssueKind::Socket { err: send_error });
476        }
477    }
478}
479
480impl<F: PathFetcher> SyncPathManager for MultiPathManager<F> {
481    fn register_path(&self, _src: IsdAsn, _dst: IsdAsn, _now: SystemTime, _path: ScionPath) {
482        // No-op
483        // Based on discussions we do not support externally registered paths in the PathManager
484        // Likely we will handle path mirroring in Connection Based Protocols instead
485    }
486
487    fn try_cached_path(
488        &self,
489        src: IsdAsn,
490        dst: IsdAsn,
491        now: SystemTime,
492    ) -> std::io::Result<Option<ScionPath>> {
493        Ok(self.cached_path(src, dst, now))
494    }
495}
496
497impl<F: PathFetcher> PathManager for MultiPathManager<F> {
498    fn path_wait(
499        &self,
500        src: IsdAsn,
501        dst: IsdAsn,
502        now: SystemTime,
503    ) -> impl std::future::Future<Output = Result<ScionPath, PathWaitError>> + Send + '_ {
504        async move {
505            match self.path(src, dst, now).await {
506                Ok(path) => Ok(path),
507                Err(e) => {
508                    match &*e {
509                        PathFetchError::NoPathsFound => Err(PathWaitError::NoPathFound),
510                        _ => Err(PathWaitError::FetchFailed(e)),
511                    }
512                }
513            }
514        }
515    }
516}
517
518impl<F: PathFetcher> PathPrefetcher for MultiPathManager<F> {
519    fn prefetch_path(&self, src: IsdAsn, dst: IsdAsn) {
520        self.ensure_managed_paths(src, dst);
521    }
522}
523
524/// Weak reference to a [`MultiPathManager`].
525///
526/// Can be upgraded to a strong reference using [`upgrade`](Self::upgrade), mirroring
527/// [`std::sync::Weak::upgrade`].
528pub struct MultiPathManagerRef<F: PathFetcher>(Weak<MultiPathManagerInner<F>>);
529
530impl<F: PathFetcher> Clone for MultiPathManagerRef<F> {
531    fn clone(&self) -> Self {
532        MultiPathManagerRef(self.0.clone())
533    }
534}
535
536impl<F: PathFetcher> MultiPathManagerRef<F> {
537    /// Attempts to upgrade the weak reference to a strong reference.
538    #[must_use]
539    pub fn upgrade(&self) -> Option<MultiPathManager<F>> {
540        self.0.upgrade().map(MultiPathManager)
541    }
542}
543
544/// Path Issue manager
545///
546/// Receives reported issues, deduplicates them, and broadcasts them to all path sets.
547struct PathIssueManager {
548    // Config
549    max_entries: usize,
550    deduplication_window: Duration,
551
552    // Mutable
553    /// Map of issue ID to issue marker
554    cache: HashMap<u64, IssueMarker>,
555    // FiFo queue of issue IDs and their timestamps
556    fifo_issues: VecDeque<(u64, SystemTime)>,
557
558    /// Channel for broadcasting issues
559    issue_broadcast_tx: broadcast::Sender<(u64, IssueMarker)>,
560}
561
562impl PathIssueManager {
563    fn new(max_entries: usize, broadcast_buffer: usize, deduplication_window: Duration) -> Self {
564        let (issue_broadcast_tx, _) = broadcast::channel(broadcast_buffer);
565        PathIssueManager {
566            max_entries,
567            deduplication_window,
568            cache: HashMap::new(),
569            fifo_issues: VecDeque::new(),
570            issue_broadcast_tx,
571        }
572    }
573
574    /// Returns a subscriber to the issue broadcast channel.
575    pub fn issues_subscriber(&self) -> broadcast::Receiver<(u64, IssueMarker)> {
576        self.issue_broadcast_tx.subscribe()
577    }
578
579    /// Adds a new issue to the manager.
580    ///
581    /// Issues might cause the Active path to change immediately.
582    ///
583    /// All issues get cached to be applied to newly fetched paths.
584    ///
585    /// If a similar issue, applying to the same Path is seen in the deduplication window, it will
586    /// be ignored.
587    pub fn add_issue(&mut self, issue: IssueKind, marker: IssueMarker) {
588        let id = issue.dedup_id(&marker.target);
589
590        // Check if we already have this issue
591        if let Some(existing_marker) = self.cache.get(&id) {
592            let time_since_last_seen = marker
593                .timestamp
594                .duration_since(existing_marker.timestamp)
595                .unwrap_or_else(|_| Duration::from_secs(0));
596
597            if time_since_last_seen < self.deduplication_window {
598                tracing::trace!(%id, ?time_since_last_seen, ?marker, %issue, "Ignoring duplicate path issue");
599                // Too soon since last seen, ignore
600                return;
601            }
602        }
603
604        // Broadcast issue
605        self.issue_broadcast_tx.send((id, marker.clone())).ok();
606
607        if self.cache.len() >= self.max_entries {
608            self.pop_front();
609        }
610
611        // Insert issue
612        self.fifo_issues.push_back((id, marker.timestamp)); // Store timestamp for matching on removal
613        self.cache.insert(id, marker);
614    }
615
616    /// Applies all cached issues to the given path.
617    ///
618    /// This is called when a path is fetched, to ensure that issues affecting it are applied.
619    /// Should only be called on fresh paths.
620    ///
621    /// Returns true if any issues were applied.
622    /// Returns the max
623    pub fn apply_cached_issues(&self, entry: &mut PathManagerPath, now: SystemTime) -> bool {
624        let mut applied = false;
625        for issue in self.cache.values() {
626            if issue
627                .target
628                .matches_path(&entry.path, &entry.scion_path().fingerprint())
629            {
630                entry.reliability.update(issue.decayed_penalty(now), now);
631                applied = true;
632            }
633        }
634        applied
635    }
636
637    /// Pops the oldest issue from the cache.
638    fn pop_front(&mut self) -> Option<IssueMarker> {
639        let (issue_id, timestamp) = self.fifo_issues.pop_front()?;
640
641        match self.cache.entry(issue_id) {
642            hash_map::Entry::Occupied(occupied_entry) => {
643                // Only remove if timestamps match
644                if occupied_entry.get().timestamp == timestamp {
645                    Some(occupied_entry.remove())
646                } else {
647                    None
648                }
649            }
650            hash_map::Entry::Vacant(_) => {
651                debug_assert!(false, "Bad cache: issue ID not found in cache");
652                None
653            }
654        }
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use helpers::*;
661    use tokio::time::timeout;
662
663    use super::*;
664
665    // The manager should create path sets on request
666    #[tokio::test]
667    #[test_log::test]
668    async fn should_create_pathset_on_request() {
669        let cfg = base_config();
670        let fetcher = MockFetcher::new(generate_responses(5, 0, BASE_TIME, DEFAULT_EXP_UNITS));
671
672        let mgr = MultiPathManager::new(cfg, fetcher, PathStrategy::default())
673            .expect("Should create manager");
674
675        // Initially no managed paths
676        assert!(mgr.0.managed_paths.is_empty());
677
678        // Request a path - should create path set
679        let path = mgr.cached_path(SRC_ADDR.isd_asn(), DST_ADDR.isd_asn(), BASE_TIME);
680        // First call returns None (not yet initialized)
681        assert!(path.is_none());
682
683        // But path set should be created
684        assert!(
685            mgr.0
686                .managed_paths
687                .contains(&(SRC_ADDR.isd_asn(), DST_ADDR.isd_asn()))
688        );
689    }
690
691    // An invalid configuration is rejected by `MultiPathManager::new`.
692    #[tokio::test]
693    #[test_log::test]
694    async fn new_rejects_invalid_config() {
695        let mut cfg = base_config();
696        // `min_refetch_delay` must not exceed `refetch_interval`; violate that invariant.
697        cfg.min_refetch_delay = cfg.refetch_interval + Duration::from_secs(1);
698        let fetcher = MockFetcher::new(generate_responses(1, 0, BASE_TIME, DEFAULT_EXP_UNITS));
699
700        let err = match MultiPathManager::new(cfg, fetcher, PathStrategy::default()) {
701            Ok(_) => panic!("invalid config should be rejected"),
702            Err(e) => e,
703        };
704        assert!(
705            err.to_string().contains("min_refetch_delay"),
706            "unexpected error message: {err}"
707        );
708    }
709
710    // The manager should remove idle path sets
711    #[tokio::test]
712    #[test_log::test]
713    async fn should_remove_idle_pathsets() {
714        let mut cfg = base_config();
715        cfg.max_idle_period = Duration::from_millis(10); // Short idle period for testing
716
717        let fetcher = MockFetcher::new(generate_responses(5, 0, BASE_TIME, DEFAULT_EXP_UNITS));
718
719        let mgr = MultiPathManager::new(cfg, fetcher, PathStrategy::default())
720            .expect("Should create manager");
721
722        // Create path set
723        let handle = mgr.ensure_managed_paths(SRC_ADDR.isd_asn(), DST_ADDR.isd_asn());
724
725        // Should exist
726        assert!(
727            mgr.0
728                .managed_paths
729                .contains(&(SRC_ADDR.isd_asn(), DST_ADDR.isd_asn()))
730        );
731
732        // Wait for idle timeout plus some margin
733        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
734
735        // Path set should be removed by idle check
736        let contains = mgr
737            .0
738            .managed_paths
739            .contains(&(SRC_ADDR.isd_asn(), DST_ADDR.isd_asn()));
740
741        assert!(!contains, "Idle path set should be removed");
742
743        let err = handle.current_error();
744        assert!(
745            err.is_some(),
746            "Handle should report error after path set removal"
747        );
748        println!("Error after idle removal: {err:?}");
749        assert!(
750            err.unwrap().to_string().contains("idle"),
751            "Error message should indicate idle removal"
752        );
753    }
754
755    // Dropping the manager should cancel all path set maintenance tasks
756    #[tokio::test]
757    #[test_log::test]
758    async fn should_cancel_pathset_tasks_on_drop() {
759        let cfg: MultiPathManagerConfig = base_config();
760        let fetcher = MockFetcher::new(generate_responses(5, 0, BASE_TIME, DEFAULT_EXP_UNITS));
761
762        let mgr = MultiPathManager::new(cfg, fetcher, PathStrategy::default())
763            .expect("Should create manager");
764
765        // ensure path set exists and initialized
766        let handle = mgr.ensure_managed_paths(SRC_ADDR.isd_asn(), DST_ADDR.isd_asn());
767        handle.wait_initialized().await;
768
769        let mut set_entry = mgr
770            .0
771            .managed_paths
772            .get_sync(&(SRC_ADDR.isd_asn(), DST_ADDR.isd_asn()))
773            .unwrap();
774
775        let task_handle = unsafe {
776            // swap join handle with a fake one, only possible since the manager doesn't use
777            // the handle
778            let swap_handle = tokio::spawn(async {});
779            std::mem::replace(&mut set_entry.get_mut().1.task, swap_handle)
780        };
781
782        let cancel_token = set_entry.get().1.cancel_token.clone();
783
784        let count = mgr.0.managed_paths.len();
785        assert_eq!(count, 1, "Should have 1 managed path set");
786
787        // Drop the manager
788        drop(mgr);
789        // Cancel token should be triggered
790        assert!(
791            cancel_token.is_cancelled(),
792            "Cancel token should be triggered"
793        );
794
795        // Give tasks time to detect manager drop and exit
796        timeout(Duration::from_millis(50), task_handle)
797            .await
798            .unwrap()
799            .unwrap();
800
801        let err = handle
802            .shared
803            .sync
804            .lock()
805            .unwrap()
806            .current_error
807            .clone()
808            .expect("Should have error after manager drop");
809
810        // XXX(ake): exit reason may vary between "cancelled" and "manager dropped" because
811        // of select!
812        assert!(
813            err.to_string().contains("cancelled") || err.to_string().contains("dropped"),
814            "Error message should indicate cancellation or manager drop"
815        );
816    }
817
818    mod issue_handling {
819        use scc::HashIndex;
820        use sciparse::identifier::{asn::Asn, isd::Isd};
821
822        use super::*;
823        use crate::path::{
824            manager::{MultiPathManagerInner, PathIssueManager, reliability::ReliabilityScore},
825            types::Score,
826        };
827
828        // When an issue is ingested, affected paths should have their reliability scores
829        // updated appropriately The issue should be in the issue cache
830        #[tokio::test]
831        #[test_log::test]
832        async fn should_ingest_issues_and_apply_to_existing_paths() {
833            let cfg = base_config();
834            let fetcher = MockFetcher::new(generate_responses(5, 0, BASE_TIME, DEFAULT_EXP_UNITS));
835            let (mgr, mut path_set) = manual_pathset(BASE_TIME, fetcher.clone(), cfg, None);
836
837            path_set.maintain(BASE_TIME, &mgr).await;
838
839            // Get the first path to create an issue for
840            let first_path = &path_set.internal.cached_paths[0];
841            let first_fp = first_path.scion_path().fingerprint();
842
843            // Create an issue targeting the first hop of the first path
844            let issue = IssueKind::Socket {
845                err: SendError::FirstHopUnreachable {
846                    isd_asn: first_path.path.src_ia(),
847                    interface_id: first_path.path.first_egress_interface().unwrap().id,
848                    address: None,
849                    msg: "test".into(),
850                },
851            };
852
853            let penalty = Score::new_clamped(-0.3);
854            let marker = IssueMarker {
855                target: issue.target_type().unwrap(),
856                timestamp: BASE_TIME,
857                penalty,
858            };
859
860            {
861                let mut issues_guard = mgr.0.issue_manager.lock().unwrap();
862                // Add issue to manager
863                issues_guard.add_issue(issue, marker);
864                // Check issue is in cache
865                assert!(!issues_guard.cache.is_empty(), "Issue should be in cache");
866            }
867            // Handle the issue in path_set
868            let recv_result = path_set.internal.issue_rx.recv().await;
869            path_set.handle_issue_rx(BASE_TIME, recv_result, &mgr);
870
871            // Check that the path's score was updated
872            let updated_path = path_set
873                .internal
874                .cached_paths
875                .iter()
876                .find(|e| e.scion_path().fingerprint() == first_fp)
877                .expect("Path should still exist");
878
879            let updated_score = updated_path.reliability.score(BASE_TIME).value();
880
881            assert!(
882                updated_score == penalty.value(),
883                "Path score should be updated by penalty. Expected: {}, Got: {}",
884                penalty.value(),
885                updated_score
886            );
887
888            // Should decay over time
889            let later_time = BASE_TIME + Duration::from_secs(30);
890            let decayed_score = updated_path.reliability.score(later_time).value();
891            assert!(
892                decayed_score > updated_score,
893                "Path score should recover over time. Updated: {updated_score}, Decayed: {decayed_score}"
894            );
895        }
896
897        #[tokio::test]
898        #[test_log::test]
899        async fn should_deduplicate_issues_within_window() {
900            let cfg = base_config();
901            let mgr_inner = MultiPathManagerInner {
902                config: cfg,
903                fetcher: MockFetcher::new(Ok(vec![])),
904                path_strategy: PathStrategy::default(),
905                issue_manager: Mutex::new(PathIssueManager::new(64, 64, Duration::from_secs(10))),
906                managed_paths: HashIndex::new(),
907            };
908            let mgr = MultiPathManager(Arc::new(mgr_inner));
909
910            let issue_marker = IssueMarker {
911                target: IssueMarkerTarget::FirstHop {
912                    isd_asn: SRC_ADDR.isd_asn(),
913                    egress_interface: 1,
914                },
915                timestamp: BASE_TIME,
916                penalty: Score::new_clamped(-0.3),
917            };
918
919            let issue = IssueKind::Socket {
920                err: SendError::FirstHopUnreachable {
921                    isd_asn: SRC_ADDR.isd_asn(),
922                    interface_id: 1,
923                    address: None,
924                    msg: "test".into(),
925                },
926            };
927
928            // Add issue first time
929            mgr.0
930                .issue_manager
931                .lock()
932                .unwrap()
933                .add_issue(issue.clone(), issue_marker.clone());
934            let cache_size_1 = mgr.0.issue_manager.lock().unwrap().cache.len();
935            assert_eq!(cache_size_1, 1);
936
937            // Add same issue within dedup window (should be ignored)
938            let issue_marker_2 = IssueMarker {
939                timestamp: BASE_TIME + Duration::from_secs(1), // Within 10s window
940                ..issue_marker.clone()
941            };
942            mgr.0
943                .issue_manager
944                .lock()
945                .unwrap()
946                .add_issue(issue.clone(), issue_marker_2);
947
948            let fifo_size = mgr.0.issue_manager.lock().unwrap().fifo_issues.len();
949            let cache_size_2 = mgr.0.issue_manager.lock().unwrap().cache.len();
950            assert_eq!(cache_size_2, 1, "Duplicate issue should be ignored");
951            assert_eq!(
952                fifo_size, 1,
953                "FIFO queue size should remain unchanged on duplicate issue"
954            );
955
956            // Add same issue outside dedup window (should be added)
957            let issue_marker_3 = IssueMarker {
958                timestamp: BASE_TIME + Duration::from_secs(11), // Outside 10s window
959                ..issue_marker
960            };
961            mgr.0
962                .issue_manager
963                .lock()
964                .unwrap()
965                .add_issue(issue, issue_marker_3);
966
967            let fifo_size_3 = mgr.0.issue_manager.lock().unwrap().fifo_issues.len();
968            let cache_size_3 = mgr.0.issue_manager.lock().unwrap().cache.len();
969            assert_eq!(
970                cache_size_3, 1,
971                "Issue outside dedup window should update existing"
972            );
973            assert_eq!(
974                fifo_size_3, 2,
975                "FIFO queue size should increase for new issue outside dedup window"
976            );
977        }
978
979        // When new paths are fetched, existing issues in the issue cache should be applied to
980        // them
981        #[tokio::test]
982        #[test_log::test]
983        async fn should_apply_issues_to_new_paths_on_fetch() {
984            let cfg = base_config();
985            let fetcher = MockFetcher::new(Ok(vec![]));
986            let (mgr, mut path_set) = manual_pathset(BASE_TIME, fetcher.clone(), cfg, None);
987
988            path_set.maintain(BASE_TIME, &mgr).await;
989
990            // Create an issue
991            let issue_marker = IssueMarker {
992                target: IssueMarkerTarget::FirstHop {
993                    isd_asn: SRC_ADDR.isd_asn(),
994                    egress_interface: 1,
995                },
996                timestamp: BASE_TIME,
997                penalty: Score::new_clamped(-0.5),
998            };
999
1000            let issue = IssueKind::Socket {
1001                err: SendError::FirstHopUnreachable {
1002                    isd_asn: SRC_ADDR.isd_asn(),
1003                    interface_id: 1,
1004                    address: None,
1005                    msg: "test".into(),
1006                },
1007            };
1008
1009            // Add to manager's issue cache
1010            mgr.0
1011                .issue_manager
1012                .lock()
1013                .unwrap()
1014                .add_issue(issue, issue_marker);
1015
1016            // Drain issue channel so no issues are pending
1017            path_set.drain_and_apply_issue_channel(BASE_TIME);
1018
1019            // Now fetch paths again - the issue should be applied to the newly fetched path
1020            fetcher.lock().unwrap().set_response(generate_responses(
1021                3,
1022                0,
1023                BASE_TIME + Duration::from_secs(1),
1024                DEFAULT_EXP_UNITS,
1025            ));
1026
1027            let next_refetch = path_set.internal.next_refetch;
1028            path_set.maintain(next_refetch, &mgr).await;
1029
1030            // The newly fetched path should have the penalty applied
1031            let affected_path = path_set
1032                .internal
1033                .cached_paths
1034                .first()
1035                .expect("Path should exist");
1036
1037            let score = affected_path
1038                .reliability
1039                .score(BASE_TIME + Duration::from_secs(1))
1040                .value();
1041            assert!(
1042                score < 0.0,
1043                "Newly fetched path should have cached issue applied. Score: {score}"
1044            );
1045        }
1046
1047        // If the active path is affected by an issue, it should be re-evaluated
1048        #[tokio::test]
1049        #[test_log::test]
1050        async fn should_trigger_active_path_reevaluation_on_issue() {
1051            let cfg = base_config();
1052            let fetcher = MockFetcher::new(generate_responses(5, 0, BASE_TIME, DEFAULT_EXP_UNITS));
1053            let (mgr, mut path_set) = manual_pathset(BASE_TIME, fetcher.clone(), cfg, None);
1054
1055            path_set.maintain(BASE_TIME, &mgr).await;
1056
1057            let active_fp = path_set.shared.active_path.load().as_ref().unwrap().1;
1058
1059            // Create a severe issue targeting the active path
1060            let issue_marker = IssueMarker {
1061                target: IssueMarkerTarget::FullPath {
1062                    fingerprint: active_fp,
1063                },
1064                timestamp: BASE_TIME,
1065                penalty: Score::new_clamped(-1.0), // Severe penalty
1066            };
1067
1068            let issue = IssueKind::Socket {
1069                err: SendError::FirstHopUnreachable {
1070                    isd_asn: SRC_ADDR.isd_asn(),
1071                    interface_id: 1,
1072                    address: None,
1073                    msg: "test".into(),
1074                },
1075            };
1076
1077            // Add issue
1078            mgr.0
1079                .issue_manager
1080                .lock()
1081                .unwrap()
1082                .add_issue(issue, issue_marker);
1083
1084            // Handle issue
1085            let recv_result = path_set.internal.issue_rx.recv().await;
1086            path_set.handle_issue_rx(BASE_TIME, recv_result, &mgr);
1087
1088            // Active path should have changed
1089            let new_active_fp = path_set.shared.active_path.load().as_ref().unwrap().1;
1090            assert_ne!(
1091                active_fp, new_active_fp,
1092                "Active path should change when severely penalized"
1093            );
1094        }
1095
1096        #[tokio::test]
1097        #[test_log::test]
1098        async fn should_swap_to_better_path_if_one_appears() {
1099            let cfg = base_config();
1100            let fetcher = MockFetcher::new(generate_responses(1, 0, BASE_TIME, DEFAULT_EXP_UNITS));
1101            let (mgr, mut path_set) = manual_pathset(BASE_TIME, fetcher.clone(), cfg, None);
1102
1103            path_set.maintain(BASE_TIME, &mgr).await;
1104
1105            // mark as used to prevent idle removal
1106            path_set
1107                .shared
1108                .was_used_in_idle_period
1109                .store(true, std::sync::atomic::Ordering::Relaxed);
1110
1111            let active_fp = path_set.shared.active_path.load().as_ref().unwrap().1;
1112
1113            // add issue to active path to lower its score
1114            let issue_marker = IssueMarker {
1115                target: IssueMarkerTarget::FullPath {
1116                    fingerprint: active_fp,
1117                },
1118                timestamp: BASE_TIME,
1119                penalty: Score::new_clamped(-0.8),
1120            };
1121
1122            mgr.0.issue_manager.lock().unwrap().add_issue(
1123                IssueKind::Socket {
1124                    err: SendError::FirstHopUnreachable {
1125                        isd_asn: SRC_ADDR.isd_asn(),
1126                        interface_id: 1,
1127                        address: None,
1128                        msg: "test".into(),
1129                    },
1130                },
1131                issue_marker,
1132            );
1133
1134            // active path should be the same
1135            let active_fp_after_issue = path_set.shared.active_path.load().as_ref().unwrap().1;
1136            assert_eq!(
1137                active_fp, active_fp_after_issue,
1138                "Active path should remain the same if no better path exists"
1139            );
1140
1141            // Now fetch a better path
1142            fetcher.lock().unwrap().set_response(generate_responses(
1143                1,
1144                100,
1145                BASE_TIME + Duration::from_secs(1),
1146                DEFAULT_EXP_UNITS,
1147            ));
1148
1149            path_set
1150                .maintain(path_set.internal.next_refetch, &mgr)
1151                .await;
1152            // mark as used to prevent idle removal
1153            path_set
1154                .shared
1155                .was_used_in_idle_period
1156                .store(true, std::sync::atomic::Ordering::Relaxed);
1157
1158            // Active path should have changed
1159            let new_active_fp = path_set.shared.active_path.load().as_ref().unwrap().1;
1160            assert_ne!(
1161                active_fp, new_active_fp,
1162                "Active path should change when a better path appears"
1163            );
1164
1165            // Should also work for positive score changes
1166            let positive_score = Score::new_clamped(0.8);
1167            let mut reliability = ReliabilityScore::new_with_time(path_set.internal.next_refetch);
1168            reliability.update(positive_score, path_set.internal.next_refetch);
1169
1170            // Change old paths reliability to be better
1171            path_set
1172                .internal
1173                .cached_paths
1174                .iter_mut()
1175                .find(|e| e.scion_path().fingerprint() == active_fp)
1176                .unwrap()
1177                .reliability = reliability;
1178
1179            path_set
1180                .maintain(path_set.internal.next_refetch, &mgr)
1181                .await;
1182
1183            assert_eq!(
1184                active_fp,
1185                path_set.shared.active_path.load().as_ref().unwrap().1,
1186                "Active path should change on positive score diff"
1187            );
1188        }
1189
1190        #[tokio::test]
1191        #[test_log::test]
1192        async fn should_keep_max_issue_cache_size() {
1193            let max_size = 10;
1194            let mut issue_mgr = PathIssueManager::new(max_size, 64, Duration::from_secs(10));
1195
1196            // Add more issues than max_size
1197            for i in 0..20u16 {
1198                let issue_marker = IssueMarker {
1199                    target: IssueMarkerTarget::FirstHop {
1200                        isd_asn: IsdAsn::new(Isd(1), Asn(1)),
1201                        egress_interface: i,
1202                    },
1203                    timestamp: BASE_TIME + Duration::from_secs(u64::from(i)),
1204                    penalty: Score::new_clamped(-0.1),
1205                };
1206
1207                let issue = IssueKind::Socket {
1208                    err: SendError::FirstHopUnreachable {
1209                        isd_asn: IsdAsn::new(Isd(1), Asn(1)),
1210                        interface_id: i,
1211                        address: None,
1212                        msg: "test".into(),
1213                    },
1214                };
1215
1216                issue_mgr.add_issue(issue, issue_marker);
1217            }
1218
1219            // Cache should not exceed max_size
1220            assert!(
1221                issue_mgr.cache.len() <= max_size,
1222                "Cache size {} should not exceed max {}",
1223                issue_mgr.cache.len(),
1224                max_size
1225            );
1226
1227            // FIFO queue should match cache size
1228            assert_eq!(issue_mgr.cache.len(), issue_mgr.fifo_issues.len());
1229        }
1230    }
1231
1232    pub mod helpers {
1233        use std::{
1234            hash::{DefaultHasher, Hash, Hasher},
1235            net::{IpAddr, Ipv4Addr},
1236            sync::{Arc, Mutex},
1237            time::{Duration, SystemTime},
1238        };
1239
1240        use sciparse::{
1241            address::ip_addr::ScionIpAddr,
1242            identifier::{asn::Asn, isd::Isd},
1243            util::test_builder::TestPathBuilder,
1244        };
1245        use tokio::sync::Notify;
1246
1247        use super::*;
1248        use crate::path::manager::{MultiPathManagerInner, PathIssueManager, pathset::PathSet};
1249
1250        pub const SRC_ADDR: ScionIpAddr =
1251            ScionIpAddr::new(IsdAsn::new(Isd(1), Asn(1)), IpAddr::V4(Ipv4Addr::LOCALHOST));
1252        pub const DST_ADDR: ScionIpAddr = ScionIpAddr::new(
1253            IsdAsn::new(Isd(2), Asn(1)),
1254            IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)),
1255        );
1256
1257        pub const DEFAULT_EXP_UNITS: u8 = 100;
1258        pub const BASE_TIME: SystemTime = SystemTime::UNIX_EPOCH;
1259
1260        pub fn dummy_path(hop_count: u16, timestamp: u32, exp_units: u8, seed: u32) -> ScionPath {
1261            let mut builder = TestPathBuilder::new(SRC_ADDR.into(), DST_ADDR.into())
1262                .using_info_timestamp(timestamp)
1263                .with_hop_expiry(exp_units)
1264                .up();
1265
1266            builder = builder.add_hop(0, 1);
1267
1268            for cnt in 0..hop_count {
1269                let mut hash = DefaultHasher::new();
1270                seed.hash(&mut hash);
1271                cnt.hash(&mut hash);
1272                let hash = hash.finish() as u32;
1273
1274                let hop = hash.saturating_sub(2) as u16; // ensure no underflow or overflow
1275                builder = builder.with_asn(hash).add_hop(hop + 1, hop + 2);
1276            }
1277
1278            builder = builder.add_hop(1, 0);
1279
1280            builder.build(timestamp).path()
1281        }
1282
1283        pub fn base_config() -> MultiPathManagerConfig {
1284            MultiPathManagerConfig {
1285                max_cached_paths_per_pair: 5,
1286                refetch_interval: Duration::from_secs(100),
1287                min_refetch_delay: Duration::from_secs(1),
1288                min_expiry_threshold: Duration::from_secs(5),
1289                max_idle_period: Duration::from_secs(30),
1290                fetch_failure_backoff: BackoffConfig {
1291                    minimum_delay_secs: 1.0,
1292                    maximum_delay_secs: 10.0,
1293                    factor: 2.0,
1294                    jitter_secs: 0.0,
1295                },
1296                issue_cache_size: 64,
1297                issue_broadcast_size: 64,
1298                issue_deduplication_window: Duration::from_secs(10),
1299                path_swap_score_threshold: 0.1,
1300            }
1301        }
1302
1303        pub fn generate_responses(
1304            path_count: u16,
1305            path_seed: u32,
1306            timestamp: SystemTime,
1307            exp_units: u8,
1308        ) -> Result<Vec<ScionPath>, String> {
1309            let mut paths = Vec::new();
1310            for resp_id in 0..path_count {
1311                paths.push(dummy_path(
1312                    2,
1313                    timestamp
1314                        .duration_since(SystemTime::UNIX_EPOCH)
1315                        .unwrap()
1316                        .as_secs() as u32,
1317                    exp_units,
1318                    path_seed + u32::from(resp_id),
1319                ));
1320            }
1321
1322            Ok(paths)
1323        }
1324
1325        pub struct MockFetcher {
1326            next_response: Result<Vec<ScionPath>, String>,
1327            pub received_requests: usize,
1328            pub wait_till_notify: bool,
1329            pub notify_to_resolve: Arc<Notify>,
1330        }
1331        impl MockFetcher {
1332            pub fn new(response: Result<Vec<ScionPath>, String>) -> Arc<Mutex<Self>> {
1333                Arc::new(Mutex::new(Self {
1334                    next_response: response,
1335                    received_requests: 0,
1336                    wait_till_notify: false,
1337                    notify_to_resolve: Arc::new(Notify::new()),
1338                }))
1339            }
1340
1341            pub fn set_response(&mut self, response: Result<Vec<ScionPath>, String>) {
1342                self.next_response = response;
1343            }
1344
1345            pub fn wait_till_notify(&mut self, wait: bool) {
1346                self.wait_till_notify = wait;
1347            }
1348
1349            pub fn notify(&self) {
1350                self.notify_to_resolve.notify_waiters();
1351            }
1352        }
1353
1354        impl PathFetcher for Arc<Mutex<MockFetcher>> {
1355            async fn fetch_paths(
1356                &self,
1357                _src: IsdAsn,
1358                _dst: IsdAsn,
1359            ) -> Result<Vec<ScionPath>, PathFetchError> {
1360                let response;
1361                // Wait for notification if needed
1362                let notify = {
1363                    let mut guard = self.lock().unwrap();
1364
1365                    guard.received_requests += 1;
1366                    response = guard.next_response.clone();
1367
1368                    // maybe wait till notified
1369                    if guard.wait_till_notify {
1370                        let notif = guard.notify_to_resolve.clone().notified_owned();
1371                        Some(notif)
1372                    } else {
1373                        None
1374                    }
1375                };
1376
1377                if let Some(notif) = notify {
1378                    notif.await;
1379                }
1380
1381                match response {
1382                    Ok(paths) if paths.is_empty() => Err(PathFetchError::NoPathsFound),
1383                    Ok(paths) => Ok(paths),
1384                    Err(e) => Err(PathFetchError::InternalError(e.into())),
1385                }
1386            }
1387        }
1388
1389        pub fn manual_pathset<F: PathFetcher>(
1390            now: SystemTime,
1391            fetcher: F,
1392            cfg: MultiPathManagerConfig,
1393            strategy: Option<PathStrategy>,
1394        ) -> (MultiPathManager<F>, PathSet<F>) {
1395            let mgr_inner = MultiPathManagerInner {
1396                config: cfg,
1397                fetcher,
1398                path_strategy: strategy.unwrap_or_else(|| {
1399                    let mut ps = PathStrategy::default();
1400                    ps.scoring.use_default_scorers();
1401                    ps
1402                }),
1403                issue_manager: Mutex::new(PathIssueManager::new(64, 64, Duration::from_secs(10))),
1404                managed_paths: HashIndex::new(),
1405            };
1406            let mgr = MultiPathManager(Arc::new(mgr_inner));
1407            let issue_rx = mgr.0.issue_manager.lock().unwrap().issues_subscriber();
1408            let mgr_ref = mgr.weak_ref();
1409            (
1410                mgr,
1411                PathSet::new_with_time(
1412                    SRC_ADDR.isd_asn(),
1413                    DST_ADDR.isd_asn(),
1414                    mgr_ref,
1415                    cfg,
1416                    issue_rx,
1417                    now,
1418                ),
1419            )
1420        }
1421    }
1422}