Files

232 lines
8.8 KiB
Swift

import Darwin
import Foundation
/// Preparation and sampling stay behind one measurement interface. Dependencies
/// at the HTTP and DNS seams admit deterministic fixtures without real egress.
@MainActor
struct ConnectivityMeasurement {
private struct Candidate {
let address: String
let milliseconds: Double
}
private struct AddressResolution {
let addresses: [String]
let trace: [ConnectivityTrace]
let failure: ServerConnectivity.Outcome?
}
private struct Selection {
let result: Result
let address: String?
}
private struct SampleSummary {
let outcome: ServerConnectivity.Outcome
let medianMilliseconds: Double?
}
struct Result {
var outcome: ServerConnectivity.Outcome = .network
var selectedIP: String?
var samples: [ConnectivityHTTPObservation] = []
var trace: [ConnectivityTrace] = []
}
var resolve: ((String, Int, ContinuousClock.Instant) async throws -> [String])?
var request: (URL, String, Int, Double) async -> ConnectivityHTTPObservation = {
await ConnectivityHTTPRequest().observe(url: $0, targetIP: $1, proxyPort: $2, timeout: $3)
}
func measure(
url: URL, proxyPort: Int, preparationDeadline: ContinuousClock.Instant
) async -> Result {
guard let host = validatedHost(in: url) else { return Result(outcome: .configuration) }
let resolution = await resolveAddresses(
host, proxyPort: proxyPort, deadline: preparationDeadline)
var result = Result(trace: resolution.trace)
if let failure = resolution.failure {
result.outcome = failure
result.trace.append(dnsFailureTrace())
return result
}
let candidates = candidateAddresses(from: resolution.addresses)
let selection = await selectCandidate(
from: candidates, url: url, proxyPort: proxyPort,
deadline: preparationDeadline, startingWith: result)
guard let address = selection.address else { return selection.result }
return await sample(url, address: address, proxyPort: proxyPort, startingWith: selection.result)
}
private func validatedHost(in url: URL) -> String? {
guard ServerConnectivity.validURL(url.absoluteString) != nil else { return nil }
return URLComponents(url: url, resolvingAgainstBaseURL: false)?.host
}
private func resolveAddresses(
_ host: String, proxyPort: Int, deadline: ContinuousClock.Instant
) async -> AddressResolution {
let literal = host.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
do {
try Task.checkCancellation()
if ConnectivityAddress.family(literal) != nil {
return AddressResolution(addresses: [literal], trace: [], failure: nil)
}
if let resolve {
return AddressResolution(
addresses: try await resolve(host, proxyPort, deadline), trace: [], failure: nil)
}
let resolution = await ConnectivityDoHResolver().resolve(
host: host, proxyPort: proxyPort, deadline: deadline)
return AddressResolution(
addresses: resolution.addresses, trace: resolution.trace, failure: nil)
} catch {
let failure: ServerConnectivity.Outcome =
ContinuousClock.now >= deadline
? .preparationTimeout : .network
return AddressResolution(addresses: [], trace: [], failure: failure)
}
}
private func dnsFailureTrace() -> ConnectivityTrace {
ConnectivityTrace(
phase: .summary, status: Task.isCancelled ? .cancelled : .network, failureStage: .dns)
}
private func candidateAddresses(from addresses: [String]) -> [String] {
var seen = Set<String>()
var counts: [Int32: Int] = [:]
return addresses.filter { address in
guard let family = ConnectivityAddress.family(address), seen.insert(address).inserted,
counts[family, default: 0] < 2
else { return false }
counts[family, default: 0] += 1
return true
}
}
private func selectCandidate(
from candidates: [String], url: URL, proxyPort: Int,
deadline: ContinuousClock.Instant, startingWith original: Result
) async -> Selection {
var result = original
var best: Candidate?
var failure: ServerConnectivity.Outcome = .network
for (index, address) in candidates.enumerated() {
if Task.isCancelled { return Selection(result: cancelled(result), address: nil) }
let remaining = Self.seconds(until: deadline)
if remaining <= 0 { return selectionTimeout(result) }
let observation = await request(url, address, proxyPort, min(3, remaining))
result.trace.append(observation.trace(phase: .candidate, index: index + 1, address: address))
failure = observation.outcome
if isBetterCandidate(observation, than: best) {
best = Candidate(address: address, milliseconds: observation.totalMilliseconds)
}
}
if Task.isCancelled { return Selection(result: cancelled(result), address: nil) }
guard ContinuousClock.now < deadline else { return selectionTimeout(result) }
guard let best else {
result.outcome = failure
result.trace.append(
ConnectivityTrace(phase: .summary, status: .network, failureStage: .selection))
return Selection(result: result, address: nil)
}
result.selectedIP = best.address
result.trace.append(
ConnectivityTrace(phase: .selected, status: .success, address: best.address))
return Selection(result: result, address: best.address)
}
private func isBetterCandidate(
_ observation: ConnectivityHTTPObservation, than best: Candidate?
) -> Bool {
guard case .success = observation.outcome else { return false }
guard let best else { return true }
return observation.totalMilliseconds < best.milliseconds
}
private func selectionTimeout(_ original: Result) -> Selection {
var result = original
result.outcome = .preparationTimeout
result.trace.append(
ConnectivityTrace(phase: .summary, status: .timeout, failureStage: .selection))
return Selection(result: result, address: nil)
}
private func sample(
_ url: URL, address: String, proxyPort: Int, startingWith original: Result
) async -> Result {
var result = original
for index in 1...3 {
if Task.isCancelled { return cancelled(result) }
let observation = await request(url, address, proxyPort, 5)
result.samples.append(observation)
result.trace.append(observation.trace(phase: .sample, index: index, address: address))
}
if Task.isCancelled { return cancelled(result) }
let summary = sampleSummary(for: result.samples)
result.outcome = summary.outcome
if let median = summary.medianMilliseconds {
result.trace.append(
ConnectivityTrace(phase: .summary, status: .success, milliseconds: median))
} else {
result.trace.append(ConnectivityTrace(phase: .summary, status: .insufficientSamples))
}
return result
}
private func sampleSummary(for samples: [ConnectivityHTTPObservation]) -> SampleSummary {
let successes = samples.compactMap { sample -> Double? in
if case .success = sample.outcome { return sample.totalMilliseconds }
return nil
}.sorted()
guard successes.count >= 2 else {
let failure =
samples.first(where: {
if case .success = $0.outcome { return false }
return true
})?.outcome ?? .network
return SampleSummary(outcome: failure, medianMilliseconds: nil)
}
let median = successes.count == 2 ? (successes[0] + successes[1]) / 2 : successes[1]
return SampleSummary(
outcome: .success(milliseconds: Int(median.rounded())), medianMilliseconds: median)
}
static func seconds(until deadline: ContinuousClock.Instant) -> Double {
let parts = ContinuousClock.now.duration(to: deadline).components
return Double(parts.seconds) + Double(parts.attoseconds) / 1e18
}
private func cancelled(_ original: Result) -> Result {
var result = original
result.outcome = .network
result.trace.append(ConnectivityTrace(phase: .summary, status: .cancelled))
return result
}
}
extension ConnectivityHTTPObservation {
func trace(phase: ConnectivityTrace.Phase, index: Int, address: String) -> ConnectivityTrace {
let status: ConnectivityTrace.Status
var http: Int?
switch outcome {
case .success: status = .success
case .requestTimeout, .preparationTimeout: status = .timeout
case .http(let code):
status = .http
http = code
case .configuration, .unsupported: status = .configuration
default: status = .network
}
return ConnectivityTrace(
phase: phase, status: status, index: index, address: address,
milliseconds: totalMilliseconds, localTCPMilliseconds: localTCPMilliseconds,
sendMilliseconds: sendMilliseconds, firstByteMilliseconds: firstByteMilliseconds,
headersMilliseconds: headersMilliseconds,
failureStage: status == .success || status == .http
? .none : ConnectivityTrace.FailureStage(rawValue: failureStage) ?? .request,
httpStatus: http)
}
}