Когда TLS остаётся открытым, а принятые к отправке данные не доходят: диагностика зависания потока в мобильной сети

PHANTOM — мессенджер с открытым кодом и сквозным шифрованием, спроектированный исходя из того, что сеть враждебна. Сказать, что сеть враждебна, легко; спроектировать систему с учётом этого — трудно, потому что неисправность во враждебной сети редко бывает «чистой». Она не присылает вам 403. Она не закрывает соединение с указанием причины. Она даёт TLS- и WebSocket-соединению установиться; клиентский API принимает записи приложения; и всё же relay ни разу не сообщает, что видел их, а сокет остаётся открытым, и клиент продолжает считать, что всё в порядке — вплоть до момента, когда сам объявляет соединение неисправным.

Это заметка об одном конкретном столкновении с таким видом отказа: воспроизводимом, «отрицаемом» зависании на российском мобильном пути, которое выводило из строя наш прямой WebSocket-транспорт, и о процессе фальсификации гипотез, который мы применили до того, как поменяли хоть одну строку транспортного кода.

Интересно здесь не то, что «в России есть DPI» — это хорошо задокументированный факт. Интересна форма самого расследования: как проверить пять правдоподобных причин, когда сеть отказывается что-либо сообщать, как сузить то, что реально подтверждается доказательствами, и — не менее важно — где нужно остановиться и признать, что часть гипотез так и осталась неопровергнутой.

Область измерений — сразу и заранее, потому что от неё зависит, как читать каждое число ниже. Всё, что описано здесь, измерено на одном тестовом стенде: один бюджетный Android-телефон в одном российском регионе, в течение нескольких окон измерений в конце II квартала 2026 года — один мобильный оператор (Tele2 LTE), один Wi-Fi-провайдер для сравнения, одно устройство.

Эта статья анализирует контролируемые диагностические плечи на raw-OkHttp. Production-путь на Ktor показал отдельный, более тяжёлый базовый профиль — 29 сессий примерно по 31 секунде; клиент не зафиксировал ни одного успешного цикла ping/pong до таймаута — и мы не смешиваем эти измерения здесь. Там, где архитектурный раздел делает выводы, они опираются на production-код, соответствующие architecture decision records и production-наблюдения; числа в диагностических разделах на них не опираются. Один оператор, один регион, одно устройство, одна измерительная кампания, охватившая несколько окон. Каждое число здесь — наблюдение с этого стенда, а не утверждение о «российских мобильных сетях» вообще и не утверждение о какой-либо конкретной системе цензуры. Экстраполяция от одного оператора в одном регионе к общенациональной политике — отдельная исследовательская программа, и это не она.


Симптом

Диагностический путь на raw-OkHttp воспроизводил класс отказа Direct WebSocket с ритмом, по которому можно было сверять часы. (Напоминание из области измерений: это диагностический клиент, не production. Смешение этих двух дало бы более складную историю — и неверную.)

Последняя строка важна: путь по Wi-Fi нельзя считать заведомо исправным контрольным вариантом. Direct WS отказывал и там, просто позже. Это расширяет поверхность отказа за пределы LTE; это не устанавливает, что оба пути имели общий триггер или механизм.

«Тишина» — ключевое слово. Не пришло ни разрыва соединения удалённой стороной, ни TLS alert — соединение так и не было закрыто с другого конца. Клиент в итоге локально признал сокет неисправным: при отправке очередного запланированного Ping библиотека обнаруживала, что всё ещё ожидает Pong на предыдущий Ping. До этого момента его собственные логи показывали, что send() принимал записи в соединение, application-нагрузку которого relay ни разу не зафиксировал как полученную. Сбой лежал где-то между приёмом в исходящий путь клиента и доставкой в приложение relay; эти логи не локализуют его точнее.

Настолько чистый ритм — подарок. «Отрицаемые» отказы мучительно отлаживать, но воспроизводимый отрицаемый отказ — это рычаг: он даёт базовую линию, и каждая гипотеза делает какое-то наблюдаемое предсказание при изменении одного релевантного условия. Этого достаточно, чтобы начать выбивать их одну за другой.


Пять правдоподобных причин и что установил каждый эксперимент

Когда соединение умирает на 30-й секунде, у каждого в команде в течение часа появляется любимая теория. Дисциплина в том, чтобы записать их все, а затем спроектировать самый дешёвый эксперимент, способный убить каждую — не подтвердить, а убить. Две из пяти пережили этот процесс в ослабленном виде, и сказать об этом — часть отчёта.

1. «Дело в нашей каденции heartbeat»

