From 79b17806482a6b155ebd542ebf8f8d6c30517b79 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 20 Jun 2026 02:01:29 -0400 Subject: [PATCH] fix(#24): evict lowest-scored path when contact history is full PathHistoryService capped each contact's recentPaths at 100 but dropped any new distinct path once full, so the list froze on the first 100 ever seen and never refreshed with newer/better routes. Evict the lowest-_scorePathRecord entry to make room, keeping the strongest 100. Adds a regression test (full history evicts the weakest to admit a new path). Co-Authored-By: Claude Opus 4.8 --- lib/services/path_history_service.dart | 28 ++++++++- test/services/path_history_service_test.dart | 65 ++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart index fc81c56..193d819 100644 --- a/lib/services/path_history_service.dart +++ b/lib/services/path_history_service.dart @@ -341,7 +341,10 @@ class PathHistoryService extends ChangeNotifier { updatedPaths.removeWhere((p) => _pathsEqual(p.pathBytes, pathBytes)); if (existing == null && updatedPaths.length >= _maxHistoryEntries) { - return; + // History is full: evict the lowest-scored path to make room rather than + // dropping the newcomer, so the list keeps refreshing with better routes + // instead of freezing on the first _maxHistoryEntries ever seen. See #24. + _evictLowestScoredPath(updatedPaths); } updatedPaths.insert(0, newRecord); @@ -359,6 +362,29 @@ class PathHistoryService extends ChangeNotifier { notifyListeners(); } + /// Remove the single lowest-scored path from [paths] in place, using the same + /// ranking as path selection so the strongest routes are the ones kept when + /// the history is at capacity. + void _evictLowestScoredPath(List paths) { + if (paths.isEmpty) return; + final fastestTripMs = _getFastestKnownTripMs(paths); + final highestRouteWeight = _getHighestKnownRouteWeight(paths); + var worstIndex = 0; + var worstScore = double.infinity; + for (var i = 0; i < paths.length; i++) { + final score = _scorePathRecord( + paths[i], + fastestTripMs: fastestTripMs, + highestRouteWeight: highestRouteWeight, + ); + if (score < worstScore) { + worstScore = score; + worstIndex = i; + } + } + paths.removeAt(worstIndex); + } + List getRecentPaths(String contactPubKeyHex) { final history = _cache[contactPubKeyHex]; if (history != null) { diff --git a/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart index 87ae729..a34464d 100644 --- a/test/services/path_history_service_test.dart +++ b/test/services/path_history_service_test.dart @@ -820,4 +820,69 @@ void main() { expect(paths.first.routeWeight, closeTo(1.5, 0.001)); }); }); + + group('history capacity', () { + test('full history evicts the weakest path to admit a new one', () async { + final pubKey = _hex('cap1'); + + // Seed a full history (100 paths): one clearly-weakest, 99 strong. + final seeded = [ + PathRecord( + hopCount: 1, + tripTimeMs: 0, + timestamp: null, + wasFloodDiscovery: false, + pathBytes: const [0xC8], + successCount: 0, + failureCount: 5, + routeWeight: 0.1, + ), + ]; + for (var i = 1; i <= 99; i++) { + seeded.add( + PathRecord( + hopCount: 1, + tripTimeMs: 100, + timestamp: DateTime.now(), + wasFloodDiscovery: false, + pathBytes: [i], + successCount: 5, + failureCount: 0, + routeWeight: 3.0, + ), + ); + } + storage._store[pubKey] = ContactPathHistory( + contactPubKeyHex: pubKey, + recentPaths: seeded, + ); + svc.getRecentPaths(pubKey); // trigger async load into cache + await _flush(); + expect(svc.getRecentPaths(pubKey).length, equals(100)); + + // A new distinct path arrives while the history is full. + svc.handlePathUpdated( + _makeContact(publicKeyHex: pubKey, pathLength: 1, path: [0xFF]), + ); + await _flush(); + + final result = svc.getRecentPaths(pubKey); + expect(result.length, equals(100), reason: 'cap stays at 100'); + expect( + result.any((p) => p.pathBytes.length == 1 && p.pathBytes[0] == 0xFF), + isTrue, + reason: 'the new path must be admitted', + ); + expect( + result.any((p) => p.pathBytes.length == 1 && p.pathBytes[0] == 0xC8), + isFalse, + reason: 'the weakest path must be evicted to make room', + ); + expect( + result.where((p) => p.routeWeight == 3.0).length, + equals(99), + reason: 'the 99 strong paths are retained', + ); + }); + }); }