From 9d245ab5e8484271aa66fca915364b26e9798d2a Mon Sep 17 00:00:00 2001 From: tasodoufu <313684430+tasodoufu@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:47:41 +0900 Subject: [PATCH 1/2] arp,ndisc: rate-limit neighbor discovery per destination address The neighbor cache kept a single `silent_until` deadline for the whole cache, so after dispatching a discovery request for one address every lookup for a different uncached address returned `RateLimited` for the next second. Resolving a gateway for DNS therefore delayed the ARP request for the actual peer by up to SILENT_TIME. Track the silence deadline per destination address in a `LinearMap` instead, evicting the entry with the earliest deadline when full. `flush()` now also clears the silence map. --- src/iface/interface/mod.rs | 4 +- src/iface/neighbor.rs | 81 ++++++++++++++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/iface/interface/mod.rs b/src/iface/interface/mod.rs index b17571e85..644f732c1 100644 --- a/src/iface/interface/mod.rs +++ b/src/iface/interface/mod.rs @@ -1201,8 +1201,8 @@ impl InterfaceInner { _ => (), } - // The request got dispatched, limit the rate on the cache. - self.neighbor_cache.limit_rate(self.now); + // The request got dispatched, limit the rate for this destination only. + self.neighbor_cache.limit_rate(dst_addr, self.now); Err(DispatchError::NeighborPending) } diff --git a/src/iface/neighbor.rs b/src/iface/neighbor.rs index 6b5c7d0a3..2ef540afd 100644 --- a/src/iface/neighbor.rs +++ b/src/iface/neighbor.rs @@ -45,7 +45,9 @@ impl Answer { #[derive(Debug)] pub struct Cache { storage: LinearMap, - silent_until: Instant, + /// Silence deadline per address, so discovery rate limiting for one + /// address does not delay discovery for a different one. + silent_until: LinearMap, } impl Cache { @@ -59,7 +61,7 @@ impl Cache { pub fn new() -> Self { Self { storage: LinearMap::new(), - silent_until: Instant::from_millis(0), + silent_until: LinearMap::new(), } } @@ -158,19 +160,38 @@ impl Cache { return Answer::Found(hardware_addr); } - if timestamp < self.silent_until { + if let Some(silent_until) = self.silent_until.get(protocol_addr) + && timestamp < *silent_until + { Answer::RateLimited } else { Answer::NotFound } } - pub(crate) fn limit_rate(&mut self, timestamp: Instant) { - self.silent_until = timestamp + Self::SILENT_TIME; + pub(crate) fn limit_rate(&mut self, protocol_addr: IpAddress, timestamp: Instant) { + // If the map is full, drop the entry with the earliest deadline, + // analogous to how `fill_with_expiration` evicts the oldest neighbor. + if self.silent_until.get(&protocol_addr).is_none() + && self.silent_until.len() >= self.silent_until.capacity() + { + if let Some(earliest) = self + .silent_until + .iter() + .min_by_key(|(_, deadline)| *deadline) + .map(|(addr, _)| *addr) + { + let _ = self.silent_until.remove(&earliest); + } + } + let _ = self + .silent_until + .insert(protocol_addr, timestamp + Self::SILENT_TIME); } pub(crate) fn flush(&mut self) { - self.storage.clear() + self.storage.clear(); + self.silent_until.clear(); } } @@ -305,17 +326,63 @@ mod test { Answer::NotFound ); - cache.limit_rate(Instant::from_millis(0)); + cache.limit_rate(MOCK_IP_ADDR_1.into(), Instant::from_millis(0)); assert_eq!( cache.lookup(&MOCK_IP_ADDR_1.into(), Instant::from_millis(100)), Answer::RateLimited ); + // A different address must not be silenced by a request for another one. + assert_eq!( + cache.lookup(&MOCK_IP_ADDR_2.into(), Instant::from_millis(100)), + Answer::NotFound + ); assert_eq!( cache.lookup(&MOCK_IP_ADDR_1.into(), Instant::from_millis(2000)), Answer::NotFound ); } + #[test] + fn test_hush_is_per_address() { + let mut cache = Cache::new(); + + cache.limit_rate(MOCK_IP_ADDR_1.into(), Instant::from_millis(0)); + assert_eq!( + cache.lookup(&MOCK_IP_ADDR_1.into(), Instant::from_millis(0)), + Answer::RateLimited + ); + assert_eq!( + cache.lookup(&MOCK_IP_ADDR_2.into(), Instant::from_millis(0)), + Answer::NotFound + ); + + cache.limit_rate(MOCK_IP_ADDR_2.into(), Instant::from_millis(100)); + assert_eq!( + cache.lookup(&MOCK_IP_ADDR_1.into(), Instant::from_millis(500)), + Answer::RateLimited + ); + assert_eq!( + cache.lookup(&MOCK_IP_ADDR_2.into(), Instant::from_millis(500)), + Answer::RateLimited + ); + assert_eq!( + cache.lookup(&MOCK_IP_ADDR_3.into(), Instant::from_millis(500)), + Answer::NotFound + ); + } + + #[test] + fn test_flush_clears_silence() { + let mut cache = Cache::new(); + + cache.limit_rate(MOCK_IP_ADDR_1.into(), Instant::from_millis(0)); + cache.flush(); + assert_eq!( + cache.lookup(&MOCK_IP_ADDR_1.into(), Instant::from_millis(0)), + Answer::NotFound + ); + } + #[test] fn test_flush() { let mut cache = Cache::new(); From 99de320b496c2965c67f6990ead2191956f9e7cf Mon Sep 17 00:00:00 2001 From: tasodoufu <313684430+tasodoufu@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:51:53 +0900 Subject: [PATCH 2/2] clippy: collapse nested if in limit_rate --- src/iface/neighbor.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/iface/neighbor.rs b/src/iface/neighbor.rs index 2ef540afd..15c9af919 100644 --- a/src/iface/neighbor.rs +++ b/src/iface/neighbor.rs @@ -174,15 +174,13 @@ impl Cache { // analogous to how `fill_with_expiration` evicts the oldest neighbor. if self.silent_until.get(&protocol_addr).is_none() && self.silent_until.len() >= self.silent_until.capacity() - { - if let Some(earliest) = self + && let Some((earliest, _)) = self .silent_until .iter() .min_by_key(|(_, deadline)| *deadline) - .map(|(addr, _)| *addr) - { - let _ = self.silent_until.remove(&earliest); - } + { + let earliest = *earliest; + let _ = self.silent_until.remove(&earliest); } let _ = self .silent_until