Разработка
Худший баг в истории — случайная потеря лучших игроков
Это история такого бага, худшего бага, с которым я когда-либо сталкивался за 30 лет программирования. Это история о том, как мы отследили проблему и поработали с Unity над ее исправлением.
Представьте, что вы обнаружили серьезную ошибку в рабочей версии сразу после выпуска игры. Представьте, что эта ошибка вредит только вашим платящим клиентам. Представьте, что игра зависает сразу после того, как игроки совершат покупку в приложении. Представьте, что после этого при перезапуске игра просто зависает. Представьте, что игрок никогда не сможет запустить ее снова и должен удалить игру. Представьте, что ваше приложение в настоящее время уже выпущено в Apple App Store. Это история такого бага, худшего бага, с которым я когда-либо сталкивался за 30 лет программирования. Это история о том, как мы отследили проблему и поработали с Unity над ее исправлением.
Боевая тревога
Через 24 часа после запуска Adventure Chef: Merge Explorer для iOS мы начали замечать, что большое количество наших игроков сталкивались с зависаниями во время запуска нашей игры. Мы используем отличную библиотеку мониторинга стабильности приложений и панель управления Bugsnag. Был набор вызовов, указывающих на пакет Unity для совершения покупок. Судя по всему, эта популярная библиотека Unity приводила к тому, что наше приложение не отвечало более двух секунд, из-за чего операционная система принудительно закрывала приложение. Оказалось, что этот код Unity просто анализировал чек iOS, небольшой текст в памяти, чтобы определить, что купил игрок.
Что может быть причиной того, что Unity IAP 4.1.1 тратит более двух секунд только для анализа фрагмента текста в памяти?
Возникло чувство крайней безотлагательности. Не назову это паникой, но в Slack рассылались срочные сообщения. Мой менеджер впервые за шесть лет написал мне на телефон:
Как инженер, ответственный за нашу низкоуровневую поддержку IAP в игре, я был самым осведомленным о происходящем и чувствовал тяжесть ответственности. Не назову это паникой, но да, это точно был стресс.
Сначала я просто пытался понять, что происходит. Изначально мы думали, что это затрагивает 10% наших игроков на iOS. После более тщательного изучения мы поняли, что это около 1.4%. Множество вызовов в стеках (что делала программа в момент ошибки) были разными. Многие из них указывали на выделение памяти. Возможно это какая-то проблема с памятью в С#?
Но все эти стеки вызовов находились в коде Unity IAP, который, судя по именам методов, анализировал чек Apple. Моя теория заключалась в том, что мы наблюдаем бесконечный цикл, в котором какая-то древовидная структура анализируется, но не завершается, и большое разнообразие стеков вызовов просто показывает, где операционная система убила наше приложение. Я собрал соответствующую информацию и отправил в Unity отчет об ошибке с самым высоким приоритетом.
Обходной путь — возврат к лучшей ошибке
После разговора с товарищами по команде кто-то указал, что этой ошибки не было в предыдущей бета-версии нашей игры, в которой использовалась Unity IAP 3.2.3. Что ж, причина, по которой мы обновили игру, заключалась в том, чтобы исправить ошибку «Нет доступных продуктов», из-за которой игроку случайным образом запрещалось совершать покупки в приложении на Android, но он мог, по крайней мере, перезапустить игру и, скорее всего, совершить покупку. Эта новая ошибка «Зависание iOS-приложения» была невообразимо хуже, поэтому было легко принять решение и вернуться к более ранней версии Unity IAP. Итак, на тот момент у нас, по крайней мере, был обходной путь. Мы вернулись с Unity IAP 4.1.1 на 3.2.3, провели ночное внеочередное офшорное тестирование, а затем отправили исправленную версию в Apple.
Поскольку у Pocket Gems есть контракт с Unity, мы получаем первоочередное обслуживание клиентов. Служба поддержки ответила нам в течение часа, и вскоре после этого команда IAP была предупреждена. Затем они сказали, что могут воспроизвести фриз приложения. Вау, это было быстро!
Поскольку кризис временно разрешился, а команда Unity IAP работала над проблемой и, возможно, уже имела решение, я смог закончить другие неотложные задачи, пока мы отправлялись на зимние каникулы.
ASN.1 и глубокое погружение
Я все еще был обеспокоен тем, что мы не знали, как воспроизвести этот сбой. Команда Unity IAP сказала, что может воспроизвести фриз, но было ли это то же самое зависание? Как я могу проверить его в предстоящем исправлении? Мы могли застрять на IAP 3.2.3 навсегда.
Перед зимними каникулами у меня было свободное время из-за пандемии и нежелания путешествовать. Мне было очень любопытно, смогу ли я воспроизвести эту проблему. Я попытался понять, что делает код Unity IAP. Это выглядело так, как будто он строил древовидную структуру из квитанции Apple. Квитанция — текстовая строка в кодировке base-64, которая представляет двоичную структуру в формате ASN.1.
ASN.1 — иерархическая структура контейнероподобных элементов и листовых узлов с простыми атрибутами:
Важно отметить, что если у вас нет схемы или какого-либо внешнего описания расположения данных, то блок байтов, как в OCTET STRING выше, может быть либо дочерней структурой ASN.1, либо просто конечным узлом, содержащим какую-то строку. Родительская структура не может сказать вам конкретно об этом! Итак, чтобы декодировать произвольный объект ASN.1, вам просто нужно попытаться разобрать каждый элемент и посмотреть, что произойдет. С точки зрения архитектуры и безопасности попытка декодировать случайный двоичный объект, чтобы увидеть, является ли он четко определенной структурой, рискованна и напоминает мне о комиксе XKCD.
Из-за этого у Unity IAP возникли проблемы, из-за попытки разобрать случайные байты? Спойлер: да, из-за этого.
Воспроизведение
Я решил создать автоматический модульный тест, который мог бы запускать только код разбора квитанций Apple в пакете Unity IAP без всех накладных расходов на отладку нашей игры на iOS-устройстве. К сожалению, их код не включается при сборке для Unity Editor Player, но мне удалось скопировать файлы C# из этого каталога в корень моего локального каталога проекта Unity:
/Library/PackageCache/com.unity.purchasing@4.1.1/Runtime/Security/Asn1Processor/
Для хранения мы сохраняем анонимные квитанции наших игроков в таблице Google BigQuery, поэтому я выполнил запрос, просмотрев небольшое подмножество квитанций от покупок в приложении нашими реальными игроками на iOS. Я загрузил данные в виде CSV-файла и написал небольшой код для синтаксического анализа. У меня было чуть более 300 реальных квитанций.
Могу ли я воспроизвести зависание? Я подключил отладчик C# (в отличной среде разработки Rider от JetBrains) к редактору Unity и запустил новый модульный тест. Я дошел до вызова кода разбора ASN.1 квитанций от Unity. Следующая строка кода не выполнилась. Модульный тест был запущен. Заморожен. Самая первая квитанция воспроизвела сбой! Перезапуск. Пропустим первый чек. Вторая квитанция разобрана нормально. И третья и четвертая. Я позволил модульному тесту работать свободно. Еще один фриз.
Я скопировал те же файлы кода из Unity IAP 3.2.3, чтобы перепроверить, пройдет ли анализ этих 300 квитанций. Да, без проблем.
Я был так счастлив! Я смог воспроизвести проблему! Теперь нужно больше квитанций!
Я столкнулся с интересной проблемой в своем автоматическом тесте — как мне протестировать тысячи квитанций, зная, что некоторые из них вызовут бесконечный цикл, но я хочу, чтобы мой тест завершился и вывел результаты?
Один из способов заключался в том, чтобы назначить задачу для каждой квитанции, а затем использовать метод .NET WaitAll вместе с параметром тайм-аута. Задачи выполняются в фоновых потоках в пуле потоков .NET, поэтому ваш основной поток не будет заблокирован и сможет сообщать о результатах.
Из 9 163 квитанций 2 вызвали сбой, 180 — зависание, а 8 981 — проанализировались правильно. Частота ошибок: 2,0% (= 182/9163).
Лучший обходной путь
В ожидании исправления от Unity мы поняли, что нам нужно использовать одну версию Unity IAP для Android и другую версию для iOS. так мы обойдем две ошибки сразу!
- Unity IAP 3.2.3 — для iOS. У этой версии есть ошибка «Нет доступных продуктов», которая почти исключительно влияет на Android, но, что особенно важно, у нее нет ошибки «Зависание iOS-приложения».
- Unity IAP 4.1.1 — для Android. Эта версия исправляет ошибку «Нет доступных продуктов» для Android, но вводит ошибку «Зависание iOS-приложения» (которая затрагивает только iOS, а не Android).
Но как выбрать версию пакета в зависимости от платформы? Мой коллега знал об элегантном решении — его можно выбрать программно при загрузке проекта в редакторе Unity! По умолчанию в диспетчере пакетов будет Unity IAP 3.2.3, а наш сборщик выберет Unity IAP 4.1.1 для Android.
Назад и вперед для исправления
Я попросил команду Unity IAP предварительно просмотреть их исправление, чтобы еще раз убедиться, что оно прошло мой автоматический тест. Они согласились, но когда я опробовал их исправление, оно, к сожалению, не устранило зависание.
Я был неуверен, что мой способ воспроизвести заморозку действителен. Что, если я неправильно использовал их код? Я чувствовал замешательство в нашем общении со службой поддержки. Итак, в четверг днем я попросил о встрече с их программистами. Они согласились и назначили встречу на 10 утра следующего дня. Вау, вот это поддержка клиентов!
Они объяснили, что, по их мнению, происходит с зависанием — что это было вызвано тем, что Unity IAP 4.x улучшил поддержку фейкового хранилища в Unity Editor Player, выполняя более глубокий парсинг этих структур ASN.1. Они подтвердили, что мой автоматический тест действителен.
Основная причина
Что это значит «более глубокий парсинг»? Представьте, что вы видите эту строку октетов в своем объекте ASN.1:
5285A91861B12FC85E94CF4C6E521B094…
Структура ASN.1 не говорит, как следует интерпретировать эту строку октетов. Это просто текстовое представление некоторых двоичных данных. По соглашению некоторые строки октетов в чеке Apple представляют собой автономный кодированный объект ASN.1. Какие? Что ж, Apple определяет это, но я думаю, что это довольно сложно понять, и проще попытаться расшифровать и посмотреть, что произойдет! Живем только один раз!
Формат ASN.1 имеет короткие теги, указывающие на тип структуры. Например, 0x3 определяет строку битов, а 0x4 — строку октетов. Существуют теги для таких контейнеров, как наборы (0x11) и последовательности (0x10) и т. д. Таким образом, несложно ошибочно принять случайный байт за тег.
Команде Unity IAP пришлось защищать свой код от случайных данных, которые также могли быть получены из ненадежного враждебного источника, как в приведенном выше комиксе Little Bobby Tables. Трудно правильно разобрать сложные структуры, которые неправильно отформатированы. Именно это привело к необработанным исключениям и бесконечным циклам.
Во всяком случае, вскоре я получил вторую предварительную версию их исправления. Оно исправило это зависание! Но падало на другой квитанции. Я тоже дал им эту квитанцию.
3-я попытка — выглядит хорошо! Все 9,163 квитанций были правильно проанализированы!
Вскоре был выпущен Unity IAP 4.1.3 с исправлениями. Вау! Я смог избавиться от нашего обходного пути.
Релиз
Я был рад, что наше офшорное и автоматизированное тестирование прошли чистыми с выпуском Unity IAP 4.1.3. Мы выпустили новую версию Adventure Chef с использованием этого исправления, и все выглядело хорошо.
Я был рад помочь другим разработчикам игр на Unity, которые, возможно, даже не знали, что некоторые из их клиентов столкнулись с этой проблемой. И я был рад помочь нашему партнеру, Unity.
Вот сводка в журнале изменений Unity IAP 4.1.3. Много работы и нервов стояло за этой невинно звучащей фразой:
Исправлен пограничный случай, когда синтаксический анализ квитанций Apple StoreKit завершался сбоем, препятствуя проверке.