Skip to content

Commit 1ef3f19

Browse files
committed
fix(node): address #462 review findings from beardthelion
- remove the write-only insertion_order bookkeeping (field, remove_insertion_token, and its call sites) and collapse the repeated peers.get/get_mut lookups in IdentifyAddressBook::update into a single entry binding - drop a peer row at the end of update() when every candidate was rejected, so zero-address rows no longer linger until the expiry tick - add a regression test proving per-peer eviction still applies at full global budget (fails if the global check is hoisted above eviction) - extract the explicit-address retention predicate into the tested is_explicit_address helper used by both Kademlia removal sites
1 parent 31f79ed commit 1ef3f19

1 file changed

Lines changed: 113 additions & 91 deletions

File tree

  • crates/gitlawb-node/src/p2p

‎crates/gitlawb-node/src/p2p/mod.rs‎

Lines changed: 113 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,19 @@ fn did_to_kad_key(did: &str) -> kad::RecordKey {
166166
kad::RecordKey::new(&format!("/gitlawb/did/{did}").as_bytes())
167167
}
168168

169+
/// Returns true when `address` was explicitly configured for `peer` through
170+
/// AddKnownPeer. Explicit addresses are retained in Kademlia even when an
171+
/// overlapping Identify lease expires or is evicted.
172+
fn is_explicit_address(
173+
explicit_addresses: &HashMap<PeerId, HashSet<Multiaddr>>,
174+
peer: &PeerId,
175+
address: &Multiaddr,
176+
) -> bool {
177+
explicit_addresses
178+
.get(peer)
179+
.is_some_and(|addresses| addresses.contains(address))
180+
}
181+
169182
#[derive(Debug)]
170183
struct IdentifyAddress {
171184
address: Multiaddr,
@@ -189,7 +202,6 @@ struct IdentifyAddressChanges {
189202
#[derive(Debug, Default)]
190203
struct IdentifyAddressBook {
191204
peers: HashMap<PeerId, IdentifyPeerAddresses>,
192-
insertion_order: VecDeque<(PeerId, Multiaddr)>,
193205
address_count: usize,
194206
}
195207

@@ -212,89 +224,64 @@ impl IdentifyAddressBook {
212224
if reported.is_empty() && !self.peers.contains_key(&peer_id) {
213225
return changes;
214226
}
215-
self.peers
227+
let state = self
228+
.peers
216229
.entry(peer_id)
217230
.or_insert_with(|| IdentifyPeerAddresses {
218231
addresses: VecDeque::new(),
219232
window_started: now,
220233
new_addresses: 0,
221234
});
222235

223-
if let Some(state) = self.peers.get_mut(&peer_id) {
224-
if now.saturating_duration_since(state.window_started) >= IDENTIFY_ADDRESS_WINDOW {
225-
state.window_started = now;
226-
state.new_addresses = 0;
227-
}
236+
if now.saturating_duration_since(state.window_started) >= IDENTIFY_ADDRESS_WINDOW {
237+
state.window_started = now;
238+
state.new_addresses = 0;
228239
}
229240

230241
// Refresh every retained address in the bounded report before admission.
231-
let mut refreshed = Vec::new();
232-
if let Some(state) = self.peers.get_mut(&peer_id) {
233-
for existing in &mut state.addresses {
234-
if reported.contains(&existing.address) {
235-
existing.expires_at = now + IDENTIFY_ADDRESS_TTL;
236-
refreshed.push(existing.address.clone());
237-
}
238-
}
239-
}
240-
// Record refreshed addresses so the Identify handler can re-admit
242+
// Refreshed addresses are reported so the Identify handler can re-admit
241243
// them to Kademlia. Kademlia may have dropped a retained address
242244
// after a failed dial or never kept it for a full k-bucket, and a
243245
// TTL refresh alone would never re-add it.
244-
for address in refreshed {
245-
self.remove_insertion_token(peer_id, &address);
246-
self.insertion_order.push_back((peer_id, address.clone()));
247-
changes.refreshed.push(address);
246+
for existing in &mut state.addresses {
247+
if reported.contains(&existing.address) {
248+
existing.expires_at = now + IDENTIFY_ADDRESS_TTL;
249+
changes.refreshed.push(existing.address.clone());
250+
}
248251
}
249252

250-
let mut new_addresses = self
251-
.peers
252-
.get(&peer_id)
253-
.map(|state| {
254-
reported
253+
let mut new_addresses: Vec<Multiaddr> = reported
254+
.iter()
255+
.filter(|address| {
256+
!state
257+
.addresses
255258
.iter()
256-
.filter(|address| {
257-
!state
258-
.addresses
259-
.iter()
260-
.any(|existing| &existing.address == *address)
261-
})
262-
.cloned()
263-
.collect::<Vec<_>>()
259+
.any(|existing| &existing.address == *address)
264260
})
265-
.unwrap_or_else(|| reported.iter().cloned().collect());
261+
.cloned()
262+
.collect();
266263
// HashSet iteration is deliberately unordered. Sort new candidates so
267264
// an oversized Identify report has deterministic admission behavior.
268265
new_addresses.sort();
269266

270267
for address in new_addresses {
271-
if self
272-
.peers
273-
.get(&peer_id)
274-
.is_some_and(|state| state.new_addresses >= IDENTIFY_NEW_ADDRESS_LIMIT)
275-
{
268+
if state.new_addresses >= IDENTIFY_NEW_ADDRESS_LIMIT {
276269
break;
277270
}
278271

279-
let evicted = self.peers.get_mut(&peer_id).and_then(|state| {
280-
if state.addresses.len() < IDENTIFY_ADDRESS_LIMIT {
281-
return None;
282-
}
283-
let index = state
272+
let evicted = if state.addresses.len() < IDENTIFY_ADDRESS_LIMIT {
273+
None
274+
} else {
275+
state
284276
.addresses
285277
.iter()
286-
.position(|existing| !reported.contains(&existing.address))?;
287-
state.addresses.remove(index)
288-
});
278+
.position(|existing| !reported.contains(&existing.address))
279+
.and_then(|index| state.addresses.remove(index))
280+
};
289281
if let Some(evicted) = evicted {
290-
self.remove_insertion_token(peer_id, &evicted.address);
291282
self.address_count -= 1;
292283
changes.removed.push((peer_id, evicted.address));
293-
} else if self
294-
.peers
295-
.get(&peer_id)
296-
.is_some_and(|state| state.addresses.len() >= IDENTIFY_ADDRESS_LIMIT)
297-
{
284+
} else if state.addresses.len() >= IDENTIFY_ADDRESS_LIMIT {
298285
// Every retained address was refreshed in this report. Keep
299286
// that stable subset and drop surplus new candidates.
300287
break;
@@ -310,19 +297,20 @@ impl IdentifyAddressBook {
310297
break;
311298
}
312299

313-
if let Some(state) = self.peers.get_mut(&peer_id) {
314-
state.new_addresses += 1;
315-
}
300+
state.new_addresses += 1;
301+
state.addresses.push_back(IdentifyAddress {
302+
address: address.clone(),
303+
expires_at: now + IDENTIFY_ADDRESS_TTL,
304+
});
305+
self.address_count += 1;
306+
changes.added.push(address);
307+
}
316308

317-
if let Some(state) = self.peers.get_mut(&peer_id) {
318-
state.addresses.push_back(IdentifyAddress {
319-
address: address.clone(),
320-
expires_at: now + IDENTIFY_ADDRESS_TTL,
321-
});
322-
self.insertion_order.push_back((peer_id, address.clone()));
323-
self.address_count += 1;
324-
changes.added.push(address);
325-
}
309+
// Do not keep a row when every candidate was rejected: a zero-address
310+
// row is invisible to address_count and would otherwise linger until
311+
// the expiry tick. This matches expire_peer, which drops empty rows.
312+
if state.addresses.is_empty() {
313+
self.peers.remove(&peer_id);
326314
}
327315

328316
changes
@@ -343,10 +331,7 @@ impl IdentifyAddressBook {
343331
.get_mut(&peer_id)
344332
.map(|state| Self::expire_state(state, now))
345333
.unwrap_or_default();
346-
for address in &expired {
347-
self.remove_insertion_token(peer_id, address);
348-
self.address_count -= 1;
349-
}
334+
self.address_count -= expired.len();
350335
if self
351336
.peers
352337
.get(&peer_id)
@@ -373,18 +358,6 @@ impl IdentifyAddressBook {
373358
state.addresses = retained;
374359
expired
375360
}
376-
377-
fn remove_insertion_token(&mut self, peer_id: PeerId, address: &Multiaddr) {
378-
if let Some(index) = self
379-
.insertion_order
380-
.iter()
381-
.position(|(token_peer, token_address)| {
382-
token_peer == &peer_id && token_address == address
383-
})
384-
{
385-
self.insertion_order.remove(index);
386-
}
387-
}
388361
}
389362

390363
/// Combined libp2p behaviour.
@@ -523,10 +496,7 @@ pub async fn start(
523496
tokio::select! {
524497
_ = identify_cleanup.tick() => {
525498
for (peer_id, address) in identify_addresses.expire(Instant::now()) {
526-
if !explicit_addresses
527-
.get(&peer_id)
528-
.is_some_and(|addresses| addresses.contains(&address))
529-
{
499+
if !is_explicit_address(&explicit_addresses, &peer_id, &address) {
530500
swarm.behaviour_mut().kademlia.remove_address(&peer_id, &address);
531501
}
532502
}
@@ -622,9 +592,7 @@ pub async fn start(
622592
&info.listen_addrs,
623593
);
624594
for (removed_peer, addr) in changes.removed {
625-
if !explicit_addresses
626-
.get(&removed_peer)
627-
.is_some_and(|addresses| addresses.contains(&addr))
595+
if !is_explicit_address(&explicit_addresses, &removed_peer, &addr)
628596
{
629597
swarm
630598
.behaviour_mut()
@@ -748,7 +716,6 @@ mod tests {
748716
);
749717
}
750718
assert!(book.peers.is_empty());
751-
assert!(book.insertion_order.is_empty());
752719
assert_eq!(book.address_count, 0);
753720
}
754721

@@ -815,7 +782,6 @@ mod tests {
815782
assert_eq!(changes.refreshed, vec![(*refreshed).clone()]);
816783
assert_eq!(book.peers.len(), IDENTIFY_GLOBAL_ADDRESS_LIMIT);
817784
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
818-
assert_eq!(book.insertion_order.len(), book.address_count);
819785
assert_eq!(
820786
book.peers[peer]
821787
.addresses
@@ -826,6 +792,62 @@ mod tests {
826792
);
827793
}
828794

795+
#[test]
796+
fn identify_per_peer_eviction_applies_at_full_global_budget() {
797+
let now = Instant::now();
798+
let mut book = IdentifyAddressBook::default();
799+
// The victim peer holds a full per-peer row while the rest of the
800+
// global budget is filled by one-address peers.
801+
let victim = PeerId::random();
802+
let victim_addresses: Vec<Multiaddr> = (0..IDENTIFY_ADDRESS_LIMIT)
803+
.map(|index| identify_address(victim, index as u8 + 1, 10_000 + index as u16))
804+
.collect();
805+
book.update(victim, now, &victim_addresses);
806+
for index in 0..IDENTIFY_GLOBAL_ADDRESS_LIMIT - IDENTIFY_ADDRESS_LIMIT {
807+
let peer = PeerId::random();
808+
let address = identify_address(peer, 200, 20_000 + index as u16);
809+
book.update(peer, now, &[address]);
810+
}
811+
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
812+
813+
// The victim reports a ninth address after the rate window rolls over.
814+
// Per-peer eviction must still apply at global saturation: the oldest
815+
// non-reported address is evicted to make room, and the global count
816+
// is unchanged. This ordering only holds because eviction runs before
817+
// the global-budget check; hoisting that check above the eviction
818+
// block makes this test fail.
819+
let ninth = identify_address(victim, 100, 30_000);
820+
let changes = book.update(
821+
victim,
822+
now + IDENTIFY_ADDRESS_WINDOW,
823+
std::slice::from_ref(&ninth),
824+
);
825+
assert_eq!(changes.added, vec![ninth]);
826+
assert_eq!(changes.removed, vec![(victim, victim_addresses[0].clone())]);
827+
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
828+
assert_eq!(book.peers[&victim].addresses.len(), IDENTIFY_ADDRESS_LIMIT);
829+
}
830+
831+
#[test]
832+
fn explicit_address_helper_detects_retained_addresses() {
833+
let peer = PeerId::random();
834+
let other = PeerId::random();
835+
let address = identify_address(peer, 1, 10_000);
836+
let mut explicit: HashMap<PeerId, HashSet<Multiaddr>> = HashMap::new();
837+
// Unknown peer: nothing is explicit.
838+
assert!(!is_explicit_address(&explicit, &peer, &address));
839+
explicit.entry(peer).or_default().insert(address.clone());
840+
assert!(is_explicit_address(&explicit, &peer, &address));
841+
// The same address under a different peer is not retained.
842+
assert!(!is_explicit_address(&explicit, &other, &address));
843+
// A different address for the same peer is not retained.
844+
assert!(!is_explicit_address(
845+
&explicit,
846+
&peer,
847+
&identify_address(peer, 2, 10_001)
848+
));
849+
}
850+
829851
#[test]
830852
fn identify_addresses_are_capped_with_fifo_eviction() {
831853
let peer_id = PeerId::random();

0 commit comments

Comments
 (0)