ESPN Deportes¿Qué significa el gesto de manos de Weghorst?InquirerPalace on Sara Duterte corporate ties: No one is above the lawESPNYankees don't expect Judge (calf) to play in Wild Card seriesBollywood HungamaDharma Productions wins Rs. 12.11 crores GST case in Bombay High Court; court rejects film copyright being treated as IT softwareThe Jerusalem PostRussia spreads AI fakes claiming Jews are being paid to settle in UkraineRTP DesportoLiga Nações. Alexander Bah dispensado dos trabalhos da seleção dinamarquesaInquirer EntertainmentDriver appeals dismissal of complaint vs Michelle Dee, others20 Minuten«Ziehe niemals mit deinen besten Freunden zusammen»ColliderNetflix's New Sci-Fi Western Officially Gets an Explosive Sneak Peek [Exclusive]ZDF heuteAktuelles zum Krieg in der UkraineDeadlineSearchlight Lands Lorraine Nicholson’s ‘Playmates’ Starring Lily-Rose Depp, Bill Pullman & Indiana Elle; Pullman Plays Hugh Hefner In Coming-Of-Age StoryThe Hollywood ReporterLily-Rose Depp to Star in Playboy Mansion Movie from Lorraine Nicholson, Searchlight
The Daily Newsstand · Free, Always
Tuesday, September 22, 2026

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

Translate

Это третья статья про 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 — интересно сравнить, особенно по сотовым сетям. Пишите в комментариях.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.