Первый подозреваемый — всегда ты сам. Может быть, наш интервал ping задевал какой-то таймер простоя.

Мы задавали несколько значений интервала WebSocket ping и измеряли время жизни сессии для каждого. Результат оказался почти оскорбительно линейным: длительность первой сессии составляла примерно два интервала ping, а последующих — примерно три, на каждом интервале, который мы пробовали.

Эта картина прямо вытекает из того, как HTTP-библиотека клиента обнаруживает мёртвый сокет: она допускает один неотвеченный Ping. В первой сессии первый Ping не получил Pong, и следующий запланированный Ping обнаружил, что ответ на предыдущий Ping всё ещё не пришёл, и завершил сокет с ошибкой — два такта, отсюда примерно 2×. В последующих сессиях сначала завершался один цикл ping/pong, поэтому отказ приходился на третий такт, примерно 3×. Это детектор «один неотвеченный Ping», а не политика «три пропущенных heartbeat».

Один только наклон кривой этого не решает: middlebox, действующий по счётчику keepalive, дал бы похожую кривую. Метки времени на стороне relay установили одну вещь — relay уже не наблюдал application-доставку до того, как клиент объявил соединение неисправным, — что отделяет зависание от его обнаружения. Но само по себе это не исключает участие каденции в триггере. Чтобы это решить, нужен был бы outbound-захват или счётчики очереди, либо тест, в котором сначала подтверждается работающая application-доставка, а затем она исчезает при разных интервалах — а мы ни разу не наблюдали сессию, где application-доставка вообще работала, так что момента перехода для измерения не было.

Поэтому честный вывод более узок, чем «теория мертва»: каденция определяет задержку обнаружения, а участвует ли она также в триггере — не доказано. Полезное переосмысление сохраняется в любом случае — ненаблюдение application-данных и обнаружение отказа сокета — два разных события, и мы их путали.

2. «Дело в нашем edge-сервере, который неправильно обрабатывает WebSocket-фреймы»

Следующий подозреваемый — наш собственный edge. Мы терминируем TLS на реверс-прокси, полностью осведомлённом о WebSocket — там достаточно места для тонкого бага обработки фреймов.

Поэтому мы убрали его из уравнения. Мы подняли второй edge, который не делал ничего, кроме терминации TLS и форвардинга сырого TCP: никакой осведомлённости об HTTP, никакой осведомлённости о WebSocket, никакого мнения о фреймах вообще. Два совершенно разных TLS-стека, два совершенно разных поведения прокси.

Оба дали одинаковый тайминг и одинаковую сигнатуру отказа в 21 сессии на каждом edge. Тот же ритм 30/45 секунд, та же тишина, то же расхождение между эндпоинтами.

Это весомый довод против implementation-specific бага edge или обработки WebSocket-фреймов. Обратите внимание на границу этого вывода: он не снимает подозрение с общей конфигурации, адреса origin-сервера, хостового ядра и сетевого пути, а также самого приложения relay — всё это оставалось постоянным на обоих edge. И мы аккуратно записали вердикт: не «наш edge невиновен, значит виноват оператор», а «implementation-specific баг обработки фреймов маловероятен; общие факторы остаются в рассмотрении». Нельзя осудить подозреваемого только потому, что оправдали другого.

3. «Дело в артефакте client-side send-буфера»

На клиенте успешный вызов send() на WebSocket означает лишь, что фрейм был принят в исходящую очередь — не то, что он покинул радиомодуль. Так что, может быть, фреймы, которые мы считали отправленными, скапливались локально и никогда не уходили в эфир.

Эту гипотезу мы не смогли убить, и честно будет так и сказать. Есть соблазн указать на асимметрию control-versus-application, описанную ниже, как на довод против локальной буферизации — но этот довод не работает: control-фреймы и application-фреймы могут проходить через разные внутренние очереди клиента, и наши логи не позволяют отличить это от сетевого эффекта. Разрешить вопрос помог бы outbound-захват или счётчики очереди, которых у нас не было. В журнале расследования эта гипотеза остаётся открытой, а не опровергнутой. Отладочный лог, который содержит только победы, вам врёт.

4. «Прошивка телефона паркует радиомодуль»

Агрессивное управление питанием на бюджетном Android-железе — реальность, и задремавший радиомодуль дал бы ровно такую тишину.

Целевая тестовая матрица исключила полную парковку радиомодуля: отказ воспроизводился в состояниях, где радиомодуль был доказуемо активен и нёс другой трафик. Обратите внимание на узость этого вывода — он исключает именно спящий радиомодуль. Он не исключает любой эффект прошивки или хостового сетевого стека, и мы не утверждаем обратного.

