Перейти к содержимому

Разработка7 мин чтения

Замирающее видео с iPhone: как мы нашли use-after-scope в webrtc-kmp

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

ID Caller — наше приложение для приватных аудио- и видеозвонков без номера телефона, написанное на Kotlin и Compose Multiplatform поверх webrtc-kmp. В июле 2026 года на полевых тестах с настоящими телефонами исходящее видео с iPhone стало замирать через несколько секунд после начала звонка. Не в каждом звонке: часть проходила нормально, и всё выглядело как капризы сети или энкодера.

Настоящей причиной оказался хелпер из четырёх строк внутри библиотеки: он читал размеры форматов камеры из памяти, которая уже была освобождена. В нашей git-истории охота укладывается между диагностическим коммитом вечером 5 июля и фиксом в начале второго ночи 7 июля. Ниже — как это было, как выглядит баг и как мы поставляем пропатченный форк.

Симптом: видео, которое встаёт после сотни кадров

Полевой стенд — Android-телефон на adb и iPhone 14 Pro Max (iOS 18) на devicectl. Стенд сузил проблему до одного направления: замирало видео, которое отправлял iPhone.

Вместо догадок мы добавили в движок звонка пробу статистики: три снимка getStats() с интервалом в восемь секунд после перехода в Connected. Проба писала в лог только счётчики. Никаких адресов, никакого SDP, ничего, что идентифицирует звонок: держать такое вне логов — один из инвариантов проекта.

RtcCallEngine.ktkotlin
private fun startStatsProbe() {
    statsJob?.cancel()
    statsJob = scope.launch {
        repeat(STATS_PROBES) {
            kotlinx.coroutines.delay(STATS_PROBE_INTERVAL_MS)
            val report = pc.getStats()?.toString() ?: return@launch
            val counterNames = "framesEncoded|framesDecoded|qualityLimitationReason|" +
                "availableOutgoingBitrate|nackCount|retransmittedPacketsSent"
            // ...
            idcLog("[idc-stats] ${counters.ifEmpty { "no-video-counters" }} tx=$tx")
        }
    }
}

Цифры были странными. Энкодер iOS останавливался примерно на 110–170 закодированных кадрах, хотя канал выглядел здоровым: availableOutgoingBitrate около 3,3 Мбит/с, камера и локальное превью живы, трансиверы в SendRecv. Переключение камеры снова проталкивало кадры. Свой код мы исключили экспериментом: с выключенными setParameters и нашим тикером адаптации качества фриз всё равно воспроизводился.

Обходные пути, которые не сработали

Первым подозреваемым стал аппаратный энкодер H.264, и мы подняли VP8 на первое место в строке m=video. Тупик: как выяснилось гораздо позже, на iOS эта правка вообще не действовала.

Вторая зацепка пришла из более подробного дампа статистики media-source: камера снимала в 3840×2160. На 4K-кадрах delay-based оценка пропускной способности падала примерно до 28 кбит/с при нулевых потерях, а аппаратный энкодер замирал секунд через пять. «Нестабильность» на деле была рулеткой форматов захвата.

Тогда мы запросили 1280×720@30 в constraints getUserMedia. На iOS их проигнорировали: media-source остался в 4K. Принудительный даунскейл через нативный RTCVideoSource.adaptOutputFormat дал два крэша подряд во время negotiation (invalid page и SIGBUS), а позже — SIGSEGV после повторного оффера, так что мы его убрали. С exact-constraints стало ещё хуже: захват упал до нуля кадров.

К позднему вечеру вывод был ясен: формат нужно правильно выбирать там, где его выбирают, — в капчере камеры внутри библиотеки. ADR-009, написанный при выборе webrtc-kmp, называл куда более крупный запасной путь: нативные WebRTC SDK на каждой платформе, около двух лишних недель. Форк был шагом поменьше, а поскольку наш интерфейс CallEngine — единственная граница между логикой звонков и webrtc-kmp, выше него ничего менять не пришлось.

Внутри форка: один патч, три версии

Около полудня следующего дня у нас был форк webrtc-kmp 0.125.11, и мы читали iOS-версию CameraVideoCapturerController. Он перебирает форматы устройства и выбирает тот, чьи ширина и высота ближе всего к цели, — по умолчанию 1280×720@30. Заодно мы научили его писать свой выбор одной строкой лога: [idc-capturer] selected %dx%d fps=%.0f.

