Files
OpenStats/Packages/OpenStatsKit/Sources/Metrics/SpeedTestProbe.swift
T

439 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Network
// MARK: - 延迟
/// 一组 TCP 建连计时的结果
public struct LatencyResult: Sendable, Codable, Equatable {
/// 最快一次的毫秒数;全部失败时为 nil
public var best: Double?
public var attempts: Int
public var failures: Int
public var isReachable: Bool { best != nil }
public init(best: Double? = nil, attempts: Int = 0, failures: Int = 0) {
self.best = best
self.attempts = attempts
self.failures = failures
}
}
/// 用 TCP 建连的耗时代替 ping:目标节点大多不回 ICMP,握手时间同样能反映往返延迟。
/// 只建连、不发数据,连上立即断开
public enum TCPLatencyProbe {
public static func measure(host: String, port: UInt16, attempts: Int = 3,
timeout: TimeInterval = 3) async -> LatencyResult {
var result = LatencyResult(attempts: attempts)
for _ in 0..<attempts {
if Task.isCancelled { break }
if let milliseconds = await connect(host: host, port: port, timeout: timeout) {
result.best = min(result.best ?? .greatestFiniteMagnitude, milliseconds)
} else {
result.failures += 1
}
}
return result
}
private static let queue = DispatchQueue(label: "com.openstats.speedtest.tcp", qos: .utility)
private static func connect(host: String, port: UInt16, timeout: TimeInterval) async -> Double? {
guard let port = NWEndpoint.Port(rawValue: port) else { return nil }
let options = NWProtocolTCP.Options()
options.connectionTimeout = Int(timeout.rounded(.up))
options.noDelay = true
let connection = NWConnection(host: NWEndpoint.Host(host), port: port,
using: NWParameters(tls: nil, tcp: options))
let clock = ContinuousClock()
let start = clock.now
let once = ResumeOnce()
let milliseconds: Double? = await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
once.attach(continuation)
connection.stateUpdateHandler = { state in
switch state {
case .ready:
once.resume(Self.elapsed(from: start, clock: clock))
case .failed, .cancelled:
once.resume(nil)
case .waiting:
// 路由不可达、端口被拒:不再等超时,直接算这次失败
once.resume(nil)
default:
break
}
}
connection.start(queue: queue)
// connectionTimeout 在个别情况下不触发状态变化,兜一个自己的超时
queue.asyncAfter(deadline: .now() + timeout) { once.resume(nil) }
}
} onCancel: {
once.resume(nil)
}
connection.cancel()
return milliseconds
}
private static func elapsed(from start: ContinuousClock.Instant, clock: ContinuousClock) -> Double {
let duration = clock.now - start
return Double(duration.components.seconds) * 1000 + Double(duration.components.attoseconds) / 1e15
}
}
/// 状态回调、超时与取消都可能同时到达,续体只能恢复一次
private final class ResumeOnce: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<Double?, Never>?
private var done = false
private var pending: Double??
func attach(_ continuation: CheckedContinuation<Double?, Never>) {
lock.lock()
if let pending {
lock.unlock()
continuation.resume(returning: pending)
return
}
self.continuation = continuation
lock.unlock()
}
func resume(_ value: Double?) {
lock.lock()
guard !done else { return lock.unlock() }
done = true
guard let continuation else {
// 续体还没挂上(取消可能先到),先记下结果
pending = .some(value)
lock.unlock()
return
}
self.continuation = nil
lock.unlock()
continuation.resume(returning: value)
}
}
// MARK: - 吞吐
/// 一次测速的用量上限:时间与流量哪个先到就停在哪
public struct SpeedLimit: Sendable, Equatable, Codable {
public var seconds: Double
public var bytes: Int
public init(seconds: Double, megabytes: Int) {
self.seconds = seconds
bytes = megabytes * 1024 * 1024
}
}
/// 单个节点一次测速最多花多少时间与流量,设置里可选
public enum SpeedTestBudget: String, Sendable, CaseIterable, Codable {
case light, medium, full
public var limit: SpeedLimit {
switch self {
case .light: SpeedLimit(seconds: 3, megabytes: 20)
case .medium: SpeedLimit(seconds: 5, megabytes: 50)
case .full: SpeedLimit(seconds: 10, megabytes: 200)
}
}
}
public struct SpeedResult: Sendable, Equatable, Codable {
public var bitsPerSecond: Double
/// 实际用掉的流量
public var bytes: Int
public var seconds: Double
}
/// 下载与上传测速:边传边算,达到上限就掐断连接,不会把整份文件收完
public enum ThroughputProbe {
/// 开头的建连、慢启动不计入速度
static let warmup: Double = 0.5
public static func download(_ url: URL, limit: SpeedLimit,
progress: @escaping @Sendable (Double, Int) -> Void = { _, _ in }) async -> SpeedResult? {
var request = URLRequest(url: url)
request.setValue("bytes=0-\(limit.bytes - 1)", forHTTPHeaderField: "Range")
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
return await SpeedMeter(limit: limit, progress: progress).run(request: request, uploadBytes: nil).result
}
public static func upload(_ url: URL, bytes: Int, limit: SpeedLimit,
progress: @escaping @Sendable (Double, Int) -> Void = { _, _ in }) async -> SpeedResult? {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
return await SpeedMeter(limit: limit, progress: progress).run(request: request, uploadBytes: bytes).result
}
/// 反复请求同一个地址直到用满上限,并读出响应头(Cloudflare 单次最多给 25 MB,
/// 而且边缘节点信息就在响应头里)
static func downloadRepeatedly(_ url: URL, limit: SpeedLimit,
progress: @escaping @Sendable (Double, Int) -> Void = { _, _ in })
async -> (result: SpeedResult?, headers: [String: String]) {
var request = URLRequest(url: url)
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
return await SpeedMeter(limit: limit, progress: progress).run(request: request, uploadBytes: nil, repeats: true)
}
}
/// 统计传输速度:记录起点与热身之后的字节数,达到上限就取消任务
private final class SpeedMeter: NSObject, URLSessionDataDelegate, @unchecked Sendable {
private let limit: SpeedLimit
private let progress: @Sendable (Double, Int) -> Void
private let clock = ContinuousClock()
private let lock = NSLock()
private var start: ContinuousClock.Instant?
/// 热身结束时的时间与字节数,速度从这里开始算
private var mark: (time: ContinuousClock.Instant, bytes: Int)?
private var bytes = 0
private var headers: [String: String] = [:]
private var finished = false
private var continuation: CheckedContinuation<(SpeedResult?, [String: String]), Never>?
private var task: URLSessionTask?
/// 单次请求给不满上限时(Cloudflare 一次最多 25 MB)继续下一次
private var repeats = false
private var session: URLSession?
private var request: URLRequest?
init(limit: SpeedLimit, progress: @escaping @Sendable (Double, Int) -> Void) {
self.limit = limit
self.progress = progress
}
func run(request: URLRequest, uploadBytes: Int?,
repeats: Bool = false) async -> (result: SpeedResult?, headers: [String: String]) {
let configuration = URLSessionConfiguration.ephemeral
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.timeoutIntervalForRequest = limit.seconds + 10
configuration.httpAdditionalHeaders = ["User-Agent": "OpenStats"]
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
defer { session.invalidateAndCancel() }
prepare(session: session, request: request, repeats: repeats)
let pair: (SpeedResult?, [String: String]) = await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
lock.lock()
self.continuation = continuation
start = clock.now
lock.unlock()
let task: URLSessionTask
if let uploadBytes {
task = session.uploadTask(with: request, from: Data(count: uploadBytes))
} else {
task = session.dataTask(with: request)
}
lock.lock()
self.task = task
lock.unlock()
task.resume()
}
} onCancel: {
finish(cancelled: true)
}
return (pair.0, pair.1)
}
/// 记下会话与请求,续传时还要再发一次(同步方法,异步上下文里不能直接拿锁)
private func prepare(session: URLSession, request: URLRequest, repeats: Bool) {
lock.lock()
self.session = session
self.request = request
self.repeats = repeats
lock.unlock()
}
// MARK: 回调
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
if let http = response as? HTTPURLResponse {
lock.lock()
headers = Dictionary(uniqueKeysWithValues: http.allHeaderFields.compactMap { key, value in
(key as? String).map { ($0.lowercased(), String(describing: value)) }
})
lock.unlock()
}
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
advance(by: data.count)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64,
totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
advance(by: Int(bytesSent))
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if error == nil, continueIfNeeded() { return }
finish(cancelled: false)
}
/// 还没到上限、这次请求又正常结束:接着发下一次,把测量凑满
private func continueIfNeeded() -> Bool {
lock.lock()
guard repeats, !finished, let start, let session, let request, bytes > 0,
bytes < limit.bytes, Self.seconds(from: start, to: clock.now) < limit.seconds else {
lock.unlock()
return false
}
let next = session.dataTask(with: request)
task = next
lock.unlock()
next.resume()
return true
}
// MARK: 计算
private func advance(by count: Int) {
lock.lock()
bytes += count
guard let start, !finished else { return lock.unlock() }
let now = clock.now
let seconds = Self.seconds(from: start, to: now)
if mark == nil, seconds >= ThroughputProbe.warmup { mark = (now, bytes) }
let (speed, used) = (rate(now: now), bytes)
let reachedLimit = bytes >= limit.bytes || seconds >= limit.seconds
lock.unlock()
progress(speed, used)
if reachedLimit { finish(cancelled: false) }
}
/// 热身之后的平均速度;还没过热身期就用全程平均,让界面上的数字先动起来
private func rate(now: ContinuousClock.Instant) -> Double {
guard let start else { return 0 }
let from = mark?.time ?? start
let base = mark?.bytes ?? 0
let seconds = Self.seconds(from: from, to: now)
guard seconds > 0.05 else { return 0 }
return Double(bytes - base) * 8 / seconds
}
private func finish(cancelled: Bool) {
lock.lock()
guard !finished, let start else { return lock.unlock() }
finished = true
let now = clock.now
let result: SpeedResult?
if cancelled || bytes == 0 {
result = nil
} else {
let from = mark?.time ?? start
let seconds = Self.seconds(from: from, to: now)
let measured = bytes - (mark?.bytes ?? 0)
result = seconds > 0.05 && measured > 0
? SpeedResult(bitsPerSecond: Double(measured) * 8 / seconds, bytes: bytes,
seconds: Self.seconds(from: start, to: now))
: nil
}
let continuation = self.continuation
let headers = self.headers
let task = self.task
self.continuation = nil
lock.unlock()
task?.cancel()
continuation?.resume(returning: (result, headers))
}
private static func seconds(from: ContinuousClock.Instant, to: ContinuousClock.Instant) -> Double {
let duration = to - from
return Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18
}
}
// MARK: - 本机宽带(Cloudflare)
/// 本机宽带测速的结果:下行、上行、空载延迟与抖动,以及这次走的 Cloudflare 边缘节点
public struct BroadbandResult: Sendable, Equatable, Codable {
public var download: SpeedResult?
public var upload: SpeedResult?
public var latencyMilliseconds: Double?
public var jitterMilliseconds: Double?
public var colo: String?
public var city: String?
public var totalBytes: Int = 0
}
/// 用 speed.cloudflare.com 测本机宽带:先测延迟与抖动,再测下行、上行。
/// 这是官方测速站自己用的接口,不需要密钥
public enum BroadbandTest {
public enum Stage: String, Sendable { case latency, download, upload }
static let downURL = URL(string: "https://speed.cloudflare.com/__down?bytes=")!
static let upURL = URL(string: "https://speed.cloudflare.com/__up")!
/// 上行的数据要在内存里生成,单独设一个上限
static let uploadMegabytes = 25
/// 下行单次请求的大小:再大 Cloudflare 会直接拒绝,凑上限靠多请求几次
static let chunkMegabytes = 25
static let latencySamples = 10
public static func run(limit: SpeedLimit,
stage: @escaping @Sendable (Stage) -> Void = { _ in },
progress: @escaping @Sendable (Double, Int) -> Void = { _, _ in }) async -> BroadbandResult {
var result = BroadbandResult()
stage(.latency)
let (samples, headers) = await latency()
result.colo = headers["cf-meta-colo"] ?? headers["colo"]
result.city = headers["cf-meta-city"]
if !samples.isEmpty {
result.latencyMilliseconds = samples.min()
// 抖动看相邻两次的差;偶尔一次重新建连会拉出一个大值,去掉最大的那个差
let gaps = zip(samples, samples.dropFirst()).map { abs($1 - $0) }.sorted().dropLast()
if !gaps.isEmpty {
result.jitterMilliseconds = gaps.reduce(0, +) / Double(gaps.count)
}
}
guard !Task.isCancelled else { return result }
stage(.download)
let url = URL(string: downURL.absoluteString + String(chunkMegabytes * 1024 * 1024))!
let (download, downloadHeaders) = await ThroughputProbe.downloadRepeatedly(url, limit: limit, progress: progress)
result.download = download
result.totalBytes += download?.bytes ?? 0
result.colo = result.colo ?? downloadHeaders["cf-meta-colo"] ?? downloadHeaders["colo"]
guard !Task.isCancelled else { return result }
stage(.upload)
let uploadBytes = min(limit.bytes, uploadMegabytes * 1024 * 1024)
let upload = await ThroughputProbe.upload(upURL, bytes: uploadBytes, limit: limit, progress: progress)
result.upload = upload
result.totalBytes += upload?.bytes ?? 0
return result
}
/// 连续请求空响应,用往返时间当延迟;第一次要建连、握手,不计入
private static func latency() async -> ([Double], [String: String]) {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 5
configuration.httpAdditionalHeaders = ["User-Agent": "OpenStats"]
let session = URLSession(configuration: configuration)
defer { session.invalidateAndCancel() }
let url = URL(string: downURL.absoluteString + "0")!
let clock = ContinuousClock()
var samples: [Double] = []
var headers: [String: String] = [:]
for index in 0..<latencySamples {
if Task.isCancelled { break }
let start = clock.now
guard let (_, response) = try? await session.data(from: url) else { continue }
let duration = clock.now - start
// 前两次在建连、握手,不算延迟
if index < 2 {
headers = Dictionary(uniqueKeysWithValues: ((response as? HTTPURLResponse)?.allHeaderFields ?? [:])
.compactMap { key, value in (key as? String).map { ($0.lowercased(), String(describing: value)) } })
continue
}
samples.append(Double(duration.components.seconds) * 1000 + Double(duration.components.attoseconds) / 1e15)
}
return (samples, headers)
}
}