5. «TLS- или WebSocket-соединение так и не устанавливается корректно»

Радикальная гипотеза: что-то фундаментально не так с самим установлением защищённого канала.

Версия гипотезы, относящаяся к установлению соединения, опровергается доказательствами, и понимание почему указывает на ответ. Сессии завершали полное TLS-рукопожатие, и relay получал control Ping клиента во всех 20 сессиях, дошедших до якоря — защищённый канал устанавливался успешно и нёс фреймы на транспортном уровне. Рукопожатие, которое завершается, и канал, который несёт фреймы десятки секунд, — это не сломанное рукопожатие. Обратите внимание, чего этот результат не исключает: post-setup баг в WebSocket-слое клиента или в приложении relay остаётся в рассмотрении, как и общие факторы из §2 и §3. И чего он не доказывает: relay не зафиксировал ни одного application heartbeat за все эти сессии, поэтому мы ни разу не наблюдали, чтобы путь application-данных работал. Setup прошёл успешно; application-доставка ни разу не была доказуемо здоровой с самого начала. Отказ не в установлении канала — сам setup прошёл успешно. Следовательно, причина связана с некоторым свойством после установления соединения или общим коррелирующим фактором: возраст соединения, объём байт или пакетов, количество событий, либо иное состояние пути.

Это переосмысление — успех установки TLS/WebSocket и успех application-доставки — разные свойства; наши эксперименты установили первое, но ни разу не наблюдали второе — превратило груду мёртвых теорий в нечто проверяемое.


Два измерения и что они показывают, а что нет

Наблюдение 1: часть control-трафика доходит, а записи приложения — нет

Мы инструментировали оба конца одного и того же соединения и сравнили логи. Это самый важный методологический шаг во всей работе: зависание видно только потому, что одна и та же сессия логировалась и с телефона, и с relay, и эти два лога разошлись в том, что реально дошло.

Эти цифры получены из той же работы по сравнению edge, описанной выше, а не из отдельной кампании: в рамках серии на edge с Caddy открылась 21 сессия, из которых 20 дошли до контрольной точки Ping до окончания захвата. По этим 20:

СобытиеНаправлениеНаблюдение
Control Pingклиент → relayrelay получил во всех 20 сессиях, дошедших до контрольной точки
Control Pongrelay → клиентпервая сессия: клиент не увидел ни одного. Последующие сессии: ровно один, затем тишина
Application heartbeatклиент → relayклиентский лог: send() сообщил об успешном результате; relay: не получено ни одного
Application echorelay → клиентне сформирован ни разу — не было heartbeat, на который отвечать

Важно внимательно читать причинный порядок, потому что его легко перепутать: relay получал control-фреймы клиента, но ни разу не зафиксировал от него application heartbeat. Echo отсутствует не потому, что потерялся на обратном пути, а потому, что relay было нечего эхировать. Достигли ли application-фреймы провода вообще — это ровно то, что мы не смогли установить.

Так что в рамках одних и тех же установленных сессий, на одном и том же пути: control-фреймы доходили до relay; записи приложения, принятые клиентом, там не наблюдались. Покинули ли эти записи исходящую очередь клиента вообще — это ровно то, что мы не смогли определить.

Чего это не показывает. Есть соблазн заключить, что нечто на пути различает control-фреймы WebSocket и текстовые фреймы. Прямо это невозможно: opcode лежит внутри WebSocket-фрейма, который лежит внутри TLS. Наблюдатель без терминации TLS не может его прочитать. Что наблюдатель на пути может видеть — это размер, тайминг, направление и структуру TLS-записей — и control-фреймы с application-фреймами различаются именно по этим осям. Различия в размере полезной нагрузки, каденции записи, батчинге, постановке в очередь на клиенте или формировании TLS-записей остаются правдоподобными объяснениями измеренной асимметрии. Мы сообщаем о наблюдении относительно наших эндпоинтов, а не о механизме внутри сети.

Наблюдение 2: один поток, наблюдался один полный чанк

Отдельно мы провели один медленный «ручеёк» по обычному HTTPS: chunked POST, отправляющий 40 KiB тела как восемь чанков по 5 KiB с интервалом в десять секунд.

Приложение relay зафиксировало первый чанк в 5 KiB и никаких последующих данных тела запроса. Иными словами, приложение relay увидело одну восьмую полного тела запроса, которое клиент пытался отправить.