Версия 1. Цикл пропускал каждый формат, у которого флаг multiCamSupported не совпадал с флагом устройства. Мы решили, что на iPhone с несколькими камерами это выбрасывает обычные форматы, и убрали фильтр. Следующий звонок выбрал 4032×3024 при цели 720p — формат, который от 720p ещё дальше, чем 4K.

Версия 2. Мы добавили жёсткий кап: пропускать всё, что шире или выше 1920 пикселей. Результат — ноль кадров. Кап отбросил все форматы камеры iPhone.

Это противоречие стало поворотным. Фильтр не может отбросить все форматы настоящей камеры, если только сравниваемые числа не ненастоящие. Обе версии брали размеры из одной и той же строки:

CameraVideoCapturerController.iosArm64.ktkotlin
val dimensions =
    CMVideoFormatDescriptionGetDimensions(format.formatDescription).copyContents()

Версия 3 читала размеры по-другому. Следующий полевой звонок записал в лог selected 1280x720 fps=30, а framesEncoded перевалил за 705 без фриза — против прежней точки остановки в 110–170.

Анатомия бага: указатель, переживший свой скоуп

Вот хелпер из iOS-утилит библиотеки:

Cintetop.ktkotlin
@OptIn(ExperimentalForeignApi::class)
internal inline fun <reified T : CStructVar> CValue<T>.copyContents(): T {
    lateinit var value: T
    this.useContents { value = this }
    return value
}

CMVideoFormatDescriptionGetDimensions — C-функция, которая возвращает структуру CMVideoDimensions по значению. Kotlin/Native отдаёт её как CValue<CMVideoDimensions> — непрозрачное значение, которое нельзя прочитать напрямую. Чтобы добраться до полей, вызывают useContents, а стандартная библиотека реализует его как memScoped { placeTo(memScope).pointed.block() }: скопировать структуру во временную арену, передать в лямбду типизированное представление этой памяти, освободить арену, когда лямбда вернётся.

Ключевое слово — представление. T : CStructVar выглядит как обычный Kotlin-объект со свойствами width и height, но на деле это типизированный указатель в нативную память. value = this копирует указатель, а не байты. Как только useContents возвращается, copyContents отдаёт представление памяти, которая больше никому не принадлежит, и любое последующее чтение dimensions.width — неопределённое поведение.

Этот один факт объясняет всю сагу. Освобождённая память часто хранит старые значения, пока её что-нибудь не переиспользует, поэтому в части звонков размеры были верными и видео работало. В остальных там был мусор: «ближайший к 720p» формат оказывался по сути случайным, наши 720p-constraints помочь не могли, а кап 1920 отбрасывал всё.

Примечание

В комментариях патча и в upstream-issue мы написали «память стека». Строго говоря, в текущем Kotlin/Native memScoped берёт память из нативной кучи и освобождает её в конце блока, так что формально это use-after-free. Эффект тот же.

Фикс читает поля внутри useContents и возвращает обычные Kotlin-значения:

