Привет! Меня зовут Александр Денисов, я из команды мобильного Яндекс.Браузера в Санкт-Петербурге. В этом посте расскажу вам, как мы справляемся с циклическими крешами на старте.


Каждый разработчик знает, насколько важна для пользователя надёжность продукта. В работе над стабильностью приложения могут помочь выстроенные процессы разработки и тестирования, продвинутые средства диагностики. Однако всё предусмотреть невозможно, особенно если ваш проект большой и сложный. И рано или поздно вы, скорее всего, столкнётесь с проблемой циклического креша на старте. Сейчас разберёмся, как можно обработать этот сценарий.


В качестве примера будет выступать приложение Яндекс.Браузер для iOS: более 100 тысяч исходных файлов, тысячи коммитов в год и около тысячи модулей без учёта ядра (Swift + Objective-C). Кстати, не так давно мы рассказывали, как помогли команде Swift ускорить отладчик.


Циклический креш на старте


Представьте, что в вашем приложении есть баг, приводящий к крешу. Несложно, правда? Причём возникает баг из-за редкого сочетания факторов, и происходит это на старте. С некоторой вероятностью баг останется незамеченным во время тестирования и попадёт в версию для App Store. А дальше пострадавшие пользователи столкнутся с приложением, которое крешится прямо на старте, и перезапуск уже не помогает — только переустановка.


Это очень неприятная ситуация, в которую попадали и мы: например, однажды мы раскатили на всю нашу аудиторию эксперимент, который закончился циклическим крешем для некоторых пользователей со старыми версиями приложения. Поскольку креш происходил прямо на старте, скачать обновлённые данные было уже невозможно. Помочь могла только переустановка или обновление в App Store.


Как бороться с циклическими крешами


Проблему циклических крешей Яндекс.Браузера мы решали в несколько подходов. Сначала просто показывали UIAlertController с предложением сбросить вкладки. Это было особенно актуально во времена UIWebView, когда приложение выступало веб-процессом, и баг в веб-подсистеме мог вызвать креш всего приложения. Легко было попасть в обидную ситуацию: открытие сайта, приводящего к падению UIWebView, не позволяло пользоваться приложением из-за постоянного креша на старте.


Затем мы реализовали более сложную подсистему, которая запускает минимальный набор компонент и позволяет сбросить кеши, настройки, вкладки и другие параметры по выбору пользователя. Мы назвали её Safe mode (или режим восстановления), и выглядит это примерно как на скриншоте в заголовке этой статьи.


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


После AB-тестирования и доводки мы внедрили новую систему в Браузер, серьёзно повысив его стабильность. Однако даже она оказалась бессильна перед проблемой, поразившей многие iOS-приложения по всему миру, включая наиболее популярные: речь идет о крешах в Facebook SDK. Напомню, в мае и июле 2020 года многие самые популярные приложения (и не только они) перестали запускаться из-за ошибки на старте, когда код Facebook SDK пытался обработать серверный ответ. И, конечно, наше приложение не было исключением. Почему же Safe mode не помог нам в этом случае?


Что делать с Facebook SDK


Проблема в том, что Facebook SDK начинал исполнять код ещё до вызова какого-либо метода своего публичного API. Objective-C без проблем позволяет делать такие (и другие) вещи с помощью метода +(void)load. Соответственно, у интегрирующего такую «невежливую» библиотеку приложения нет возможности защититься от выполнения кода, который никто явно не вызывал. Или есть?


Идея решения возникла, когда мы размышляли над постановкой задачи с технической стороны. Если мы не можем защититься от ошибок, происходящих сразу после загрузки кода в память процесса, стоит ли вообще загружать этот код? Проблемную часть кода можно не включать в основной исполняемый файл приложения, а положить рядом и подгружать (и выгружать, если бы такая функциональность была доступна на iOS) по мере необходимости. Этот механизм называется динамическим связыванием (линковкой), а подгружаемая часть кода — динамически подключаемой библиотекой. В формате Mach-O (исполняемые файлы на платформах Apple) динамические библиотеки обычно имеют расширение dylib.


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