Чего это не показывает, и это важная часть. Это был единственный прогон при единственной конфигурации, и это не был тест с единственным соединением: параллельно с ним шли вызов session-auth и три запроса публикации prekey, два из которых завершились клиентским таймаутом. То, что повлияло на POST, могло зависеть от этой параллельной нагрузки, либо повлияло и на эти запросы тоже — мы не можем разделить два эффекта. Вдобавок размер чанка, каденция, число байт, число пакетов и прошедшее время менялись одновременно, и граница между тем, что дошло, и тем, что нет, пришлась ровно на границу первого чанка. По одному прогону мы не можем отделить триггер по числу байт от таймаута простоя между первым и вторым чанком, окна буферизации на нашем собственном edge, границы PDP-контекста или любого из нескольких эффектов планирования, которые могли совпасть с этой границей. Мы также не можем установить даже диапазон, в котором мог бы находиться порог по числу байт, если причиной вообще был такой порог: всё, что мы знаем, — первый чанк в 5 KiB дошёл, а после него не дошло ничего — реальный предел мог лежать где угодно дальше, а второй чанк просто ни разу не был доставлен. Другой размер чанка или другая пауза могли дать другой результат — или не дать вообще никакого.

Так что честное утверждение таково: в этой единственной конфигурации приложение relay зафиксировало первый чанк в 5 KiB и никаких последующих данных тела запроса — что согласуется с гипотезой об отсечении на уровне потока, но не доказывает ни само отсечение, ни его триггер. Что-либо сильнее — «бюджет байт на поток составляет X» — это гипотеза, для которой у нас недостаточно данных. Чтобы её установить, нужны были бы повторы с разными размерами чанков и каденциями, плюс демонстрация того, что свежий TCP-поток снова пропускает начальную порцию.

О предшествующих работах. Сообщество уже документировало зависания на уровне потока у российских мобильных операторов — тред net4people/bbs Issue #490 изначально описывает зависание примерно после 15–20 KB в направлении сервер→клиент, а позднейшее обновление характеризует его как примерно 25 пакетов в любом направлении, в среднем около 16 KB. Две вещи делают это плохой калибровкой для нашего прогона. Исходный кейс преимущественно идёт в направлении сервер→клиент, тогда как наш медленный POST — клиент→сервер. А более фундаментально — это разные типы величин: их число — это выведенный порог, наше — это просто последний полностью наблюдённый чанк. Наш прогон нельзя поставить ни ниже, ни выше их результата, потому что мы вообще не измеряли порог. Из этого сравнения можно сделать только один вывод: никому не следует предполагать универсальный порог, и оба наблюдения вполне могут иметь разные триггеры. Конструкция, которая жёстко закладывает предположение о «том самом пороге», строится на песке.

Что мы говорим, а что нет. Что показал этот стенд: на Tele2 LTE базовые диагностические сессии WebSocket показывали время жизни 30/45 секунд; в 20 сессиях, дошедших до контрольной точки на edge с Caddy, relay ни разу не получил application heartbeat; а в одном медленном HTTPS POST приложение relay зафиксировало только первый чанк в 5 KiB. Чего мы намеренно не говорим: что мы определили порог, что он применим к другим операторам, что нам известен механизм, или что PHANTOM что-либо «побеждает». Мы обходим конкретный, наблюдённый эффект на конкретном стенде. Это гораздо меньшее и гораздо более честное утверждение.


Ни один транспорт не является универсальным фундаментом

Здесь диагностика перестаёт быть детективной историей и становится архитектурным ограничением. Пояснение о том, на чём стоит этот раздел: диагностические числа выше стали поводом для этой работы, но архитектурные выводы ниже опираются на production-код, architecture decision records и production-наблюдения — не на диагностические плечи. Если потоки могут зависать рано на некоторых путях, а условия, вызывающие это, не являются устойчивой известной константой, из этого следует несколько вещей.

Ни один транспорт не является фундаментом. Direct WebSocket, обычный HTTPS long-poll, TLS-мимикрирующий туннель и Tor демонстрируют разные режимы отказа и разные компромиссы. Если ваша realtime-подложка — один сетевой протокол, вы наследуете его конкретный отказ. Значит подложка не может быть протоколом. Она должна быть набором протоколов за одним интерфейсом — хотя не каждый режим получает fallback, и это осознанный выбор, а не недосмотр (см. режимы приватности ниже).

PHANTOM обращается к своим транспортам через единый интерфейс RelayTransport, за которым стоит несколько классов:

