GGWP Lab

Как Кукарек научился звонить

8 сентября 2026

Кукарек — анонимный чат со звонками. Сегодня он за один день прошёл путь от «звоню, а ничего не происходит» до приложения с собственным доменом, шифрованным каналом и заявкой в App Store. Рассказываю про починки, потому что все три оказались поучительными: симптомы были разные, а корень один.

Звонок доходил, а телефон молчал

Первый баг проявлялся несимметрично, и это сбивало с толку сильнее всего. Звонки от одного человека проходили нормально. Звонки к нему — нет: у звонящего шли гудки, у абонента не звонило вообще.

Несимметричные баги почти всегда означают, что стороны находятся в разном состоянии. Так и оказалось.

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

Сервер считал такого абонента доступным и отправлял вызов прямо в этот мёртвый сокет — вместо того, чтобы разбудить телефон push-уведомлением. Вызов уходил в пустоту. Звонящий послушно слышал гудки: сервер ведь ответил, что всё в порядке.

Лечится это единственным способом — периодически спрашивать. Сервер теперь пингует каждое соединение раз в полминуты и закрывает то, которое не ответило дважды подряд. Мёртвое соединение исчезает из списка «в сети», и звонок честно доходит до ветки с push-уведомлением.

Одного пропуска мало: телефон в разговоре может отвлечься на переключение сети, и рвать из-за этого сокет — значит рвать сигналинг ровно тогда, когда он нужнее всего.

Соединились, но не слышим друг друга

Дальше звонки стали доходить — и оказалось, что в них тишина. С обеих сторон.

Самое неприятное в этом баге: всё наблюдаемое выглядело исправным. Соединение устанавливалось, на экране шёл таймер разговора, в логах WebRTC значилось ICE: connected. Ни одной ошибки нигде.

Дело было в устройстве звука на iOS. Когда приложение работает через CallKit — системный экран входящего вызова, — аудиосессией распоряжается не приложение, а система. WebRTC переводят в ручной режим: он не издаёт ни звука, пока CallKit не разрешит.

Разрешение приходит вызовом, в который система передаёт объект аудиосессии. Приложение этот объект выбрасывало и просто взводило флаг «звук включён». Флага мало: в ручном режиме WebRTC ведёт собственный учёт того, активна ли сессия, и запускает звуковой блок только после того, как ему эту сессию отдадут явно.

Не отдали — значит блок не стартовал. Не стартовал — значит тишина. При полностью исправном, установленном, работающем соединении.

Мелодия, которую убивал сам звонок

Починили звук — пропала мелодия ожидания. Точнее, играла ровно две секунды и обрывалась.

Две секунды — подозрительно круглая величина. Столько проходит от начала звонка до момента, когда CallKit активирует аудиосессию. То есть мелодию убивало то же самое событие, которое чинило звук: запуск звукового блока WebRTC перенастраивает сессию, и проигрывание обрывается.

А до этого была ещё и обратная ошибка: мелодия заводилась раньше, чем WebRTC настраивал сессию, и он глушил её в первую же долю секунды. Проигрыватель при этом не жалуется. Формально всё играет — просто в выключенную сессию.

Теперь порядок обратный, а пока ответа нет, мелодия заводится заново поверх запущенного звукового блока. Когда разговор начинается, маршрут звука возвращается к тому, что выбрал пользователь.

Общая причина

Все три починки — про одно: разговор не должен быть свойством соединения.

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

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

Теперь разговор привязан к учётной записи, а не к сокету. Он переживает переподключение, честно заканчивается и не оставляет после себя призраков.

Что помогло найти

До сегодняшнего дня сервер писал в лог только про фильтр, жалобы и провалившиеся уведомления. Весь путь звонка проходил молча: по логам нельзя было отличить «вызов ушёл в сокет» от «вызов удержан и отправлено уведомление».

Первым делом появились нормальные логи — строка на событие, поля ключ=значение. И маршрут звонка сразу стало видно целиком:

call.held      от=9f90b0 кому=f21d33 токен=…ae5b7a
call.push.ok   кому=f21d33
call.delivered от=9f90b0 кому=f21d33 ждал_с=2
call.reject    от=f21d33 кому=9f90b0 причина=занят

Последняя строка — тот самый «занято от собственного собеседника». Без логов её пришлось бы вылавливать по описанию симптома, то есть угадывать.

Участники в логе обозначены обезличенными метками, которые меняются при каждом перезапуске сервера. Маршрут по ним читается так же, как по никам, а сложить из накопленных логов историю «кто с кем общался» нельзя. Для приложения, которое называет себя анонимным, это не мелочь: журнал не должен становиться тем, чего в приложении нет.

И заодно

В тот же день Кукарек перестал ходить открытым текстом. Раньше переписка и сигналинг шли по незашифрованному соединению на голый IP-адрес, а в настройках приложения ради этого висело исключение, снимавшее защиту со всех соединений сразу. Теперь у проекта есть домен и сертификат, а исключение убрано.

Голос был защищён и раньше — ключи только у собеседников, сервер их не видит. Но договориться о звонке можно было подслушать. Больше нельзя.