Итак, для применения описанного подхода нам нужно:


  1. Уметь загружать код Facebook SDK по требованию — в этом нам как раз поможет dylib.
  2. Централизованно управлять отключением функциональности у всех пользователей. Если в вашем приложении ещё нет такой возможности — самое время её добавить!
  3. Предусмотреть защитный механизм, позволяющий получить новую настройку, даже если приложение падает на старте. В Браузере мы на сутки блокируем Facebook SDK, если сталкиваемся с циклическим крешем, чтобы приложение выжило и скачало обновлённый конфиг.

Рассмотрим последний пункт подробнее.


Как же определить, что произошёл креш, к тому же ещё и циклический? Если в вашем приложении используется крешлоггер, то задача уже практически решена: получаем данные от него, и остаётся только реализовать логику, отличающую случаи циклического креша на старте от обычных разовых крешей. Например, записываем на диск признак случившегося креша и сбрасываем его, если приложение было активно в течение, скажем, минуты. Так, даже если повторный креш случится позже, уже не считаем его циклическим.


Если же крешлоггера нет, можно воспользоваться эвристикой: при нормальном уходе в фон или завершении работы с делегатным вызовом applicationWillTerminate() (для SwiftUI используется UIApplicationDelegateAdaptor) записываем на диск признак, которого там не окажется при некорректном завершении работы (то есть креше).


Отмечу, что история с Facebook SDK не уникальна: подобного рода проблемы могут возникнуть с любым third-party кодом. При этом рассмотренный защитный механизм является универсальным и может быть применён во всех случаях, где на первый план выходит надёжность.


Динамическая библиотека


Теперь вернёмся к самому интересному — динамической библиотеке. В этой статье мы будем работать с сэмпл-проектами с использованием Swift Package Manager, так как в нашем проекте используется кастомная система сборки, основанная на GN и Ninja: если мы поделимся примерами кода «как есть», то применить их сможет весьма ограниченная аудитория разработчиков.


Сразу оговорюсь, что Swift Package Manager используется здесь для простоты: такой код легко собрать и запустить. Однако в SPM нет поддержки iOS. То есть добавить зависимость на Facebook SDK для iOS мы сможем, а вот собрать такой код — уже нет. Поэтому для использования в iOS-приложении всё же потребуется интеграция кода из примера в Xcode-проект: например, добавление дополнительного шага сборки, копирующего библиотеку в подкаталог Frameworks внутри бандла приложения. Если оставить Facebook SDK и особенности платформы iOS за скобками, то получим следующие примеры.


Пример 1. Простой интерфейс


Первый пример описывает случай, когда публичный API библиотеки можно представить в виде функций, использующих базовые типы Swift.


Структура проекта предельно проста: имеем независимые динамическую библиотеку и приложение, которое её загружает.


import PackageDescription

let package = Package(
    name: "DylibExample",
    products: [
        .library(name: "Dylib", type: .dynamic, targets: ["Dylib"]),
    ],
    targets: [
        .target(name: "Dylib", dependencies: []),
        .target(name: "HostApp", dependencies: []),
    ]
)

Для начала разберём код на стороне библиотеки:


import Foundation

@_cdecl("performSampleTask")
public func performSampleTask(
    _ name: String,
    _ options: [String: Any]?
) -> Bool {
    print("Performing task with name: \(name), options: \(options ?? [:])")
    return true
}

Обратите внимание на атрибут @_cdecl(), позволяющий экспортировать символы в C-нотации.


Далее — уже на стороне основного приложения — функциональность для поддержки dylib:


import Darwin

public final class DynamicLinkLibrary {
    public let handle: UnsafeMutableRawPointer

    public init?(path: String, mode: Int32 = RTLD_LAZY) {
        guard let handle = dlopen(path, mode) else { return nil }
        self.handle = handle
    }

    deinit {
        dlclose(handle)
    }

    public func load<T>(symbol: String) -> T? {
        dlsym(handle, symbol).map { unsafeBitCast($0, to: T.self) }
    }
}

Здесь мы инкапсулируем логику по загрузке и выгрузке (однако такая возможность не поддерживается на платформе iOS) библиотеки и разрешению символов (в нашем случае — функций). RTLD_LAZY используется для «ленивого» связывания символов по мере использования и является режимом по умолчанию. Альтернативой может быть RTLD_NOW, когда связывание для всех символов происходит прямо во время вызова dlopen.


