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', + ); + }); + }); }