Технический детектив о последовательной очереди, бесконечной валидации и баге, который оказался сразу несколькими.
Первый симптом выглядел почти комично.
Пользователь один раз нажимал кнопку отправки — и в чате появлялось несколько одинаковых сообщений. Иногда два-три. Иногда уже такой залп, после которого проще было закрыть приложение и подождать, пока оно успокоится.
Почти одновременно начинал мерцать интерфейс. Контекстное меню над сообщением открывалось и закрывалось само. Голосовые уходили с огромной задержкой. При удалении клиент мог зависнуть ровно между первой кнопкой «Удалить» и вторым выбором — удалить у себя или у всех.
Через несколько минут телефон заметно нагревался.
Самое неприятное было не в количестве симптомов, а в их бессвязности. Отправка, меню, удаление, голосовые, нагрев — слишком много разных систем ломались одновременно. Такие баги заставляют подозревать всё подряд: жесты, сеть, базу данных, анимации, старую iOS и, ближе к утру, уже фазу Луны.
Но у нас было важное преимущество. На том же iPhone официальный Telegram работал нормально. Чистый Swiftgram, от которого произошёл наш форк, — тоже. На устройствах с более новой iOS проблема не проявлялась.
Значит, виновник находился где-то среди наших собственных изменений.
Как выяснилось позже, виновник был не один.
Невинная функция
В DarkGram есть локальное сохранение удалённых сообщений. Идея простая: если собеседник удаляет уже доставленное сообщение, клиент не выбрасывает локальную копию, а помечает её удалённой и продолжает показывать пользователю.
Первые варианты реализации были хрупкими. Сообщение удалялось из основного хранилища Telegram, после чего возвращалось в историю искусственно. Из-за этого мерцала лента, ломалась анимация удаления, терялось медиа, а иногда из нескольких сохранённых записей оставалась только последняя.
Поэтому архитектуру развернули в другую сторону:
-
не удалять сообщение из Postbox — основного локального хранилища клиента;
-
добавить к нему специальный атрибут;
-
отрисовать его как удалённое, сохранив исходный текст и медиа;
-
параллельно держать архивные данные для отдельного браузера истории.
С точки зрения интерфейса это было намного надёжнее. Сообщение оставалось там, где родилось, и не требовало сложной повторной инъекции в историю.
Но у решения появилась цена, которую поначалу никто не видел.
Функция сохранения удалённых сообщений оказалась встроена сразу в несколько горячих путей клиента. И часть работы выполнялась там, где любая задержка распространялась почти на всё приложение.
Место преступления: Postbox
Postbox в Telegram-iOS — не просто файл локальной базы. Через него проходит огромная часть состояния клиента: сообщения, история, представления чатов, транзакции, локальные изменения и часть жизненного цикла отправки.
Для этой истории важнее всего одно: значительная часть работы идёт через последовательную очередь.
Это удобно для консистентности. Две операции не начинают одновременно менять одни и те же данные, а порядок событий остаётся предсказуемым.
Но последовательная очередь похожа на однополосный тоннель. Пока внутри стоит грузовик, за ним ждут все — даже те, кому нужно быстро проехать налегке.
В нашем случае в пробке оказались:
-
открытие чата;
-
построение контекстного меню;
-
чтение локального состояния сообщения;
-
обработка изменений исходящей отправки;
-
обновление истории;
-
часть сценария удаления.
Поэтому внешне разные функции начали ломаться одновременно. Они не были связаны напрямую. Они просто стояли в одной очереди.
Оставалось понять, кто поставил грузовик поперёк тоннеля.
Первый подозреваемый: быстрый доступ
Самым заметным кандидатом был Quick Access — наш слой быстрых жестов и оверлеев.
Он устанавливал глобальный жест, слушал изменения настроек, создавал дополнительные окна для статусных элементов и мог обновлять интерфейс поверх основного клиента. На старой iOS такой набор выглядел достаточно подозрительно.
Мы собрали версию, в которой Quick Access полностью отключался ещё на этапе установки.
Цирк продолжился.
После дополнительного ограничения шумных observer-вызовов телефон стал вести себя чуть спокойнее, но сообщения всё ещё размножались, меню мерцало, а удаление зависало.
Самый зрелищный модуль оказался почти ни при чём.
Второй подозреваемый: SQLite
Следующая гипотеза выглядела гораздо убедительнее.
Помимо сохранения сообщения внутри Postbox, архив записывал снимки в SQLite. Вызов находился рядом с транзакцией обработки сообщений. Если запись на диск выполнялась синхронно, она могла удерживать последовательную очередь.
Картина складывалась почти идеально:
архив пишет SQLite ↓очередь Postbox задерживается ↓исходящее сообщение дольше остаётся pending ↓интерфейс ждёт, телефон греется,растёт окно для повторной обработки
Запись в архив перенесли на отдельную последовательную очередь.
Устройство действительно стало холоднее.
Но основной баг остался.
Это был худший вид ложной победы: гипотеза оказалась не выдумкой, изменение реально помогло, однако корень проблемы находился глубже. Мы сняли один тяжёлый слой и на несколько часов получили ощущение, что почти добрались до причины.
На деле расследование только началось.
Баг №1: сообщение, которое невозможно провалидировать
Первый настоящий механизм обнаружился в HistoryViewStateValidation.
Сохранённое удалённое сообщение продолжало жить локально, но сервер уже не возвращал его как обычную актуальную запись. Для системы валидации оно выглядело устаревшим или неподтверждённым.
В штатном клиенте такая запись в конце концов была бы удалена или заменена серверными данными. Но наш защитный код специально не позволял удалить её: иначе функция сохранения потеряла бы смысл.
Получалась замкнутая система:
обновилось представление истории ↓удалённое сообщение попало в validation batch ↓клиент запросил состояние у сервера ↓сервер не вернул удалённое сообщение ↓наш guard сохранил локальную копию ↓следующее обновление истории ↓тот же validation batch
Сообщение не могло ни успешно провалидироваться, ни исчезнуть. Оно снова и снова запускало одну и ту же работу.
Исправление оказалось почти оскорбительно маленьким: если сообщение помечено как локально сохранённое удалённое, для этого механизма считать его валидным сразу.
По размеру diff — несколько строк.
По последствиям — остановка бесконечного цикла запросов, обновлений представления и транзакций.
Количество изменённого кода в очередной раз ничего не сказало о сложности бага.
Баг №2: архив, который снимал вообще всё
Следующая находка была менее тонкой, но гораздо более прожорливой.
Вызов darkgramArchiveMessage(state: .active) стоял после добавления каждого сообщения.
Не каждого удалённого сообщения.
Каждого.
Он срабатывал при:
-
получении нового сообщения;
-
собственной отправке;
-
загрузке истории;
-
синхронизации;
-
добавлении сообщений в любом чате.
Для каждой записи архив мог обходить медиаграф, подготавливать ссылки на ресурсы, запускать сохранение и создавать снимок состояния.
Во время аудита выяснилось, что снимки .active практически не имели потребителей. История редакций использовала .edited, браузер удалённых сообщений — .deleted, а рабочего сценария, оправдывающего поток активных снимков, не нашлось.
То есть клиент выполнял тяжёлую архивную работу на каждом сообщении ради данных, которые потом почти никто не читал.
Вызов удалили.
Иногда оптимизация — это не более быстрый алгоритм. Иногда лучший алгоритм — перестать делать работу, которая вообще не нужна.
Баг №3: слишком заботливое сохранение медиа
Сам SQLite к этому моменту уже работал в фоне. Но рядом оставалась ещё одна операция: preserveResources.
Она обходила медиа сообщения, собирала ссылки на ресурсы и могла запускать их загрузку. С точки зрения архива это логично: бесполезно сохранить запись об удалённой фотографии, если сам файл позднее исчезнет из локального кэша.
Проблема была во времени и месте.
Обход медиаграфа и eager fetch происходили рядом с критическим путём обработки сообщений. На аккаунте с тяжёлой историей или большим количеством медиа такая забота превращалась в серьёзную нагрузку.
Работу перенесли на отдельную serial resource queue.
Семантика сохранилась: ресурсы всё ещё можно было удерживать для архива. Но Postbox больше не обязан был ждать, пока архив разберётся со всеми фотографиями, видео и файлами.
Баг №4: полный скан истории ради одного системного события
Самая дорогая находка скрывалась в обработке .UpdateMinAvailableMessage.
Это обновление связано с продвижением минимально доступного сообщения — например, когда сервер обрезает старую часть истории канала.
Наша модификация пыталась быть максимально бережной. Перед штатным удалением старых сообщений она проходила по всей локальной истории пира, архивировала записи и сохраняла ресурсы.
Выглядело благородно: даже то, что сервер решил убрать, останется у пользователя.
Практически это означало полный withAllMessages(peerId:) внутри одной транзакции.
На крупном канале клиент мог сканировать огромный массив сообщений, создавать архивные записи и запускать сохранение ресурсов, пока остальное приложение ждало освобождения той же последовательной очереди.
Здесь пришлось принять неприятное, но взрослое решение: вернуть upstream-поведение.
Старая server-trimmed история больше не сохранялась этим способом. Зато клиент перестал замерзать ради редкого граничного сценария.
Это один из тех компромиссов, которые плохо выглядят в списке возможностей, но хорошо — в работающем продукте.
Полнота архива не имеет смысла, если из-за неё невозможно нормально отправить сообщение.
Четыре бага под одним костюмом
После разгрузки тестер повторил сценарии на проблемном устройстве.
Исчезли общие зависания, мерцание контекстного меню, пауза внутри удаления, сильный нагрев и ощущение, что любое действие клиента ждёт несколько секунд.
Пользователь видел один большой хаотичный баг. Внутри это были четыре независимых механизма, складывавшие нагрузку в одну очередь:
-
бесконечная валидация сохранённого сообщения;
-
архивирование каждого активного сообщения;
-
тяжёлая работа с медиа в горячем пути;
-
полный скан истории при редком серверном обновлении.
Поэтому каждое раннее исправление помогало — и ни одно не завершало расследование.
Последний фокус: редкие дубли остались
После основной операции клиент снова стал отзывчивым. Но иногда один тап всё ещё приводил к нескольким копиям сообщения.
Это была важная улика: тормоза и сетевые дубли оказались связаны, но не были одной и той же ошибкой.
Долгая блокировка Postbox растягивала время, в течение которого исходящее сообщение оставалось в промежуточном состоянии. Чем дольше живёт pending-операция, тем выше шанс пересечения с перезапуском приложения, восстановлением соединения или повторной обработкой очереди отправки.
У дублей, по данным расследования, совпадал random_id. Поэтому версия «мы просто добавили одно сообщение в очередь несколько раз» объясняла картину плохо.
Аудит указал на более узкую границу: сообщение могло уже получить признак серверного подтверждения, но после восстановления pending-очереди снова рассматриваться как кандидат на отправку.
Для проверки добавили экспериментальный guard: не повторять отправку обычного облачного сообщения, если его OutgoingMessageInfoAttribute уже содержит acknowledged == true.
И здесь мы намеренно не объявили победу.
Защита от дубля, способная хотя бы в одном редком сценарии потерять сообщение, опаснее самого дубля. Поэтому guard оставили выключенным по умолчанию и вынесли в отдельную настройку для проверки на проблемном устройстве.
Эта часть расследования осталась рабочей гипотезой, а не таким же хорошо подтверждённым корнем, как перегрузка Postbox. Честное «здесь мы ещё не уверены» полезнее красивой причинности, придуманной задним числом.
Почему всё проявлялось на iOS 16
Самый соблазнительный ответ — найти один старый API, одну системную регрессию или один if #available, объясняющий всё.
Мы такого ответа не нашли.
Подтверждено только наблюдение:
-
на iPhone 14 Pro Max с iOS 16.0.2 проблема воспроизводилась стабильно;
-
на устройствах с iOS 18 тот же форк работал нормально;
-
официальный Telegram и чистый Swiftgram на проблемном устройстве не ломались.
Значит, старая iOS не была первопричиной. Наш код создавал неправильную нагрузку сам, а конкретная среда делала её заметнее — вероятно, из-за отличий в таймингах или производительности отдельных путей.
Точный механизм различия расследование не установило.
Самая честная формулировка: iOS 16 стала усилителем архитектурной ошибки, но не её автором.
Три вывода, которые остались после расследования
«Работает в фоне» ничего не значит без названия очереди
SQLite можно перенести на background queue и всё равно оставить половину тяжёлой работы в критическом пути. Смотреть нужно не на имя функции, а на весь граф действий до первого настоящего асинхронного разрыва.
Два разумных защитных механизма могут собрать бесконечный цикл
Мы не позволяли удалить сохранённое сообщение — и были правы в рамках своей функции. Соседняя система считала, что невосстановимую запись нужно валидировать снова. Локально правильные решения образовали глобально бессмысленную петлю.
Редкий edge case не должен брать основной продукт в заложники
Мы отказались от сохранения server-trimmed истории через полный скан. Это ухудшило один редкий сценарий и улучшило каждое обычное действие пользователя.
Функции пришлось стать менее жадной
Сохранение удалённых сообщений в DarkGram после этого не исчезло. Мы не вырезали его и не заменили заглушкой. Удалённые сообщения по-прежнему остаются локально и отображаются внутри истории.
Но функции пришлось стать менее жадной.
Она перестала архивировать всё подряд, перестала удерживать критическую очередь ради медиа и отказалась от попытки спасти абсолютно каждую серверно обрезанную запись.
Пожалуй, это и было настоящим исправлением: не заставить тяжёлую функцию работать любой ценой, а провести границу, после которой её амбиции начинают ломать основной продукт.
Иногда лучший способ сохранить историю — перестать мешать мессенджеру жить в настоящем.
и вот вам вопрос: насколько далеко должна заходить локальная функция сохранения данных, если полная сохранность начинает конкурировать со стабильностью основного приложения?
Наши соц сети:
-
Телеграмм канал: https://t.me/darkgraam
-
Телеграмм чат: https://t.me/jailbreak_iPhone_iPad_android
-
Тик ток: https://www.tiktok.com/@darkgram.project?_r=1&_t=ZS-98KSNqNgNUG
-
Twitter/X: https://x.com/darkgramdev?s=11
ссылка на оригинал статьи https://habr.com/ru/articles/1062948/