Почему спидтест говорит «всё хорошо», а игра лагает: как я построил Latency Dashboard и научил пинг не врать

Это третья статья про NetDiag+ — iOS-утилиту сетевой диагностики, которую я пишу один. В первой было про ICMP-сокеты без entitlements и traceroute, во второй — про MTR, Path MTU и детект блокировок по SNI. Сегодня — про инструмент, который отвечает на вопрос «почему лагает», и про баг в самом пинге, который я нашёл только потому, что положил рядом с телефоном ноутбук.
Спойлер: правильно измерить RTT на телефоне сложнее, чем отправить. Отправить — это sendto(). Измерить — семь раундов на устройстве, пять дефектов и один латентный баг, живший в приложении с версии 1.0.
Зачем ещё один инструмент, если есть Site Reach
В приложении уже был Site Reach — параллельные пробы TCP/443 и TLS-с-SNI по списку хостов, чтобы отличать блокировку по IP от блокировки по SNI и от DNS-подмены. Это инструмент про цензуру, и он работает. Но ~60% моей аудитории — Персидский залив, где ничего из этого списка не заблокировано. Site Reach показывает им 35/35 зелёных строк и ничего не говорит. Их вопрос звучит иначе: «почему в PUBG Mobile 180 мс, если спидтест показывает 200 мегабит».
Первая мысль — отсортировать существующие RTT из Site Reach по убыванию. Не работает:
Его RTT — время TLS handshake до веб-фронтенда: DNS + TCP + криптография, а не сетевая задержка.
Веб-фронтенды сидят на CDN-edge рядом со всеми.
twitch.tvиз Эр-Рияда отвечает быстро независимо от того, лагает ли стрим.Игровые серверы — другие машины, другой протокол (UDP), другие регионы.
Одна проба не даёт джиттера, а для игр и голоса джиттер важнее среднего.
Значит — новое измерение, новый список целей, три колонки вместо одной. Так появился Latency Dashboard.
Каталог: главное — что мерить, а не чем
Ценность такого дашборда целиком в списке целей. Конкурент в нише отгружает один английский список — Steam, Xbox, Twitch, Discord, — то есть список североамериканского геймера. Саудовскому пользователю от него ничего.
Я собрал таблицы по четырём рынкам, откуда идёт трафик (SA, TR, RU, EG), плюс глобальный дефолт, по 13–15 строк. Больше на телефонном экране — уже шум. У каждой строки есть источник, оценка достоверности, категория и port: 0 — ICMP echo, иначе TCP connect на этот порт.
static let sa: [LatencyTarget] = [
.init(id: "aws-me-south-1", displayName: "AWS Bahrain",
hostOrIP: "ec2.me-south-1.amazonaws.com", port: 443, category: .cloudRegion,
confidence: .medium, note: "Degraded post Mar 2026 — PUBG-M ME hosted here"),
.init(id: "fortnite-me", displayName: "Fortnite Middle East",
hostOrIP: "ping-me.ds.on.epicgames.com", port: 0, category: .gaming,
confidence: .high, note: nil),
// ...
]
Три принципа, которые пришлось выработать:
Не выдумывать. PUBG Mobile, Free Fire, CoD Mobile, Discord voice, Riot Direct не публикуют per-region ping-хосты; всё, что гуляет по gist’ам, — собранное пользователями и протухшее. Если игра живёт в известном облачном регионе, строка называется «AWS Bahrain», а в note написано, кто там хостится. Никогда не «твой пинг в PUBG». Единственная игра с честным публичным per-region хостом — Fortnite (ping-*.ds.on.epicgames.com).
Облака — только TCP. AWS/GCP/Azure режут ICMP в VPC по политике, даже когда регион здоров. Поэтому у облачных строк port: 443, и строка помечена как TCP-измерение: handshake систематически выше сетевого RTT, и эти числа нельзя молча класть в одну колонку с ICMP.
Падение — это тоже данные. В марте 2026 бахрейнский me-south-1 физически пострадал, Riot увёл Valorant MENA в Мумбаи. Красный Бахрейн рядом с зелёным Мумбаи — ровно та картинка, которую саудовский игрок хочет увидеть. Строку не скрываем. В российской таблице аналогично: YouTube CDN с пометкой про замедление, fbcdn с пометкой «падение и есть сигнал».
Таблица выбирается по storefront App Store (SKPaymentQueue.default().storefront?.countryCode, fallback — Locale), а не по языку интерфейса: саудовец, читающий приложение по-английски, всё равно хочет таблицу Залива. Переключить руками можно.
Шлюз первым
К любой таблице добавляется строка не из каталога: default gateway, который без root достаёт C-хелпер через sysctl(CTL_NET, PF_ROUTE, 0, AF_INET, NET_RT_FLAGS, RTF_GATEWAY).
Шлюз пингуется первым и в одиночестве. Если до роутера уже 30+ мс, каждая удалённая цифра раздута этой задержкой, и виноват не игровой сервер, а Wi-Fi между телефоном и стеной — баннер об этом показывается раньше любых строк. Второй баннер — про джиттер шлюза (порог 15 мс): если трясёт локальный линк, «Unstable» на удалённых строках нельзя вешать на маршрут.
Сортировка — worst-first по весу loss × 1000 + jitter × 3 + median, сырые числа всегда видны. Для игр и звонков стабильные 40 мс лучше, чем 25, прыгающие до 90, — отсюда тройка у джиттера.
[СКРИНШОТ: Latency Dashboard на саудовской таблице — шлюз в заголовке, строки worst-first, Бахрейн красный, Мумбаи зелёный, у облачных строк метка TCP]
Семь раундов против MacBook
Первая версия прошла симулятор и на телефоне показала ерунду: 8 из 9 строк «Unstable», джиттер 384–402 мс на путях с медианой 56–195. Тут я сделал то, что надо было сделать сразу — написал на Mac скрипт с той же методикой и запускал его в ту же минуту, что и телефон, на том же Wi-Fi. Дальше каждое расхождение — дефект измерения, а не сети. За один вечер вылезли пять разных проблем.
Раунд 1–2: прогрев и фейковые потери
Первая проба в каждом параллельном чанке платила ~1,5 с за подъём пути в Network.framework и выход радио из энергосбережения. При среднем арифметическом |Δ| одна такая проба на девять нормальных даёт «джиттер» в сотни миллисекунд. А когда прогрев не укладывался в 2-секундный таймаут — он выглядел как 20% потерь.
Фикс из трёх частей. Один выброшенный TCP connect до 1.1.1.1:443 перед всем разбегом — путь греется один раз за прогон. Пробы стали списком попыток [Double?], где nil — нет ответа, и попытка №1 отбрасывается независимо от того, ответила ли она; для ICMP слоты восстанавливаются по sequence из результата пинга, так что потеря ложится в правильный слот, а не сдвигает соседей. И джиттер — это медиана последовательных |Δ|, а не среднее: один остаточный выброс сдвигает её чуть-чуть, а не на spike/N.
Заодно поменялся порог «Unstable». Плоские 10 мс — глупость: 12 мс дрожания на пути в 30 мс — проблема, на пути в 260 мс до Токио — шум. Порог стал относительным, с более высоким полом для TCP, потому что handshake дрожит сильнее ICMP по природе:
let floor: Double = measurement == .tcpConnect ? 20 : 15
let unstableAt = max(floor, median * 0.2)
Раунд 3–4: Wi-Fi спит между пробами
После этого шлюз читался 3 ± 1 мс, а Франкфурт — 68 ± 36. Mac в ту же минуту: 29 ± 3. Одна цель за три запуска подряд давала джиттер 38 → 2 → 1. Внутри одного прогона чистые строки перемешивались с дрожащими: Калифорния 188 ± 8 рядом с Сингапуром 173 ± 38.
Это не помехи — при помехах шлюз тоже бы трясло. Это power-save радио Wi-Fi. Ответ от шлюза за 3 мс прилетает, пока радио не заснуло. Ответ с 30–170 мс ловит радио в дозе и ждёт на точке доступа следующего beacon — случайные +30…+100 мс, которые никакое число со шлюза не предскажет. Баннер про джиттер шлюза на это принципиально не срабатывает (оставлен — реальную локальную тряску он ловит).
Раз детектировать нельзя — убираем причину. На время разбега в отдельной Task.detached(priority: .utility) запускается свой экземпляр PingService до шлюза с интервалом 50 мс (заведомо внутри любого dynamic-PS hold time), ответы выбрасываются. Стартует после строки шлюза, чтобы её baseline остался нетронутым; отменяется перед .done. Это не читерство: игровой и голосовой трафик держит радио бодрым сам, так что числа с бодрым радио — как раз те, на которых пользователь играет. Мерить спящее радио — измерять не сеть, а таймер энергосбережения.
Раунд 4 показал, что помогло частично: дальние пути успокоились (ОАЭ 35 → 2 мс джиттера, Токио 42 → 6), короткие — нет (Франкфурт 60/25 против 29/3 на Mac). И появилось новое: CDN-строки стали бимодальными. Snapchat — медиана 24, джиттер 230. TikTok — 24 / 219. Медиана |Δ| такой величины при такой медиане означает одно: сэмплы идут 24, 250, 24, 250.
Раунд 5: адрес меняется между попытками
Радио так не делает. Так делает свежий NWConnection по имени хоста на каждую попытку: DNS с коротким TTL на CDN-именах плюс Happy Eyeballs, гоняющий v6 и v4 наперегонки, и победитель меняется от попытки к попытке. Mac-скрипт резолвил один раз и коннектился к IP — на тех же строках 18–20 ± 2–5.
TCP-connect — это сетевой RTT, только если все попытки идут на один и тот же адрес:
/// Resolve once, connect to the literal. IPv4 to match the ICMP rows;
/// nil (→ hostname as before, so v6-only/NAT64 still works) when no A record.
private static func pinnedIPv4(_ host: String) -> String? {
guard var sin = try? DNSResolver.resolve(host) else { return nil }
var buf = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
guard inet_ntop(AF_INET, &sin.sin_addr, &buf, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil }
return String(cString: buf)
}
Бимодальность пропала, Snapchat и TikTok вылетели из «худшей пятёрки», а баннер про тряску Wi-Fi впервые сработал сам — на саудовской таблице джиттер шлюза 40 мс.
Раунд 6: статистика на четырёх дельтах
Остаток: короткие TCP-пути 16–29 мс джиттера против 2–6 на Mac, при шлюзе 2 мс в том же прогоне. Уже не радио и не DNS. Дефект статистики.
У TCP-строк было 6 попыток → 5 сэмплов → 4 дельты. А deltas[count / 2] на четырёх значениях — второе по величине, верхняя медиана. Один-два остаточных выброса становились «джиттером». На четырёх дельтах это не статистика, это монетка.
Фикс: 11 попыток везде (10 сэмплов, 9 дельт), fallback 3 → 6, и настоящая медиана — среднее двух центральных при чётном n, и для RTT, и для джиттера:
static func trueMedian(_ xs: [Double]) -> Double {
precondition(!xs.isEmpty)
let s = xs.sorted()
let mid = s.count / 2
return s.count.isMultiple(of: 2) ? (s[mid - 1] + s[mid]) / 2 : s[mid]
}
Прогон стал на ~1 с длиннее на чанк. TCP-строки совпали с Mac: Snapchat 19/1, WhatsApp 22/3, Discord 22/8. Победа. Кроме одного: все ICMP-строки внезапно показали «медиана 0 мс, потери 0%, Close & stable». Включая Fortnite ME, который до этого честно был «No reply» из Европы.
Раунд 7: чужие ответы в моём сокете
Вот тут и вылез баг, живший в PingService с первой версии, — keepalive просто сделал его видимым.
Пинг был устроен как один send() → один receive(). Проверялся только тип ответа:
// было (1.0 … 1.4.5)
try sock.send(data: packet, to: dest)
let (data, fromIP) = try sock.receive()
let recvTime = CFAbsoluteTimeGetCurrent()
guard let header = ICMPHeader.parse(data),
header.type == ICMPType.echoReply.rawValue
else { continue }
continuation.yield(PingResult(sequence: seq, rtt: recvTime - sendTime, ...))
Пока в процессе один пинг — работает. Но на Darwin непривилегированный ICMP-сокет (SOCK_DGRAM + IPPROTO_ICMP) получает все echo reply, дошедшие до хоста, а не только адресованные его identifier’у. Насколько я понимаю, Linux в ping-сокетах демультиплексирует ответы по identifier (и потому переписывает его на «порт» сокета); Darwin такого не делает — каждый сокет получает копию каждого ответа.
Шлюз отвечал keepalive’у каждые ~1 мс. Любой receive() в любой ICMP-строке получал ближайший ответ шлюза и записывал его как RTT своей цели. Отсюда 0 мс до Fortnite. Тот же механизм работал и раньше, тише: в bufferbloat-тесте два PingService (шлюз и 1.1.1.1) крутятся одновременно, и их сэмплы могли меняться местами. И даже в одиночном пинге опоздавший ответ на seq N записывался как ответ на seq N+1 с крошечным RTT.
Фикс — в самом PingService: крутить receive() до совпадения identifier и sequence, каждый раз перевзводя SO_RCVTIMEO на остаток дедлайна:
// стало (1.4.6)
let sendTime = CFAbsoluteTimeGetCurrent()
let deadline = sendTime + timeout
try sock.send(data: packet, to: dest)
while true {
let remaining = deadline - CFAbsoluteTimeGetCurrent()
guard remaining > 0 else { throw SocketError.timeout }
sock.setTimeout(seconds: max(remaining, 0.001)) // 0 заблокирует навсегда
let (data, fromIP) = try sock.receive()
let recvTime = CFAbsoluteTimeGetCurrent()
guard let header = ICMPHeader.parse(data),
header.type == ICMPType.echoReply.rawValue,
header.identifier == identifier,
header.sequenceNumber == UInt16(seq)
else { continue } // чужой или опоздавший — ждём свой
continuation.yield(PingResult(sequence: seq, bytes: data.count, ttl: 0,
rtt: recvTime - sendTime, from: fromIP,
timestamp: Date()))
break
}
Две грабли внутри фикса. SO_RCVTIMEO = 0 на BSD означает «без таймаута» — отсюда пол в 1 мс. И identifier должен доехать до ответа нетронутым — на Darwin доезжает, иначе после фикса все строки читались бы как «No reply», а раунд 7 показал реальные числа (1.1.1.1 20/13, Google DNS 27/14, Fortnite Europe 41/4, Fortnite Asia 257/6).
Итог: пять дефектов, каждый доказан ноутбуком на том же Wi-Fi. Два из них — cross-talk и опоздавший seq — были отгружены в Ping и Bufferbloat версии 1.4.5. Урок записал крупно: показание задержки с одного телефона — не доказательство; сначала тот же пробник с ноутбука на том же Wi-Fi, потом трогать пороги.
Bufferbloat: двойной пинг под нагрузкой
Bufferbloat-тест появился в 1.4.5 — второй потребитель того же исправленного пинга. Схема: 5 с baseline → 10 с насыщения download → 2 с recovery → 10 с насыщения upload, и всё это время два ICMP-потока параллельно, до шлюза и до 1.1.1.1, по 200 мс.
Смысл в дифференциале. Растёт задержка до интернета, а до шлюза плоская — очередь за роутером, у провайдера. Растёт и до шлюза — очередь между телефоном и роутером, Wi-Fi. «У тебя bufferbloat» превращается в «очередь вот тут»:
let verdict: BufferbloatResult.Verdict = {
if onCellular { return .carrierSide }
guard let dnN = dnNetRise, let upN = upNetRise else { return .cannotGrade }
// Шлюз сам вырос >30 мс под нагрузкой — Wi-Fi, перекрывает всё остальное
if let dnGw = dnGwRise, dnGw > 30 { return .localWifi }
if let upGw = upGwRise, upGw > 30 { return .localWifi }
if grade == .aPlus || grade == .a || grade == .b { return .clean }
// Асимметрия ≥ 2× называет очередь
if upN >= dnN * 2 { return .uplinkQueue } // буфер аплинка роутера — лечится SQM
if dnN >= upN * 2 { return .ispDownstream } // очередь провайдера — только документировать
return .modemOrLine
}()
Оценка A+…F считается по росту медианы над baseline, а не по абсолютному RTT; шкала совпадает с Waveform/DSLReports. На сотовой сети шлюз не пингуется — там это CGNAT-хоп, вердикт честно carrierSide.
В 1.4.6 добавился тренд. История хранила прогон одной строкой текста — рисовать было нечего. Теперь BufferbloatResult стал Codable, и в историю пишется конверт {report, result}: текстовый отчёт для просмотра и экспорта плюс структурный результат для графика. При загрузке записи toolID == "bufferbloat" прогоняются через try? decoder.decode(...): записи 1.4.5 остаются читаемыми, но в график не попадают — он копится с первого прогона 1.4.6. Рисуется Swift Charts: худший всплеск (p95 над baseline) на прогон, точки окрашены по оценке, 7D/30D/90D. Пока точка одна, секция скрыта — «тренд» через одну точку рисовать не надо.
[СКРИНШОТ: результат Bufferbloat с вердиктом и график тренда worst spike за 30 дней]
Честные ограничения
Нет root, нет raw-сокетов. Всё ICMP — через
SOCK_DGRAM/IPPROTO_ICMP. TTL из IP-заголовка на устройстве не достать (на macOS заголовок приходит в данных, на iOS — нет), поэтому в результатахttl: 0.ICMP RTT — не игровой пинг. Поверх — overhead протокола, tick rate, регион матчмейкинга. Строка называется «сетевая задержка до региона, где стоят серверы», и никак иначе.
TCP connect — не RTT. Всегда помечен; для облаков это единственный вариант.
Keepalive держит бодрым только Wi-Fi. На сотовой сети шлюза нет, keepalive не стартует, а сотовый DRX — отдельный механизм, который я пока не трогал: первые RTT на LTE/5G могут быть завышены, сброс попытки №1 закрывает это лишь частично.
Порталы операторов — плохие якоря.
stc.com.saрезолвится в CDN-edge (17 мс из Словении — это не Саудовская Аравия), уmobily.com.saджиттер 102 на Mac и 289 на телефоне. WAF/CDN-фронты, о сети оператора не говорят ничего. Строки оставлены с явной пометкой до проверки тестером внутри страны.iOS 16+, Network.framework для TCP, BSD-сокеты для ICMP. Без фона — измерение живёт, пока экран открыт.
Не измерить вообще: UDP-джиттер до реального игрового сервера (адрес выдаётся per-session), потери на конкретном хопе (для этого MTR) и что-либо про радио глубже, чем «ответ шлюза дрожит».
Mac-скрипт и сравнительная таблица по каждому раунду лежат рядом с репозиторием, и я прогоняю их перед любой правкой порогов. Дешевле, чем ещё один вечер объяснять себе, почему Fortnite стал 0 мс.
NetDiag+ 1.4.6 — в App Store: apps.apple.com/app/id6761954529. Бесплатно, 27 инструментов, 13 языков; премиум — разовые $2.99. Про интерпретацию результатов — гайды на сайте: про bufferbloat и про пинг и джиттер в играх. Если кто-то мерил RTT на iOS и упирался в те же power-save или cross-talk — интересно сравнить, особенно по сотовым сетям. Пишите в комментариях.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.