From d5b91e767df72f382b1893eb1d3dc18f6737e726 Mon Sep 17 00:00:00 2001 From: "David E. Weekly" Date: Wed, 2 Sep 2026 09:23:36 -0700 Subject: [PATCH 1/2] feat(rdns): network-scoped rDNS eviction with generation-guarded inflight lookups --- CHANGELOG.md | 3 + Sources/SwiftFTR/RDNSCache.swift | 30 +++- Sources/SwiftFTR/Traceroute.swift | 10 ++ .../SwiftFTRTests/CacheGenerationTests.swift | 128 ++++++++++++++++++ 4 files changed, 164 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59296db..73c55c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Unreleased - `cancelActiveTraces()` cancels currently active traces without invalidating cached rDNS, public IP, or ASN resolution. +- `invalidateNetworkScopedRDNS()` evicts network-scoped (private, CGNAT, link-local, loopback, + ULA) rDNS entries and resets the stall breaker while preserving globally routable internet + hostnames and allowing in-flight global lookups to finish normally. - `TraceOptions` and per-operation options on `trace(to:options:)` and `traceClassified(to:vpnContext:resolver:options:)` permit overriding `maxHops` on individual traces without reconfiguring the actor. diff --git a/Sources/SwiftFTR/RDNSCache.swift b/Sources/SwiftFTR/RDNSCache.swift index 352154f..1a00463 100644 --- a/Sources/SwiftFTR/RDNSCache.swift +++ b/Sources/SwiftFTR/RDNSCache.swift @@ -39,7 +39,8 @@ actor RDNSCache { private let clock = ContinuousClock() private let resolver: Resolver private let lookupDeadline: TimeInterval - private var generation: UInt64 = 0 + private var scopedGeneration: UInt64 = 0 + private var globalGeneration: UInt64 = 0 private var consecutiveStalls = 0 /// Initialize a new rDNS cache. @@ -86,7 +87,8 @@ actor RDNSCache { // Lookups keep stalling, so skip the wait and report numerically until `clear()` reopens. guard !isSuppressingLookups else { return nil } - let lookupGeneration = generation + let isScoped = ipAddressScope(of: ip) != .global + let lookupGeneration = isScoped ? scopedGeneration : globalGeneration let startedAt = clock.now let hostname = await resolver(ip) let elapsed = startedAt.duration(to: clock.now) @@ -99,10 +101,11 @@ actor RDNSCache { consecutiveStalls = 0 } - // `clear()` may run while the resolver is suspended. A result from the old - // network generation must neither escape to the caller nor repopulate the - // newly invalidated cache. - guard lookupGeneration == generation else { return nil } + // A result from an invalidated network generation must neither escape to the caller nor + // repopulate the newly invalidated cache. Network-scoped invalidation rejects in-flight + // scoped lookups while allowing global lookups to finish; clear() rejects both. + let currentGeneration = isScoped ? scopedGeneration : globalGeneration + guard lookupGeneration == currentGeneration else { return nil } // Cache the result cache[ip] = CacheEntry(hostname: hostname, timestamp: clock.now) @@ -138,11 +141,24 @@ actor RDNSCache { /// Clear all cached entries. func clear() { - generation &+= 1 + scopedGeneration &+= 1 + globalGeneration &+= 1 cache.removeAll() consecutiveStalls = 0 } + /// Invalidate network-scoped (non-global) rDNS cache entries and reset the stall breaker. + /// + /// Evicts cached rDNS entries (positive and negative) whose address is not globally routable + /// (RFC 1918 private, CGNAT, link-local, loopback, ULA). Preserves globally routable entries + /// and their TTLs. In-flight lookups for scoped addresses are discarded, while in-flight + /// global lookups continue to complete and cache normally. + func invalidateNetworkScoped() { + scopedGeneration &+= 1 + cache = cache.filter { ip, _ in ipAddressScope(of: ip) == .global } + consecutiveStalls = 0 + } + /// Get the current number of cached entries. var count: Int { cache.count diff --git a/Sources/SwiftFTR/Traceroute.swift b/Sources/SwiftFTR/Traceroute.swift index 15c0085..bee3b1e 100644 --- a/Sources/SwiftFTR/Traceroute.swift +++ b/Sources/SwiftFTR/Traceroute.swift @@ -1322,6 +1322,16 @@ public actor SwiftFTR { cacheGeneration &+= 1 cachedPublicIP = nil } + + /// Invalidate network-scoped reverse DNS cache entries. + /// + /// Evicts cached reverse DNS entries whose address is not globally routable + /// (e.g. RFC 1918 private addresses, CGNAT, link-local, loopback, IPv6 ULA). + /// Globally routable Internet hostnames and their TTLs are preserved. + /// Also resets the reverse-DNS stall breaker so subsequent lookups are attempted. + public func invalidateNetworkScopedRDNS() async { + await rdnsCache.invalidateNetworkScoped() + } } // MARK: - Dual-stack trace helpers (Stage 2 IPv6) diff --git a/Tests/SwiftFTRTests/CacheGenerationTests.swift b/Tests/SwiftFTRTests/CacheGenerationTests.swift index 133ee93..af234c7 100644 --- a/Tests/SwiftFTRTests/CacheGenerationTests.swift +++ b/Tests/SwiftFTRTests/CacheGenerationTests.swift @@ -58,6 +58,134 @@ struct CacheGenerationTests { #expect(current == "198.51.100.8") #expect(await tracer.publicIP == "198.51.100.8") } + + @Test("invalidateNetworkScoped evicts only non-global entries and preserves global ones") + func scopedEvictionPreservesGlobal() async { + let resolver = CountingResolver(result: "resolved.example") + let cache = RDNSCache(resolver: { ip in await resolver.resolve(ip) }) + + let scopedIPs = [ + "192.168.1.1", + "10.0.0.1", + "172.16.0.1", + "100.64.0.1", + "169.254.1.1", + "127.0.0.1", + "::1", + "fe80::1", + "fc00::1", + ] + + let globalIPs = [ + "1.1.1.1", + "8.8.8.8", + "2606:4700:4700::1111", + ] + + for ip in scopedIPs + globalIPs { + _ = await cache.lookup(ip) + } + #expect(await cache.count == scopedIPs.count + globalIPs.count) + + let callsBefore = await resolver.callCount + + // Perform network-scoped eviction + await cache.invalidateNetworkScoped() + + // Global IPs should remain cached (no additional resolver call) + for ip in globalIPs { + #expect(await cache.lookup(ip) == "resolved.example") + } + #expect(await resolver.callCount == callsBefore) + + // Scoped IPs were evicted, so looking them up hits the resolver again + for ip in scopedIPs { + _ = await cache.lookup(ip) + } + #expect(await resolver.callCount == callsBefore + scopedIPs.count) + } + + @Test("invalidateNetworkScoped resets the stall breaker") + func scopedEvictionResetsStallBreaker() async { + let cache = RDNSCache( + lookupDeadline: 0.05, + resolver: { _ in + try? await Task.sleep(for: .milliseconds(60)) + return nil + } + ) + + _ = await cache.lookup("192.0.2.1") + _ = await cache.lookup("192.0.2.2") + #expect(await cache.isSuppressingLookups) + + await cache.invalidateNetworkScoped() + #expect(await cache.isSuppressingLookups == false) + } + + @Test("In-flight scoped lookup is rejected while concurrent global lookup succeeds") + func inFlightScopedVsGlobal() async { + let lookup = MultiSuspendedLookup() + let cache = RDNSCache(resolver: { ip in await lookup.resolve(ip) }) + + let scopedTask = Task { await cache.lookup("192.168.1.1") } + let globalTask = Task { await cache.lookup("1.1.1.1") } + + await lookup.waitUntilStarted("192.168.1.1") + await lookup.waitUntilStarted("1.1.1.1") + + // Invalidate network-scoped rDNS while both are in flight + await cache.invalidateNetworkScoped() + + await lookup.resume("192.168.1.1", returning: "router.local") + await lookup.resume("1.1.1.1", returning: "one.one.one.one") + + let scopedResult = await scopedTask.value + let globalResult = await globalTask.value + + #expect(scopedResult == nil) + #expect(globalResult == "one.one.one.one") + + // Global result is cached; scoped result is NOT cached + #expect(await cache.count == 1) + } + + @Test("SwiftFTR.invalidateNetworkScopedRDNS clears scoped entries via actor") + func tracerScopedEviction() async { + let tracer = SwiftFTR(config: SwiftFTRConfig(noReverseDNS: false)) + _ = await tracer.rdnsCache.lookup("192.168.1.1") + _ = await tracer.rdnsCache.lookup("1.1.1.1") + + await tracer.invalidateNetworkScopedRDNS() + #expect(await tracer.rdnsCache.count == 1) + } +} + +private actor MultiSuspendedLookup { + private var startedKeys: Set = [] + private var startWaiters: [String: [CheckedContinuation]] = [:] + private var continuations: [String: CheckedContinuation] = [:] + + func resolve(_ key: String) async -> String? { + startedKeys.insert(key) + if let waiters = startWaiters.removeValue(forKey: key) { + for waiter in waiters { waiter.resume() } + } + return await withCheckedContinuation { cont in + continuations[key] = cont + } + } + + func waitUntilStarted(_ key: String) async { + if startedKeys.contains(key) { return } + await withCheckedContinuation { cont in + startWaiters[key, default: []].append(cont) + } + } + + func resume(_ key: String, returning value: String?) { + continuations.removeValue(forKey: key)?.resume(returning: value) + } } private actor SuspendedLookup { From 65bbbda7ee4ba8562a7d8749e4757e75a1c5ffaa Mon Sep 17 00:00:00 2001 From: "David E. Weekly" Date: Wed, 2 Sep 2026 17:17:59 -0700 Subject: [PATCH 2/2] fix(rdns): isolate stall breaker updates with breakerGeneration --- Sources/SwiftFTR/RDNSCache.swift | 18 ++++++---- .../SwiftFTRTests/CacheGenerationTests.swift | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftFTR/RDNSCache.swift b/Sources/SwiftFTR/RDNSCache.swift index 1a00463..ba1dba4 100644 --- a/Sources/SwiftFTR/RDNSCache.swift +++ b/Sources/SwiftFTR/RDNSCache.swift @@ -41,6 +41,7 @@ actor RDNSCache { private let lookupDeadline: TimeInterval private var scopedGeneration: UInt64 = 0 private var globalGeneration: UInt64 = 0 + private var breakerGeneration: UInt64 = 0 private var consecutiveStalls = 0 /// Initialize a new rDNS cache. @@ -89,16 +90,19 @@ actor RDNSCache { let isScoped = ipAddressScope(of: ip) != .global let lookupGeneration = isScoped ? scopedGeneration : globalGeneration + let lookupBreakerGeneration = breakerGeneration let startedAt = clock.now let hostname = await resolver(ip) let elapsed = startedAt.duration(to: clock.now) - // A lookup that consumed its whole budget did not answer; it timed out or was starved. Track - // that separately from a resolver that answered "no such name", which is a normal fast result. - if elapsed >= .seconds(lookupDeadline * 0.9) { - consecutiveStalls += 1 - } else { - consecutiveStalls = 0 + // Only lookups initiated under the current breaker generation may update breaker state. + // Stale lookups from a prior network generation must not re-trip or reset the breaker on the new network. + if lookupBreakerGeneration == breakerGeneration { + if elapsed >= .seconds(lookupDeadline * 0.9) { + consecutiveStalls += 1 + } else { + consecutiveStalls = 0 + } } // A result from an invalidated network generation must neither escape to the caller nor @@ -143,6 +147,7 @@ actor RDNSCache { func clear() { scopedGeneration &+= 1 globalGeneration &+= 1 + breakerGeneration &+= 1 cache.removeAll() consecutiveStalls = 0 } @@ -155,6 +160,7 @@ actor RDNSCache { /// global lookups continue to complete and cache normally. func invalidateNetworkScoped() { scopedGeneration &+= 1 + breakerGeneration &+= 1 cache = cache.filter { ip, _ in ipAddressScope(of: ip) == .global } consecutiveStalls = 0 } diff --git a/Tests/SwiftFTRTests/CacheGenerationTests.swift b/Tests/SwiftFTRTests/CacheGenerationTests.swift index af234c7..a1fafb1 100644 --- a/Tests/SwiftFTRTests/CacheGenerationTests.swift +++ b/Tests/SwiftFTRTests/CacheGenerationTests.swift @@ -123,6 +123,40 @@ struct CacheGenerationTests { #expect(await cache.isSuppressingLookups == false) } + @Test("Stale lookups finishing after invalidateNetworkScoped do not re-trip the breaker") + func staleScopedLookupsDoNotRetripBreaker() async { + let lookup = MultiSuspendedLookup() + let cache = RDNSCache( + lookupDeadline: 0.05, + resolver: { ip in + _ = await lookup.resolve(ip) + try? await Task.sleep(for: .milliseconds(60)) + return nil + } + ) + + // Start 2 scoped lookups on the old network + let task1 = Task { await cache.lookup("192.168.1.1") } + let task2 = Task { await cache.lookup("192.168.1.2") } + + await lookup.waitUntilStarted("192.168.1.1") + await lookup.waitUntilStarted("192.168.1.2") + + // Invalidate network-scoped rDNS and reset breaker + await cache.invalidateNetworkScoped() + #expect(await cache.isSuppressingLookups == false) + + // Resume the stale lookups so they complete with stall durations + await lookup.resume("192.168.1.1", returning: nil) + await lookup.resume("192.168.1.2", returning: nil) + + _ = await task1.value + _ = await task2.value + + // The breaker must NOT be re-tripped by stale lookups + #expect(await cache.isSuppressingLookups == false) + } + @Test("In-flight scoped lookup is rejected while concurrent global lookup succeeds") func inFlightScopedVsGlobal() async { let lookup = MultiSuspendedLookup()