diff --git a/CHANGELOG.md b/CHANGELOG.md index 037f9ef..59296db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to this project are documented here. This project follows Se Unreleased ---------- +### Added + +- `cancelActiveTraces()` cancels currently active traces without invalidating cached rDNS, + public IP, or ASN resolution. +- `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. + ### Tooling - Pull requests are now gated on the DocC documentation build: broken doc links and diff --git a/Sources/SwiftFTR/ConfigurationValidation.swift b/Sources/SwiftFTR/ConfigurationValidation.swift index 1be0dc0..d34bb7a 100644 --- a/Sources/SwiftFTR/ConfigurationValidation.swift +++ b/Sources/SwiftFTR/ConfigurationValidation.swift @@ -110,6 +110,17 @@ extension StreamingTraceConfig { } } +extension TraceOptions { + /// Validates per-operation options before socket operations begin. + func validateForOperation() throws { + if let maxHops { + guard (1...255).contains(maxHops) else { + throw TracerouteError.invalidConfiguration(reason: "maxHops must be in 1...255") + } + } + } +} + extension PingConfig { /// Validates values before constructing packets, sequence numbers, sleeps, or timers. func validateForOperation() throws { diff --git a/Sources/SwiftFTR/TraceHandle.swift b/Sources/SwiftFTR/TraceHandle.swift index 84d8026..7c07adf 100644 --- a/Sources/SwiftFTR/TraceHandle.swift +++ b/Sources/SwiftFTR/TraceHandle.swift @@ -9,6 +9,7 @@ import Foundation public actor TraceHandle { private var _isCancelled = false private var cancellationHandler: (@Sendable () -> Void)? + private var handlerRegistrationID: UInt64 = 0 /// Whether this trace has been cancelled. public var isCancelled: Bool { @@ -31,16 +32,29 @@ public actor TraceHandle { /// /// If cancellation already happened, the handler is invoked immediately so /// setup cannot race ahead with an already-cancelled trace. - internal func installCancellationHandler(_ handler: @escaping @Sendable () -> Void) { + /// + /// - Returns: A monotonically increasing registration ID identifying this handler. + @discardableResult + internal func installCancellationHandler(_ handler: @escaping @Sendable () -> Void) -> UInt64 { + handlerRegistrationID &+= 1 + let id = handlerRegistrationID if _isCancelled { handler() } else { cancellationHandler = handler } + return id } /// Removes an operation-specific cleanup handler after the operation ends. - internal func clearCancellationHandler() { + /// + /// If `id` is provided, the handler is only cleared if it matches the specified + /// registration ID. This prevents an asynchronous or delayed cleanup from clearing + /// a subsequent phase's newly installed cancellation handler. + internal func clearCancellationHandler(id: UInt64? = nil) { + if let id { + guard id == handlerRegistrationID else { return } + } cancellationHandler = nil } @@ -62,3 +76,110 @@ extension TraceHandle: Hashable { ObjectIdentifier(self).hash(into: &hasher) } } + +/// A thread-safe box that synchronizes task completion with cancellation. +private final class CancellationContinuationBox: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var isCancelled = false + private var hasResumed = false + + func cancel() { + lock.lock() + guard !hasResumed else { + lock.unlock() + return + } + isCancelled = true + if let cont = continuation { + continuation = nil + hasResumed = true + lock.unlock() + cont.resume(throwing: TracerouteError.cancelled) + } else { + lock.unlock() + } + } + + func attach(continuation: CheckedContinuation, task: Task) { + lock.lock() + if isCancelled { + hasResumed = true + lock.unlock() + continuation.resume(throwing: TracerouteError.cancelled) + return + } + self.continuation = continuation + lock.unlock() + + Task { + do { + let value = try await task.value + self.resume(with: .success(value)) + } catch { + self.resume(with: .failure(error)) + } + } + } + + private func resume(with result: Result) { + lock.lock() + guard !hasResumed else { + lock.unlock() + return + } + hasResumed = true + let cont = continuation + continuation = nil + lock.unlock() + cont?.resume(with: result) + } +} + +/// Executes an async throwing operation wrapped in an interruptible cancellation race with a `TraceHandle`. +/// +/// If `handle` is cancelled (e.g. via `cancelActiveTraces()`) or the calling task is cancelled while the +/// operation is in flight, the operation task is cancelled and this function immediately throws +/// `TracerouteError.cancelled` without stranding the caller. +internal func withTraceCancellation( + handle: TraceHandle?, + _ operation: @escaping @Sendable () async throws -> T +) async throws -> T { + guard let handle else { + return try await operation() + } + if await handle.isCancelled { + throw TracerouteError.cancelled + } + + let task = Task { + try await operation() + } + + let box = CancellationContinuationBox() + let registrationID = await handle.installCancellationHandler { + task.cancel() + box.cancel() + } + + do { + let result = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + box.attach(continuation: continuation, task: task) + } + } onCancel: { + task.cancel() + box.cancel() + } + await handle.clearCancellationHandler(id: registrationID) + return result + } catch { + await handle.clearCancellationHandler(id: registrationID) + task.cancel() + box.cancel() + if await handle.isCancelled || error is CancellationError { + throw TracerouteError.cancelled + } + throw error + } +} diff --git a/Sources/SwiftFTR/Traceroute.swift b/Sources/SwiftFTR/Traceroute.swift index 3997b4a..15c0085 100644 --- a/Sources/SwiftFTR/Traceroute.swift +++ b/Sources/SwiftFTR/Traceroute.swift @@ -121,6 +121,19 @@ public struct TraceResult: Sendable { } } +/// Per-operation options for traceroute executions. +public struct TraceOptions: Sendable, Equatable { + /// Maximum TTL/hops to probe for this trace. + /// + /// When `nil`, defaults to the configuration's `maxHops`. + /// If specified, must be in the range `1...255`. + public var maxHops: Int? + + public init(maxHops: Int? = nil) { + self.maxHops = maxHops + } +} + /// Errors that can occur while performing a traceroute. public enum TracerouteError: Error, CustomStringConvertible { /// DNS resolution failed for the destination host. @@ -463,6 +476,21 @@ public actor SwiftFTR { public func trace( to host: String ) async throws -> TraceResult { + try await trace(to: host, options: .init()) + } + + /// Perform a fast traceroute with per-operation options. + /// + /// - Parameters: + /// - host: Destination hostname or IPv4/IPv6 address. + /// - options: Per-operation trace options such as `maxHops` override. + /// - Returns: A `TraceResult` with ordered hops and whether the destination responded. + /// - Throws: `TracerouteError` if resolution, socket operations fail, or trace is cancelled + public func trace( + to host: String, + options: TraceOptions + ) async throws -> TraceResult { + try options.validateForOperation() let handle = TraceHandle() // Register active trace @@ -471,7 +499,7 @@ public actor SwiftFTR { // Run trace in a task so we can check cancellation return try await withTaskCancellationHandler { - try await performTrace(to: host, handle: handle) + try await performTrace(to: host, handle: handle, maxHopsOverride: options.maxHops) } onCancel: { Task { await handle.cancel() } } @@ -660,7 +688,7 @@ public actor SwiftFTR { yield: yield ) - await handle.installCancellationHandler { streamOperation.cancel() } + let registrationID = await handle.installCancellationHandler { streamOperation.cancel() } do { try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { @@ -670,9 +698,9 @@ public actor SwiftFTR { } onCancel: { streamOperation.cancel() } - await handle.clearCancellationHandler() + await handle.clearCancellationHandler(id: registrationID) } catch { - await handle.clearCancellationHandler() + await handle.clearCancellationHandler(id: registrationID) throw error } @@ -708,11 +736,12 @@ public actor SwiftFTR { internal func performTrace( to host: String, handle: TraceHandle, - flowIdentifier: UInt16? = nil + flowIdentifier: UInt16? = nil, + maxHopsOverride: Int? = nil ) async throws -> TraceResult { try config.validateForOperation() - let maxHops = config.maxHops + let maxHops = maxHopsOverride ?? config.maxHops let timeout = TimeInterval(config.maxWaitMs) / 1000.0 let payloadSize = config.payloadSize @@ -801,7 +830,7 @@ public actor SwiftFTR { enableLogging: config.enableLogging ) - await handle.installCancellationHandler { operation.cancel() } + let registrationID = await handle.installCancellationHandler { operation.cancel() } let receiveResult: TraceReceiveResult do { receiveResult = try await withTaskCancellationHandler { @@ -811,9 +840,9 @@ public actor SwiftFTR { } onCancel: { operation.cancel() } - await handle.clearCancellationHandler() + await handle.clearCancellationHandler(id: registrationID) } catch { - await handle.clearCancellationHandler() + await handle.clearCancellationHandler(id: registrationID) throw error } @@ -899,115 +928,155 @@ public actor SwiftFTR { to host: String, vpnContext: VPNContext? = nil, resolver: ASNResolver? = nil + ) async throws -> ClassifiedTrace { + try await traceClassified( + to: host, vpnContext: vpnContext, resolver: resolver, options: .init()) + } + + /// Perform a classified traceroute with per-operation options. + /// + /// - Parameters: + /// - host: Destination hostname or IPv4/IPv6 address. + /// - vpnContext: Context for VPN-aware classification (optional, auto-detected from interface). + /// - resolver: ASN resolver implementation (default: uses internal cached resolver). + /// - options: Per-operation trace options such as `maxHops` override. + /// - Returns: A ClassifiedTrace containing segment labels and (when available) ASN info. + /// - Throws: `TracerouteError` if resolution or socket operations fail + public func traceClassified( + to host: String, + vpnContext: VPNContext? = nil, + resolver: ASNResolver? = nil, + options: TraceOptions ) async throws -> ClassifiedTrace { try config.validateForOperation() + try options.validateForOperation() - // Validate interface early if specified (before any network operations) - if let interfaceName = config.interface { - if config.enableLogging { - print("[SwiftFTR] Validating interface '\(interfaceName)' for classified trace...") - } - _ = try validateInterface(interfaceName) - if config.enableLogging { - print("[SwiftFTR] Interface '\(interfaceName)' validated successfully") + let handle = TraceHandle() + activeTraces.insert(handle) + defer { activeTraces.remove(handle) } + + return try await withTaskCancellationHandler { + // Validate interface early if specified (before any network operations) + if let interfaceName = config.interface { + if config.enableLogging { + print("[SwiftFTR] Validating interface '\(interfaceName)' for classified trace...") + } + _ = try validateInterface(interfaceName) + if config.enableLogging { + print("[SwiftFTR] Interface '\(interfaceName)' validated successfully") + } } - } - // Each enrichment step submits blocking work to a shared, bounded executor. Checking - // cancellation between steps keeps a cancelled trace from queueing work whose result nobody - // will read, and returns the caller promptly rather than at the end of the pipeline. - try Task.checkCancellation() + try Task.checkCancellation() + if await handle.isCancelled { throw TracerouteError.cancelled } - let effectivePublicIP = await effectivePublicIPForClassification { - try? await self.discoverPublicIP() - } + let effectivePublicIP = await effectivePublicIPForClassification { + if await handle.isCancelled { return nil } + return try? await self.discoverPublicIP(handle: handle) + } - try Task.checkCancellation() + try Task.checkCancellation() + if await handle.isCancelled { throw TracerouteError.cancelled } - // Perform base trace (includes rDNS if enabled) - let tr = try await trace(to: host) + // Perform base trace using the classified trace's handle + let tr = try await performTrace(to: host, handle: handle, maxHopsOverride: options.maxHops) - guard let destIP = tr.resolvedIP else { - throw TracerouteError.resolutionFailed( - host: host, details: "Trace completed without a resolved destination address") - } + guard let destIP = tr.resolvedIP else { + throw TracerouteError.resolutionFailed( + host: host, details: "Trace completed without a resolved destination address") + } - // Collect IPs for batch operations - var allIPs = Set(tr.hops.compactMap { $0.ipAddress }) - allIPs.insert(destIP) - if let pip = effectivePublicIP { allIPs.insert(pip) } + // Collect IPs for batch operations + var allIPs = Set(tr.hops.compactMap { $0.ipAddress }) + allIPs.insert(destIP) + if let pip = effectivePublicIP { allIPs.insert(pip) } - try Task.checkCancellation() + try Task.checkCancellation() + if await handle.isCancelled { throw TracerouteError.cancelled } + + // Get hostnames (either from trace or via rDNS) + var hostnameMap: [String: String] = [:] + if !config.noReverseDNS { + // Get any missing hostnames (destination and public IP) + let ipsNeedingRDNS = allIPs.filter { ip in + !tr.hops.contains { $0.ipAddress == ip && $0.hostname != nil } + } + if !ipsNeedingRDNS.isEmpty { + if await handle.isCancelled { throw TracerouteError.cancelled } + let additionalHostnames = try await withTraceCancellation(handle: handle) { + await self.rdnsCache.batchLookup(Array(ipsNeedingRDNS)) + } + if await handle.isCancelled { throw TracerouteError.cancelled } + hostnameMap = additionalHostnames + } - // Get hostnames (either from trace or via rDNS) - var hostnameMap: [String: String] = [:] - if !config.noReverseDNS { - // Get any missing hostnames (destination and public IP) - let ipsNeedingRDNS = allIPs.filter { ip in - !tr.hops.contains { $0.ipAddress == ip && $0.hostname != nil } - } - if !ipsNeedingRDNS.isEmpty { - let additionalHostnames = await rdnsCache.batchLookup(Array(ipsNeedingRDNS)) - hostnameMap = additionalHostnames + // Add hostnames from trace + for hop in tr.hops { + if let ip = hop.ipAddress, let hostname = hop.hostname { + hostnameMap[ip] = hostname + } + } } - // Add hostnames from trace - for hop in tr.hops { - if let ip = hop.ipAddress, let hostname = hop.hostname { - hostnameMap[ip] = hostname - } + try Task.checkCancellation() + if await handle.isCancelled { throw TracerouteError.cancelled } + + // Use provided resolver or internal one + let effectiveResolver = resolver ?? asnResolver + + // Determine VPN context - use provided or auto-detect from interface + let effectiveVPNContext = vpnContext ?? VPNContext.forInterface(config.interface) + + // Classify with enhanced data + let classifier = TraceClassifier() + let baseClassified = try await withTraceCancellation(handle: handle) { + try await classifier.classify( + trace: tr, + destinationIP: destIP, + resolver: effectiveResolver, + timeout: 1.5, + publicIP: effectivePublicIP, + interface: self.config.interface, + sourceIP: self.config.sourceIP, + vpnContext: effectiveVPNContext, + enableLogging: self.config.enableLogging, + publicIPDiscoveryTimeout: self.config.publicIPDiscoveryTimeoutForOperation + ) } - } - // Use provided resolver or internal one - let effectiveResolver = resolver ?? asnResolver - - // Determine VPN context - use provided or auto-detect from interface - let effectiveVPNContext = vpnContext ?? VPNContext.forInterface(config.interface) - - // Classify with enhanced data - let classifier = TraceClassifier() - let baseClassified = try await classifier.classify( - trace: tr, - destinationIP: destIP, - resolver: effectiveResolver, - timeout: 1.5, - publicIP: effectivePublicIP, - interface: config.interface, - sourceIP: config.sourceIP, - vpnContext: effectiveVPNContext, - enableLogging: config.enableLogging, - publicIPDiscoveryTimeout: config.publicIPDiscoveryTimeoutForOperation - ) + if await handle.isCancelled { throw TracerouteError.cancelled } - // Enhance classified result with hostnames - let enhancedHops = baseClassified.hops.map { hop in - ClassifiedHop( - ttl: hop.ttl, - ip: hop.ip, - rtt: hop.rtt, - asn: hop.asn, - asName: hop.asName, - category: hop.category, - hostname: hop.ip.flatMap { - hostnameMap[$0] ?? tr.hops.first { $0.ipAddress == hop.ip }?.hostname - }, - outcome: hop.outcome + // Enhance classified result with hostnames + let enhancedHops = baseClassified.hops.map { hop in + ClassifiedHop( + ttl: hop.ttl, + ip: hop.ip, + rtt: hop.rtt, + asn: hop.asn, + asName: hop.asName, + category: hop.category, + hostname: hop.ip.flatMap { + hostnameMap[$0] ?? tr.hops.first { $0.ipAddress == hop.ip }?.hostname + }, + outcome: hop.outcome + ) + } + + return ClassifiedTrace( + destinationHost: baseClassified.destinationHost, + destinationIP: baseClassified.destinationIP, + destinationHostname: hostnameMap[destIP], + publicIP: baseClassified.publicIP, + publicHostname: effectivePublicIP.flatMap { hostnameMap[$0] }, + clientASN: baseClassified.clientASN, + clientASName: baseClassified.clientASName, + destinationASN: baseClassified.destinationASN, + destinationASName: baseClassified.destinationASName, + hops: enhancedHops ) + } onCancel: { + Task { await handle.cancel() } } - - return ClassifiedTrace( - destinationHost: baseClassified.destinationHost, - destinationIP: baseClassified.destinationIP, - destinationHostname: hostnameMap[destIP], - publicIP: baseClassified.publicIP, - publicHostname: effectivePublicIP.flatMap { hostnameMap[$0] }, - clientASN: baseClassified.clientASN, - clientASName: baseClassified.clientASName, - destinationASN: baseClassified.destinationASN, - destinationASName: baseClassified.destinationASName, - hops: enhancedHops - ) } /// Ping a target host with specified configuration. @@ -1096,21 +1165,21 @@ public actor SwiftFTR { } /// Discover public IP via STUN (with DNS fallback) - internal func discoverPublicIP() async throws -> String { + internal func discoverPublicIP(handle: TraceHandle? = nil) async throws -> String { let interface = config.interface let sourceIP = config.sourceIP let enableLogging = config.enableLogging - // `stunTimeout` and `dnsTimeout` bound the socket waits, not the `getaddrinfo` that resolves - // each STUN hostname first. That call holds its worker for 30 seconds against an unresponsive - // resolver, and the server list is walked serially, so discovery needs a bound of its own. - return try await runDetachedBlockingIO(deadline: config.publicIPDiscoveryTimeoutForOperation) { - try getPublicIPv4( - stunTimeout: 2.0, - dnsTimeout: 3.0, - interface: interface, - sourceIP: sourceIP, - enableLogging: enableLogging - ).ip + + return try await withTraceCancellation(handle: handle) { + try await runDetachedBlockingIO(deadline: self.config.publicIPDiscoveryTimeoutForOperation) { + try getPublicIPv4( + stunTimeout: 2.0, + dnsTimeout: 3.0, + interface: interface, + sourceIP: sourceIP, + enableLogging: enableLogging + ).ip + } } } @@ -1201,6 +1270,19 @@ public actor SwiftFTR { // MARK: - Cache Management + /// Cancel all active traceroute operations without clearing caches. + /// + /// Snapshots the currently running trace handles and cancels them. + /// Traces registered after this call begins remain tracked for subsequent cancellation. + /// This method does not invalidate rDNS, public IP, or ASN caches. + public func cancelActiveTraces() async { + let tracesToCancel = activeTraces + activeTraces.removeAll() + for trace in tracesToCancel { + await trace.cancel() + } + } + /// Handle network changes by cancelling active traces and clearing caches. /// /// Call this method when the network configuration changes (e.g., WiFi to cellular, @@ -1211,14 +1293,9 @@ public actor SwiftFTR { // subsequent network change instead of being silently removed. cacheGeneration &+= 1 cachedPublicIP = nil - let tracesToCancel = activeTraces - activeTraces.removeAll() + await cancelActiveTraces() await rdnsCache.clear() - for trace in tracesToCancel { - await trace.cancel() - } - // Note: ASN cache could optionally be cleared too } diff --git a/Tests/SwiftFTRTests/StreamingLifecycleTests.swift b/Tests/SwiftFTRTests/StreamingLifecycleTests.swift index c71725b..fd395a7 100644 --- a/Tests/SwiftFTRTests/StreamingLifecycleTests.swift +++ b/Tests/SwiftFTRTests/StreamingLifecycleTests.swift @@ -32,6 +32,34 @@ struct StreamingLifecycleTests { #expect(counter.value == 1) } + @Test("Clearing an older registration ID does not clear the next phase's handler") + func handlerRegistrationIsolation() async { + let handle = TraceHandle() + let counterA = LockedCounter() + let counterB = LockedCounter() + + // Phase 1 installs handler A + let idA = await handle.installCancellationHandler { + counterA.increment() + } + #expect(await handle.hasCancellationHandler) + + // Phase 2 installs handler B + _ = await handle.installCancellationHandler { + counterB.increment() + } + + // Phase 1 cleanup attempts to clear its handler using idA + await handle.clearCancellationHandler(id: idA) + + // Handler B must NOT have been cleared + #expect(await handle.hasCancellationHandler) + + await handle.cancel() + #expect(counterA.value == 0) + #expect(counterB.value == 1) + } + @Test("networkChanged stops an active streaming receive operation", .timeLimit(.minutes(1))) func networkChangeStopsStreamingOperation() async throws { let tracer = SwiftFTR( diff --git a/Tests/SwiftFTRTests/TraceLifecycleTests.swift b/Tests/SwiftFTRTests/TraceLifecycleTests.swift new file mode 100644 index 0000000..284188e --- /dev/null +++ b/Tests/SwiftFTRTests/TraceLifecycleTests.swift @@ -0,0 +1,213 @@ +import Foundation +import Testing + +@testable import SwiftFTR + +@Suite("Trace Lifecycle and Options Tests") +struct TraceLifecycleTests { + @Test("cancelActiveTraces cancels active trace while preserving caches", .timeLimit(.minutes(1))) + func cancelActiveTracesPreservesCaches() async throws { + let tracer = SwiftFTR( + config: SwiftFTRConfig(maxHops: 1, maxWaitMs: 1_000, noReverseDNS: false) + ) + + // Populate rDNS cache with an entry + _ = await tracer.rdnsCache.lookup("1.1.1.1") + + // Set a cached public IP directly or via effective discovery + let _ = await tracer.effectivePublicIPForClassification { "198.51.100.1" } + #expect(await tracer.publicIP == "198.51.100.1") + + let streamConfig = StreamingTraceConfig( + probeTimeout: 30, + retryAfter: nil, + emitTimeouts: false, + maxHops: 1 + ) + + let consumer = Task { + var iterator = tracer.traceStream( + to: "192.0.2.1", config: streamConfig + ).makeAsyncIterator() + return try await iterator.next() + } + + let receiving = await waitUntil { + guard let handle = await tracer.activeTraces.first else { return false } + return await handle.hasCancellationHandler + } + #expect(receiving, "The streaming trace should start before cancellation") + + let started = ContinuousClock.now + await tracer.cancelActiveTraces() + + do { + _ = try await consumer.value + } catch is CancellationError { + } catch TracerouteError.cancelled { + } + + let unregistered = await waitUntil { await tracer.activeTraces.isEmpty } + #expect(unregistered) + #expect(started.duration(to: .now) < .seconds(1)) + + // Verify caches are still intact! + #expect(await tracer.publicIP == "198.51.100.1") + + // Now call networkChanged() and verify it DOES clear the public IP and rDNS + await tracer.networkChanged() + #expect(await tracer.publicIP == nil) + #expect(await tracer.rdnsCache.count == 0) + } + + @Test("TraceOptions maxHops bounds trace hops") + func traceOptionsBoundsMaxHops() async throws { + let tracer = SwiftFTR( + config: SwiftFTRConfig(maxHops: 30, maxWaitMs: 100, noReverseDNS: true) + ) + + // Trace to loopback with maxHops: 2 + let result = try await tracer.trace(to: "127.0.0.1", options: TraceOptions(maxHops: 2)) + #expect(result.maxHops == 2) + #expect(result.hops.count <= 2) + } + + @Test("TraceOptions rejects out-of-range maxHops before network work") + func traceOptionsRejectsOutOfRange() async { + let tracer = SwiftFTR(config: SwiftFTRConfig(noReverseDNS: true)) + + let invalidOptions: [(name: String, options: TraceOptions)] = [ + ("zero hops", TraceOptions(maxHops: 0)), + ("negative hops", TraceOptions(maxHops: -1)), + ("too many hops", TraceOptions(maxHops: 256)), + ("extreme hops", TraceOptions(maxHops: 1_000)), + ] + + for (name, options) in invalidOptions { + do { + _ = try await tracer.trace(to: "127.0.0.1", options: options) + Issue.record("\(name) should have thrown invalidConfiguration") + } catch TracerouteError.invalidConfiguration(let reason) { + #expect(reason.contains("maxHops must be in 1...255")) + } catch { + Issue.record("\(name) threw unexpected error: \(error)") + } + + do { + _ = try await tracer.traceClassified(to: "127.0.0.1", options: options) + Issue.record("\(name) should have thrown invalidConfiguration for classified trace") + } catch TracerouteError.invalidConfiguration(let reason) { + #expect(reason.contains("maxHops must be in 1...255")) + } catch { + Issue.record("\(name) threw unexpected error: \(error)") + } + } + } + + @Test("TraceOptions defaults to nil maxHops") + func traceOptionsDefaultInit() { + let options = TraceOptions() + #expect(options.maxHops == nil) + } + + @Test("cancelActiveTraces cancels in-flight classified trace") + func cancelActiveTracesCancelsClassifiedTrace() async throws { + let tracer = SwiftFTR( + config: SwiftFTRConfig( + maxHops: 30, + maxWaitMs: 1000, + enableLogging: false, + noReverseDNS: false + ) + ) + + let task = Task { + try await tracer.traceClassified(to: "192.0.2.1") + } + + let started = await waitUntil { + await !tracer.activeTraces.isEmpty + } + #expect(started) + + await tracer.cancelActiveTraces() + + do { + _ = try await task.value + Issue.record("traceClassified should have thrown cancelled") + } catch TracerouteError.cancelled { + // Expected + } catch { + Issue.record("Unexpected error from cancelled traceClassified: \(error)") + } + + let unregistered = await waitUntil { await tracer.activeTraces.isEmpty } + #expect(unregistered) + } + + final class HangingASNResolver: ASNResolver, @unchecked Sendable { + private let started = DispatchSemaphore(value: 0) + + func waitUntilStarted() { + started.wait() + } + + func resolve(ipv4Addrs: [String], timeout: TimeInterval) async throws -> [String: ASNInfo] { + started.signal() + // Suspends indefinitely, simulating an unresponsive resolver that ignores its timeout + while true { + try await Task.sleep(nanoseconds: 1_000_000_000) + } + } + } + + @Test("cancelActiveTraces interrupts ASN enrichment when resolver is suspended") + func cancelActiveTracesInterruptsASNEnrichment() async throws { + let resolver = HangingASNResolver() + let tracer = SwiftFTR( + config: SwiftFTRConfig( + maxHops: 1, + maxWaitMs: 100, + enableLogging: false, + noReverseDNS: true + ) + ) + + let task = Task { + try await tracer.traceClassified(to: "127.0.0.1", resolver: resolver) + } + + // Wait until the resolver is actively invoked during the enrichment phase + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + resolver.waitUntilStarted() + continuation.resume() + } + } + + // Cancel while classifier.classify() is actively awaiting the suspended resolver + await tracer.cancelActiveTraces() + + do { + _ = try await task.value + Issue.record("traceClassified should have thrown cancelled during ASN enrichment") + } catch TracerouteError.cancelled { + // Expected: immediately interrupted rather than waiting on hanging resolver + } catch { + Issue.record("Unexpected error from cancelled traceClassified: \(error)") + } + + let unregistered = await waitUntil { await tracer.activeTraces.isEmpty } + #expect(unregistered) + } + + private func waitUntil( + _ condition: @escaping @Sendable () async -> Bool + ) async -> Bool { + for _ in 0..<10_000 { + if await condition() { return true } + await Task.yield() + } + return false + } +}