На базе этого класса делаем обвязку под свою задачу:


import Foundation

final class DylibImpl {
    private let performSampleTaskSymName = "performSampleTask"
    private let dylib: DynamicLinkLibrary

    typealias PerformSampleTaskFunc = @convention(c) (
        _ name: String,
        _ options: [String: Any]?
    ) -> (Bool)

    private(set) lazy var performSampleTask: PerformSampleTaskFunc =
        self.dylib.load(symbol: performSampleTaskSymName)!

    init(path: String) {
        self.dylib = DynamicLinkLibrary(path: path)!
    }
}

Здесь потребуется указать имена символов и сигнатуры, чтобы корректно провести маппинг. Это придётся сделать для всех используемых функций и классов.


Ну и наконец самое приятное — собираем всё воедино и запускаем:


let dylib = DylibImpl(path: "libDylib.dylib")
let result = dylib.performSampleTask("ExampleTask", ["exampleKey": "exampleValue"])
print("Result: \(result)")

./HostApp
Performing task with name: ExampleTask, options: ["exampleKey": "exampleValue"]
Result: true

Пример 2. Публичный интерфейс, вынесенный отдельно


Этот пример описывает библиотеку с богатым публичным API, который следует вынести в отдельную библиотеку (она будет загружаться автоматически на старте приложения). Пример вдохновлён этой публикацией.


Для начала рассмотрим пакет публичного API:


import PackageDescription

let package = Package(
    name: "DylibInterface",
    products: [
        .library(name: "DylibInterface", type: .dynamic, targets: ["DylibInterface"]),
    ],
    targets: [
        .target(name: "DylibInterface", dependencies: []),
    ]
)

public struct DylibDTO {
    public let str: String
    public let int: Int

    public init(str: String, int: Int) {
        self.str = str
        self.int = int
    }
}

public protocol DylibInterface {
    func fetch() -> DylibDTO
}

// We use abstract class since `Unmanaged` only supports class type.
open class DylibInterfaceProvider {
    public init() {}

    open func provide() -> DylibInterface {
        preconditionFailure("Not implemented")
    }
}

Наличие общего интерфейса позволяет использовать существенно более богатый API, если сравнивать с предыдущим примером (я постарался проиллюстрировать это с помощью типа DylibDTO), при этом для доступа к типам не требуется ручной маппинг с использованием символов.


Теперь рассмотрим код на стороне библиотеки и основного приложения.


import PackageDescription

let package2 = Package(
    name: "DylibExample",
    products: [
        .library(name: "Dylib", type: .dynamic, targets: ["Dylib"]),
    ],
    dependencies: [
        .package(name: "DylibInterface", path: "../example_interface"),
    ],
    targets: [
        .target(name: "Dylib", dependencies: ["DylibInterface"]),
        .target(name: "HostApp", dependencies: ["DylibInterface"]),
    ]
)

Библиотека:


import DylibInterface

struct DylibImpl: DylibInterface {
    func fetch() -> DylibDTO {
        return DylibDTO(str: "Llorem Ipsum", int: 42)
    }
}

@_cdecl("getDylibProvider")
public func getDylibProvider() -> UnsafeMutableRawPointer {
    return Unmanaged.passRetained(DylibProviderImpl()).toOpaque()
}

final class DylibProviderImpl: DylibInterfaceProvider {
    override func provide() -> DylibInterface {
        DylibImpl()
    }
}

Описание библиотеки очень похоже на предыдущий пример и использует ту же реализацию DynamicLinkLibrary.swift (поэтому не будем приводить её повторно):


import DylibInterface
import Foundation

final class DylibImpl {
    private let getDylibProviderSymName = "getDylibProvider"
    private let dylib: DynamicLinkLibrary

    typealias getDylibProviderFunc = @convention(c) () -> UnsafeMutableRawPointer

    private(set) lazy var getDylibProvider: getDylibProviderFunc =
        self.dylib.load(symbol: getDylibProviderSymName)!

    func getInterface() -> DylibInterface {
        let provider = Unmanaged<DylibInterfaceProvider>.fromOpaque(getDylibProvider()).takeRetainedValue()
        return provider.provide()
    }

    init(path: String) {
        self.dylib = DynamicLinkLibrary(path: path)!
    }
}

