Как Кукарек научился звонить
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-адрес, а в настройках приложения ради этого висело исключение, снимавшее защиту со всех соединений сразу. Теперь у проекта есть домен и сертификат, а исключение убрано.
Голос был защищён и раньше — ключи только у собеседников, сервер их не видит. Но договориться о звонке можно было подслушать. Больше нельзя.