[Перевод] Как мы теряли игроков из-за того, что они платили за игру
Представьте, каково это — найти серьёзный баг в продакшене сразу после выпуска игры. Представьте, что этот баг вредит только платным пользователям. Представьте, что игра зависает сразу после того, как игроки завершают внутриигровую покупку. Представьте, что когда игрок перезапускает игру, она зависает при запуске. Представьте, что игроку так и не удаётся запустить игру и приходится её удалять. Представьте, что ваше приложение в этот момент находится в рекомендованных Apple Store. Эта статья — рассказ о таком баге, худшем из всех, что я видел за тридцать лет программирования. Это история о том, как мы его выявили и совместно с разработчиками Unity работали над его устранением.
В первые 24 часа после выпуска игры Adventure Chef: Merge Explorer для iOS мы стали замечать, что большое количество игроков сталкивается с зависаниями в момент запуска. Мы используем превосходную библиотеку и дэшборд стабильности приложений Bugsnag. В нём мы увидели несколько стеков вызовов, указывающих на пакет Unity In App Purchasing (IAP). Очевидно, из-за этой популярной библиотеки Unity наше приложение зависало на более чем две секунды, что заставляло операционную систему принудительно его закрывать. Казалось, что этот код Unity просто парсил чек iOS (небольшой фрагмент текста в памяти), чтобы определить, что купил игрок.
Из-за чего парсинг блока текста в памяти занимал у Unity IAP 4.1.1 больше двух секунд?
Ситуация требовала срочного решения. Я бы не назвал это состояние паникой, но в Slack были отправлены срочные сообщения. Мой менеджер впервые за шесть лет написал на мой личный телефон:
Привет, Рон, в Chef возникает ошибка IAP, затронувшая 10% пользователей (приложение зависает и пользователи ничего не могут с этим сделать). Недавно ты разбирался с проблемами в IAP, поэтому, вероятно, сможешь помочь. Посмотришь? Спасибо!
Я отвечал за низкоуровневую поддержку IAP в игре, поэтому больше всех остальных мог понимать, что происходит, и ощутил свою ответственность. Не могу назвать это паникой, но да, ситуация была стрессовой.
В первую очередь я просто попробовал разобраться в происходящем. Изначально мы думали, что проблема затронула 10% игроков в iOS. После более внимательного изучения мы поняли, что их было около 1,4%. Очень многие стеки вызовов (сообщающие о том, что делала программа на момент ошибки) различались. Многие из них показывали, что распределялась память. Была ли это какая-то проблема с памятью в C#?
GC_gcj_malloc
il2cpp::vm::Object::NewAllocSpecific(Il2CppClass*) (Object.cpp)
Asn1Node_CreateAndAddChildNode (UnityEngine.Purchasing.Security.cpp)
Asn1Node_ListDecodeIndefiniteLengthInternal(UnityEngine.Purchasing.Security.cpp)
Asn1Node_ListDecode(UnityEngine.Purchasing.Security.cpp)
Asn1Node_InternalLoadData(UnityEngine.Purchasing.Security.cpp)
Asn1Node_CreateAndAddChildNode (UnityEngine.Purchasing.Security.cpp)
Asn1Node_ListDecodeChildNodesWithKnownLength (UnityEngine.Purchasing.Security.cpp)
Asn1Node_LoadData (UnityEngine.Purchasing.Security.cpp)
Asn1Parser_LoadData (UnityEngine.Purchasing.Security.cpp)
AppleReceiptParser_Parse (UnityEngine.Purchasing.Security.cpp)
AppleReceiptParser_Parse (UnityEngine.Purchasing.Security.cpp)
AppleStoreImpl_getAppleReceiptFromBase64String (UnityEngine.Purchasing.Stores.cpp)
AppleStoreImpl_OnProductsRetrieved (UnityEngine.Purchasing.Stores.cpp)
Но все эти стеки вызовов находились в коде Unity IAP, который, судя по именам методов, парсил чеки Apple. По моей теории, мы столкнулись с каким-то бесконечным циклом, где выполнялся, но не завершался парсинг некой древовидной структуры, а различия в стеках вызовов просто показывали нам, где операционная система завершала приложение. Я собрал всю необходимую информацию и отправил в Unity баг-репорт с максимально высоким приоритетом.
В процессе обсуждения с коллегами кто-то заметил, что этот баг не происходил в предыдущем бета-релизе, в котором использовался Unity IAP 3.2.3. Мы обновили пакет, чтобы устранить ошибку «No Products Available», при которой игрок случайным образом иногда не мог выполнять внутриигровые покупки в Android, но, по крайней мере, он мог перезапустить игру и часто это помогало. Но поскольку баг с зависанием приложения в iOS был гораздо хуже, мы просто откатились к предыдущей версии Unity IAP. Итак, на этом этапе, мы, по крайней мере, частично решили проблему. Мы откатились с Unity IAP 4.1.1 до 3.2.3, сторонняя команда тестеров за ночь провела тестирование, после чего мы отправили исправленную версию Apple.
Так как у Pocket Gems был договор с Unity, мы получили первоклассное клиентское обслуживание. Служба поддержки связалась с нами в течение часа, а вскоре после этого о баге сообщили команде разработчиков IAP. Она сказала, что у неё тоже воспроизводится зависание. Очень быстрая работа!
После того, как кризис временно разрешился, а команда Unity IAP начала работать над проблемой, мне удалось завершить другие срочные задачи, пока приближались зимние каникулы.
Меня по-прежнему беспокоило то, что мы не знаем, как воспроизвести зависание. Команда Unity IAP сообщила, что ей удаётся воспроизвести зависание, но то ли это зависание, или какое-то другое? Как я смогу проверить их исправление, когда его выпустят? Возможно, нам придётся навсегда остаться с IAP 3.2.3.
Из-за пандемии и нежелания путешествовать у меня появилось свободное время на зимних каникулах. Мне было очень любопытно, удастся ли воспроизвести эту проблему. Я пытался понять, что делает код Unity IAP. Похоже было, что она создаёт древовидную структуру из чека Apple, являющегося текстовой строкой в кодировке base-64. Это дерево представляет собой кроссплатформенную двоичную структуру в формате ASN.1.
ASN.1 — это иерархическая структура контейнероподобных элементов и узлов-листьев с простыми атрибутами:
Критически важно было то, что если у тебя нет схемы или какого-то внешнего описания структуры данных, то блоб данных, например, строка октетов на изображении, может быть или дочерней структурой ASN.1, или просто узлом-листом, содержащим какую-то строку. Родительская структура не может сообщить, какой из двух вариантов верный! То есть для декодирования произвольного элемента ASN.1, можно лишь распарсить каждый элемент и посмотреть, что произойдёт. С точки зрения архитектуры и безопасности попытка декодировать произвольный двоичный блоб, чтобы посмотреть, является ли он чёткой структурой — это рискованный шаг, напомнивший мне о юном Робби Tables.
Возможно, именно из-за попытки распарсить произвольные байты Unity IAP сталкивается с проблемой? Спойлер: да, всё так и было.
Я решил создать автоматизированный юнит-тест, который будет выполнять только код парсинга чека Apple в пакете Unity IAP без необходимости отладки игры на устройстве с iOS. К сожалению, его код не включён при сборке для Player в Unity Editor, но мне удалось скопировать файлы C# из этой папки в корень моей локальной папки проекта Unity:
/Library/PackageCache/com.unity.purchasing@4.1.1/Runtime/Security/Asn1Processor/
Мы сохраняем анонимизированные чеки наших игроков в таблицу Google BigQuery, поэтому я создал запрос, ищущий небольшое подмножество чеков внутриигровых покупок, сделанных нашими игроками на iOS. Потом скачал данные как файл CSV и написал код парсинга. У меня было чуть больше трёхсот реальных чеков.
Смогу ли я воспроизвести зависание? Я подключил свой отладчик C# (из превосходной Rider IDE JetBrains) к Unity Editor и запустил свой новый юнит-тест. Затем пошагово выполнил код до вызова кода парсинга чека ASN1 в Unity. До следующей строки кода выполнение не доходило. Юнит-тест был запущен и привёл к зависанию. Самый первый чек позволил воспроизвести зависание! Я перезапустил код, пропустил первый чек. Второй чек распарсился без проблем. Как и третий с четвёртым. Я запустил выполнение юнит-теста без ограничений. Снова зависание.
Я скопировал те же файлы кода из Unity IAP 3.2.3, чтобы убедиться, что он распарсит эти триста чеков. И он справился без проблем.
Я был так счастлив! Мне удалось воспроизвести проблему! А теперь нужно заняться другими чеками.
В своём автоматизированном тесте я столкнулся с интересной проблемой — как протестировать тысячи чеков, зная, что некоторые из них вызовут бесконечный цикл, а мне нужно, чтобы тест завершился и выдал результаты?
Один из вариантов решения: назначить каждому чеку задачу, а затем использовать метод .NET WaitAll с параметром таймаута. Задачи выполняются в фоновых потоках пула потоков .NET, поэтому мой основной поток не будет заблокирован и сможет сообщить о результатах.
// создаём Task для парсинга каждого чека.
var receiptParsingTasks = new List();
foreach (string line in File.ReadLines(receiptsPath))
{
Task task = Task.Run(() => ParseReceipt(line));
receiptParsingTasks.Add(task);
}
// Парсинг каждого чека должен быть очень быстрым. Если прошло несколько секунд,
// значит, это бесконечный цикл.
bool completedOnTime = Task.WaitAll(receiptParsingTasks.ToArray(), 5000);
if (!completedOnTime)
{
int firstIncompletedIndex = receiptParsingTasks.FindIndex(
task => !task.IsCompleted);
Debug.LogError($"Line #{firstIncompletedIndex + 1} caused a freeze.");
}
Assert.IsTrue(completedOnTime);
Из 9163 чеков 2 вызвали вылет, 180 привели к зависанию, а 8981 распарсился правильно. Частота ошибок: 2,0% (= 182 / 9163).
Ожидая исправления ошибки от Unity, мы осознали, что нужно использовать одну версию Unity IAP для Android, и другую для iOS. Так мы сможем обойти два бага!
- Unity IAP 3.2.3 — используем для iOS. В нём есть баг «No Products Available», который встречается почти исключительно в Android, а самое важное, что он не содержит бага с зависанием приложения в iOS.
- Unity IAP 4.1.1 — используем для Android. Он устраняет ошибку «No Products Available» в Android, но приводит к багу зависания приложения в iOS (который затрагивает только iOS, но не Android).
Но как выбирать версию пакета в зависимости от платформы? У моего коллеги было изящное решение — можно выбирать его программно при загрузке проекта в Unity Editor! По умолчанию в Package Manager должен стоять Unity IAP 3.2.3, а наш сборщик должен выбирать Unity IAP 4.1.1 для Android.
[InitializeOnLoadMethod]
private static void LoadUnityIAPPackage()
{
// Чтобы не менять у всех локальные packages-lock.json и manifest.json,
// будем переключаться на версию для Android, если мы на Jenkins.
if (Application.isBatchMode &&
EditorUserBuildSettings.activeBuildTarget == BuildTarget.Android)
{
UnityEditor.PackageManager.Client.Add("com.unity.purchasing@4.1.1");
}
}
Я попросил у команды Unity IAP превью их решения проблемы, чтобы убедиться, что оно проходит мой автоматизированный тест. Они согласились, но, к сожалению, я его попробовал и оказалось, что оно не устраняет зависания.
Мне сложно было подтвердить, что мой способ воспроизведения зависания правилен. Может быть, я использую их код неверно? Я чувствовал растерянность при обсуждении тикета техподдержки. Поэтому в четверг вечером я попросил о встрече с программистами пакета. Они согласились и запланировали встречу на 10 часов утра. Вот так поддержка клиентов!
Они рассказали о том, что, по их мнению, является причиной зависания — оно вызывалось тем, что в Unity IAP 4.x улучшили поддержку имитации магазина в Player Unity Editor, обеспечив более глубокий парсинг подобных структур ASN.1. Они подтвердили, что с моими автоматизированными тестами всё в порядке.
Первопричина
Что значит «более глубокий парсинг»? Представьте, что вы видите в своём объекте ASN.1 следующую строку октетов:
5285A91861B12FC85E94CF4C6E521B094…
Структура ASN.1 не сообщает, как нужно интерпретировать эту строку октетов; это просто текстовое представление каких-то двоичных данных. По стандарту некоторые строки октетов в чеке Apple обозначают отдельный закодированный в формат ASN.1 объект. Какие строки? Apple указывает это, но, наверно, разбираться с этим сложно, поэтому проще попробовать раскодировать всё и посмотреть, что произойдёт!
У формата ASN.1 есть короткие метки, обозначающие тип структуры. Например, 0×3 обозначает строку битов, а 0×4 — строку октетов. Есть метки для контейнеров с множествами (0×11), последовательностями (0×10), и т. д. Поэтому несложно ошибочно идентифицировать произвольный байт как метку.
Команда Unity IAP усилила защиту своего кода от произвольных данных, которые могут приходить в том числе и от ненадёжных враждебных источников. Правильный парсинг сложных структур с неверным форматированием — сложная задача. Именно она приводит к необрабатываемым исключениям и бесконечным циклам.
Вскоре я получил второе превью исправления. Оно устраняло зависание! Однако оно вылетало на другом чеке. Я передал команде и этот чек.
Третья попытка выглядела неплохо, она смогла распарсить все 9163 чека!
Вскоре выпустили Unity IAP 4.1.3 с исправлениями. Наконец-то я мог избавиться от неуклюжего частичного решения.
Я испытал облегчение, увидев, что и стороннее тестирование, и автоматизированное тестирование с релизом Unity IAP 4.1.3 проходило успешно. Мы выпустили новую версию Adventure Chef, и всё выглядело отлично.
Я был счастлив, что смог помочь другим разработчикам игр на движке Unity, которые возможно и не знали, что часть их пользователей может столкнуться с такой проблемой. И я был рад, что помог нашему партнёру Unity.
Вот краткое описание в changelog Unity IAP 4.1.3; за этим невинно выглядящим предложением скрывается много работы и стресса!
«Исправлен пограничный случай, вызывавший сбой парсинга чеков Apple StoreKit, что не позволяло выполнить валидацию».