Обратите внимание на работу с созданным объектом через Unmanaged: здесь нам потребуется использовать ручное управление памятью.


И теперь снова собираем всё воедино и запускаем:


let dylib = DylibImpl(path: "libDylib.dylib")
let result = dylib.getInterface().fetch()
print("Result: \(result)")

./HostApp
Result: DylibDTO(str: "Llorem Ipsum", int: 42)

Заключение


Итак, мы рассмотрели несколько подходов, которые помогают делать приложение надёжнее:


  1. Обнаружение циклических крешей.
  2. Режим восстановления, позволяющий отправить диагностическую информацию и экстренно очистить данные, которые могли привести к сбою.
  3. Вынесение потенциально опасного кода в динамически подключаемую библиотеку, с тем чтобы его можно было отключить при неблагоприятных условиях.

Мы на практике убедились, что такие подходы помогают значительно повысить стабильность приложения. Попробуйте и вы.

Комментарии (8)


  1. amarao
    28.10.2021 11:02
    +3

    А зачем вы использовали Facebook SDK? Какая от него польза фейсбуку?


    1. Firsto
      28.10.2021 11:37

      Аналитика скорее всего.


    1. mmmisha
      28.10.2021 14:56
      +2

      Тоже не люблю это сдк, жрет ресурсов и трафика больше чем приложение).
      Но если приложение хотят раскручивать через Facebook или Instagram - это сдк заставляют добавить в проект..


  1. fk01
    28.10.2021 14:12
    +1

    Интересная тема, к сожалению немного однобоко. Проблема на самом деле актуальна практически везде. Стратегия обработки ошибок как таковая часто попросту отметается. Считается что будет выпущен идеальный, безошибочный продукт. А если ошибки и будут, то они как нибудь будут исправлены... О сценариях со стороны потребителя обычно не задумываются. Хотя стоило бы подходить с этой стороны.

    Меж тем сколько-нибудь сложный программный продукт практически неизбежно во-первых содержит ошибки, во-вторых в процессе борьбы с ними начинает обладать примерно следующим набором характеристик:

    1. Ведение протокола работы (лога) в процессе работы, сохранение его где-либо, в частности сохранение в момент возникновения сбоя, и способность его загрузки с целевой платформы в случае расследования причин сбоев.

    2. Самостоятельная регистрация собственных ошибкок, в т.ч. фатальных, преведших к прекращению функционирования: в случае работы в обычной ОС это может быть отдельный процесс наблюдающий за работой основной программы, обработчики ряда "фатальных" сигналов, функций обработчиков исключительных ситуаций (как минимум, в частности abort(), чем часто заканчивается ошибка в C++ и asserts), запись трассировки стека.

    3. Введение т. н. "сторожевого таймера", периодически сбрасываемого в цикле нормальной работы ПО (обычно в каком-либо цикле обработки событий, например). Если цикл остановлен, в следствие взаимоблокировки (deadlock) или другой какой-либо проблемы по крайней мере ошибка может быть зафиксирована, получены трассировки стека. Хотя непосредственно взаимоблокировки вызванные неправильным использованием механизмов синхронизации (мьютексов) могут обнаруживаться другими способами сразу, без задержки.

    4. Автоматический перезапуск (хотя это критично преимущественно для embedded, где пользователя или нет, или запускать программы вручную он никак не может).

    5. Обнаружение циклических перезапусков и автоматический переход в какой-либо вариант "безопасного режима" с ограниченным функционалом (в котором, по крайней мере можно произвести действия по отправке отчёта об ошибке, например, и автоматическое обновление).

    6. Введение какой-либо политики по обработке ошибок: очевидно, что часть ошибок не являются фатальными для функционирования продукта в целом и вызывают лишь отказ в выполнении отдельных, может быть совершенно не критичных функций. Такие ошибки не должны приводить к аварийному выходу из программы или циклической перезагрузке (что в свою очердь приводит к полному отказу). Тем не менее такие ошибки должны как-то регистрироваться (иначе о них никто и никогда не узнает, кроме как из жалоб пользователя). Обычно ошибка возникает в какой-то подсистеме, затем "всплывает" на более верхние урови, при это трансформируется код или тип ошибки (т.к. разные уровни API оперируют разными кодами или типами ошибок). Важно, чтоб при каждой трансформации ошибка протоколировалась (иначе есть риск получить некий GENERAL_FAILURE на верхнем уровне, из которого ничего не понятно).

    7. Может быть -- исключение детерминизма в работе ПО, введение некого случайного фактора. Например, если программа состоит из скольки-то отдельных компонентов, они стартуют не в фиксированном, жёстко заданном порядке, а в случайном порядке. Идея в том, что некая определённая последовательность действий может вызывать каждый раз фатальный сбой (например взаимоблокировку, deadlock). И если каждый раз эта последовательность повторяется, то в принципе устранимый сбой превращается в циклическую перезагрузку и полный отказ. Фактор случайности позволяет обойти такие ошибки и уменьшить время простоя, исключить массовые жалобы пользователей.

    8. Какая-либо политика по использования assert'ов в проекте. В каждом проекте конечно своя специфика, но в общем случае: отключение assert'ов вообще достаточно плохая идея, т. к. легко обнаружимые и понятные ошибки превращаются в цепочку совершенно других сбоев в других подсистемах вызванных неправильным функционированием одной другой подсистемы. С другой стороны, программисты склонны вписывать в код достаточно бездумые проверочные условия, не приемлемые для "продуктового билда" (см. также пункт 6). Кроме того, использование в выражении assert'а функций с побочным эффектом -- распространённая ошибка (а asserts могут быть отключены). В общем случае asserts скорей не должны использоваться для обнаружения ошибок (нужно условия записывать в явном виде), и должны содержать лишь условия, нарушение которых означает полный отказ. Либо следует разделять как-то "продуктовые" и "отладочные" assert'ы...

    9. Возможно, принятие решение об автоматическом перезапуске по условиям отличным от возникновения ошибок. Вплоть до перезагрузки просто по расписанию. Или, например, исчерпание свободной оперативной памяти ниже некого заданного лимита. Хотя утечки памяти это конечно тоже ошибки. Но лучше получить перезагрузку в некоторое время когда с прибором или программой не работают, или перед началом цикла работы, чем в середине цикла, что для потребителя является явным сбоем с какими-то потерями.

    10. Стратегия обработки ошибок нехватки памяти (см. std::set_new_handler), позволяющая вместо сбоя прямо здесь и сейчас, как "обухом по голове" (std::bad_allocation не обрабатывается и уходит в abort()), высвободить какую-то память чтоб по крайней мере суметь выполнить какие-то действия по более безопасному или корректному прерыванию текущего цикла работы. Например может высвобождаться некий резервный массив памяти достаточный для по крайней мере корректной обработки возникших ошибок.


    1. Stonespb Автор
      28.10.2021 15:20
      +1

      Спасибо большое за важный и интересный комментарий! Вы совершенно правильно отметили, что работа над надёжностью приложения - задача комплексная, и в статье освещена только одна сторона проблемы.

      К перечисленным выше пунктам я бы хотел ещё добавить следующие:

      1. Сбор и анализ технических метрик. В первую очередь интересует прод, но также важно мониторить внутренние билды, чтобы вовремя заметить и исправить регрессию.

      2. Выстраивание системы тестирования, автоматизация. Здесь будет большой творческий процесс - как сделать максимально надёжно в условиях, когда проверить всё-всё для каждого билда может быть слишком дорого.

      3. Мониторинг ключевых показателей в режиме реального времени. Ведь зачастую от времени реакции зависит очень многое.

      4. Приёмка по результатам A/B-тестирования. Нужны объективные данные для принятия осознанного решения, инструменты анализа.

      Возможно получится осветить все эти темы в одной из следующих публикаций.


  1. Akr0n
    28.10.2021 14:57

    Подскажете, а Ваш браузер уже научился автоматом закрывать старые вкладки при долгой неактивности или перезапуске? Раньше они сотнями висели пока руками не закроешь, для неопытного пользователя это трудно понимаемо. А каждый поиск через табло всегда открывал +1 новую...


  1. HighFlyer
    29.10.2021 11:55

    Привет! Как в итоге работа с Facebook SDK сделана? Написали модуль-обёртку над ним?


    1. Stonespb Автор
      29.10.2021 14:13

      Да, обернули его аналогично тому, как описано в статье, и подключаем в виде dylib.