webrtc-kmp-0.125.11-idc2.patchdiff
         for (format in formats) {
             format as? AVCaptureDeviceFormat ?: continue
-            if (format.multiCamSupported != AVCaptureMultiCamSession.multiCamSupported) continue
-
-            val dimensions =
-                CMVideoFormatDescriptionGetDimensions(format.formatDescription).copyContents()
+            // IDC patch (0.125.11-idc1): multiCam-фильтр отбрасывал все обычные
+            // форматы на устройствах с multiCamSupported=true — выживали только
+            // 4K, WebRTC-энкодер замирал. RTCCameraVideoCapturer работает с
+            // обычной AVCaptureSession, фильтр не нужен.
+
+            // IDC patch v3: размеры читаются внутри useContents (copyContents
+            // отдавал мусор со стека — см. startCapture).
+            val (formatWidth, formatHeight) =
+                CMVideoFormatDescriptionGetDimensions(format.formatDescription)
+                    .useContents { width to height }
+            // IDC patch: full-sensor форматы (>1920) телефонному звонку не
+            // нужны и замораживают HW-энкодер — отбрасываем жёстко.
+            if (formatWidth > 1920 || formatHeight > 1920) continue
             val pixelFormat = CMFormatDescriptionGetMediaSubType(format.formatDescription)
-            val diff = abs(targetWidth - dimensions.width) + abs(targetHeight - dimensions.height)
+            val diff = abs(targetWidth - formatWidth) + abs(targetHeight - formatHeight)

Та же правка сделана в startCapture, где размеры выбранного формата попадают в настройки трека. Правило, которое мы вынесли: ничто из того, что дают useContents или memScoped, — ни receiver, ни указатель — не должно покидать лямбду. Возвращайте Int, Pair или data class.

Как мы поставляем пропатченный форк

Форк полезен, только если каждая сборка резолвит его одинаково: Android Studio, Xcode, релизная сборка на другой машине. Вот на чём мы остановились.

  1. Патч лежит в репозитории — mobile/patches/webrtc-kmp-0.125.11-idc2.patch, диф против апстрим-тега v0.125.11. Кроме фикса в нём сборочные правки под наш тулчейн: Kotlin 2.3, без sample-модуля, deployment target, который принимает Xcode 27.
  2. У форка своя версия: 0.125.11-idc1, позже 0.125.11-idc2 — в сентябре она ещё включила multitasking camera access для системного Picture-in-Picture. Отдельный суффикс гарантирует, что Gradle никогда молча не подтянет вместо неё апстрим-артефакт.
  3. Собранные артефакты закоммичены в mobile/local-maven. Сначала мы использовали mavenLocal(), но он резолвится через user.home: Xcode-сборкам понадобился костыль -Duser.home, а в sandbox-IDE релизная сборка падала с «Could not find webrtc-kmp:0.125.11-idc1». Закоммиченные артефакты занимают 1,8 МБ, потому что бинарей WebRTC в них нет: на iOS они приходят через Swift Package Manager.
settings.gradle.ktskotlin
dependencyResolutionManagement {
    repositories {
        // Форк webrtc-kmp 0.125.11-idc2 (...): артефакты закоммичены в
        // mobile/local-maven (1.8 МБ) — mavenLocal зависел от user.home и
        // терялся в Xcode/sandbox-IDE.
        // Обновление форка: рецепт в mobile/patches/webrtc-kmp-0.125.11-idc2.patch
        // (Kotlin 2.3 + Xcode 27) → publishToMavenLocal → скопировать
        // ~/.m2/…/com/shepeliev сюда.
        maven(url = File(settingsDir, "local-maven").toURI()) {
            content { includeGroup("com.shepeliev") }
        }
        google()
        mavenCentral()
        // ...
    }
}

Фильтр content важен: из локальной папки резолвится только группа com.shepeliev, и ничего постороннего через неё не проскочит.

И наконец, апстрим. 7 июля мы завели issue #192 с диагнозом, фиксом и предложением прислать pull request. На момент написания оно всё ещё открыто, а в нашем файле статуса проекта висит постоянный пункт: снять форк, как только фикс появится в апстриме.

Выводы

  • Измеряйте оба конца, прежде чем менять код. framesEncoded встал, а availableOutgoingBitrate остался высоким — значит, дело в отправителе; один дамп media-source показал 4K.
  • Логируйте решения, которые библиотека принимает за вас. Одна строка [idc-capturer] превратила «камера ведёт себя странно» в «селектор выбирает невозможный формат». Мы сознательно оставили её в релизных сборках.
  • Если фикс даёт невозможный результат, подозревайте входные данные, а не логику.
  • В interop Kotlin/Native ничто из useContents или memScoped не покидает блок. Поищите в своём коде и зависимостях паттерны вроде useContents { x = this }.
  • Проверяйте, что обходной путь действительно сработал. В конце сентября, разбирая другой фриз, мы обнаружили, что наше предпочтение VP8 на iOS не работало никогда: webrtc-kmp создаёт RTCPeerConnectionFactory() без аргументов, и в оффере iPhone был только H.264.
  • У диагностики тоже есть жизненный цикл. Наша проба статистики пережила pc.close() и уронила Android-телефон с SIGSEGV в нативном getStats; фикс — отменять её до закрытия соединения.
  • Поставляйте форк как релиз: патч в репозитории, отдельная версия, локальный Maven-каталог в репозитории с content-фильтром, issue в апстриме и записанное условие, при котором форк снимается.
ПоделитьсяXLinkedIn

ONLYAPPS Team

Нови-Сад, Сербия · onlyapps.org

Контакты

Аналитические cookie мы используем только с вашего согласия. Необходимые cookie обеспечивают работу сайта. Политика конфиденциальности