Пользователь не выбирает конкретный транспорт. Он выбирает политику приватности, а менеджер транспорта выбирает среди маршрутов и возможностей, которые эта политика допускает. Standard и Private сохраняют более широкие цепочки кандидатов и примерно сопоставимую функциональность. Ghost — осознанное исключение: работает только через Tor и закрывается при отказе (fail-closed), а в текущей альфа-версии поддерживает только текстовые сообщения, жертвуя доступностью медиа и связи в реальном времени ради более сильного сокрытия источника, вместо того чтобы молча откатываться на менее приватный путь.

Режимы действительно различаются в том, что происходит, когда транспорт недоступен — и честное описание уже, чем «просто переключается на другой». На этапе первичного выбора соединения режимы Standard и Private проходят по упорядоченным цепочкам кандидатов. После того как соединение установлено, деградация WebSocket в runtime обрабатывается в первую очередь слоем REST-fallback; он не проходит автоматически заново по цепочке внешних транспортов. Это было осознанным решением на протестированных путях: на момент, когда эта политика выбиралась, REST поверх Direct оставался более быстрым путём восстановления в рамках соответствующей проверки, тогда как мимикрирующий транспорт ещё не был подтверждён, а Tor мог запускаться минутами. Отказ от рабочего пути REST-поверх-Direct просто из-за деградации WebSocket сделал бы восстановление медленнее, а не лучше. (Позднейшее тестирование на Tele2 обнаружило отдельный режим отказа REST long-poll на этом же пути — напоминание, что и это тоже наблюдение, ограниченное временем и сетью, а не постоянное свойство.) Самый строгий режим допускает только Tor и fail-closed вместо отката — потому что молчаливый откат на менее приватный путь есть ровно то поведение, которого пытается избежать тот, кто выбрал этот режим.

Несущая идея, которую стоит унести с собой, даже если вы никогда не столкнётесь с цензурируемой сетью: контракт доставки живёт над транспортным интерфейсом, а не внутри какого-либо транспорта.

Сообщения ставятся в очередь на relay, могут повторно выдаваться после переподключения и имеют последовательную нумерацию для каждого получателя, при этом идемпотентность на стороне relay и ограниченное по времени хранение tombstone снижают дублирование доставки — а дедупликация по envelope-ID на стороне клиента служит последней защитой. Всё это — свойство слоя доставки, находящегося поверх абстракции транспорта.

Так что когда поток прямого WebSocket молча зависает, отправитель может повторить отправку того же запечатанного envelope через путь REST send, а получатель забирает его поллингом — без повторного шифрования, без повторной подписи. В пределах окна идемпотентности и хранения tombstone на relay тот же envelope-ID позволяет пути REST send подавлять дублирующие повторы. За пределами этого окна дедупликация по envelope-ID на клиенте остаётся последней защитой. Отказ транспорта не обязан становиться потерей сообщения, если доставка успешно завершается по другому пути, — но абстракция не может гарантировать, что альтернативный путь сработает в любой сети; как минимум на одном пути Tele2 LTE сам REST long-poll не смог завершить доставку. Устойчивость никогда не была обязанностью какого-либо одного транспорта, но она и не гарантия.

(Оговорка о текущем состоянии: персистентные очереди relay (с сохранением на диске) с тех пор попали в master, но не были развёрнуты во время этих экспериментов, и production-раскатка пока не состоялась.)

Сжато: там, где политика допускает fallback, восстанавливаемость — это свойство подложки, а не какого-либо одного сетевого протокола. Нельзя сделать враждебную сеть надёжной, найдя тот единственный волшебный транспорт, который всегда работает, — такого не существует, и на этой посылке построены Standard/Private. Ghost — осознанное исключение: он принимает доступность только через Tor, чтобы сохранить свой fail-closed контракт приватности, обменивая восстанавливаемость на гарантию того, что никогда молча не откатится на что-то менее приватное.


Что мы сказали бы следующему человеку

Ничто из этого не требовало знать, кто это делал или почему. Требовалось относиться к враждебной сети как к отлаживаемой системе: формулировать гипотезы, проектировать эксперименты, способные их опровергнуть, инструментировать оба конца и соразмерять уверенность выводов с тем, что действительно подтверждают доказательства. Сеть может быть враждебной. Но она не магия — и нам нельзя быть чрезмерно уверенными в своих выводах.


PHANTOM — проект с открытым кодом (AGPL-3.0-or-later). Архитектурные решения, модель угроз и известные проблемы открыты публично. Если вы строите транспорты для враждебных сетей или хотите поискать слабые места в наших решениях — код и проектная документация доступны на github.com/LiudvigVladislav/Phantom, а сам проект живёт на phntm.pro.