Preskoči na sadržaj

Inženjering8 min čitanja

Zamrznut video sa iPhone-a i use-after-scope bag u biblioteci webrtc-kmp

Odlazni video sa iPhone-a zamrzavao se posle nekoliko sekundi u nekim pozivima. WebRTC statistika, neuspela rešenja i tri verzije forka doveli su nas do helpera koji je čitao veličine kamere iz oslobođene memorije.

ID Caller je naša aplikacija za privatne audio i video pozive bez broja telefona, napisana u Kotlinu i Compose Multiplatformu nad bibliotekom webrtc-kmp. U julu 2026, tokom terenskih testova na pravim telefonima, odlazni video sa iPhone-a počeo je da se zamrzava nekoliko sekundi posle početka poziva. Ne u svakom pozivu: neki su prolazili bez problema, pa je sve ličilo na nestabilnu mrežu ili ćudljiv enkoder.

Pravi uzrok bio je helper od četiri linije unutar biblioteke koji je čitao veličine formata kamere iz memorije koja je već bila oslobođena. U našoj git istoriji potraga traje od dijagnostičkog commita uveče 5. jula do ispravke malo posle jedan sat posle ponoći 7. jula. U nastavku: kako je teklo, kako bag izgleda i kako isporučujemo zakrpljeni fork.

Simptom: video koji stane posle stotinak kadrova

Naš terenski stend činili su Android telefon na adb-u i iPhone 14 Pro Max (iOS 18) na devicectl-u. Stend je suzio problem na jedan smer: zamrzavao se video koji šalje iPhone.

Umesto nagađanja, dodali smo u engine za pozive probu statistike: tri snimka getStats() u razmaku od osam sekundi, nakon što veza pređe u Connected. Proba je u log upisivala samo brojače. Bez adresa, bez SDP-a, bez ičega što identifikuje poziv: da toga nema u logovima jedna je od invarijanti projekta.

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")
        }
    }
}

Brojke su bile čudne. iOS enkoder je stajao na otprilike 110–170 kodiranih kadrova, iako je kanal izgledao zdravo: availableOutgoingBitrate oko 3,3 Mbit/s, kamera i lokalni pregled živi, transiveri u SendRecv. Promena kamere bi ponovo progurala kadrove. Sopstveni kod smo isključili eksperimentom: sa isključenim setParameters i našim tikerom za adaptaciju kvaliteta, zamrzavanje se i dalje ponavljalo.

Zaobilazna rešenja koja nisu izdržala

Prvi osumnjičeni bio je hardverski H.264 enkoder, pa smo VP8 pomerili na prvo mesto u liniji m=video. Slepa ulica: kako smo saznali mnogo kasnije, na iOS-u ta izmena nikada nije ni proradila.

Drugi trag stigao je iz detaljnijeg dampa statistike media-source: kamera je snimala u 3840×2160. Sa 4K kadrovima, delay-based procena propusnog opsega padala je na oko 28 kbit/s pri nultom gubitku paketa, a hardverski enkoder se zamrzavao posle otprilike pet sekundi. „Nestabilnost” je zapravo bila rulet formata snimanja.

Zato smo u constraints za getUserMedia zatražili 1280×720@30. Na iOS-u su ignorisani: media-source je ostao na 4K. Prinudno smanjivanje kroz nativni RTCVideoSource.adaptOutputFormat dalo je dva pada zaredom tokom negotiation-a (invalid page i SIGBUS), a kasnije i SIGSEGV posle ponovljenog offera, pa smo ga uklonili. Sa exact constraints bilo je još gore: snimanje je palo na nula kadrova.

Do kasno uveče zaključak je bio jasan: format treba ispravno izabrati tamo gde se bira, u capturer-u kamere unutar biblioteke. ADR-009, napisan kada smo birali webrtc-kmp, navodio je mnogo veći izlazni put: nativne WebRTC SDK-ove na svakoj platformi, oko dve nedelje više posla. Fork je bio manji korak, a pošto je naš interfejs CallEngine jedina granica između logike poziva i webrtc-kmp, iznad njega ništa nije moralo da se menja.

Unutar forka: jedna zakrpa, tri verzije

Oko podneva sledećeg dana imali smo fork webrtc-kmp 0.125.11 i čitali iOS verziju klase CameraVideoCapturerController. Ona prolazi kroz formate uređaja i bira onaj čija su širina i visina najbliže cilju, podrazumevano 1280×720@30. Usput smo je naterali da svoj izbor upiše u jednu liniju loga: [idc-capturer] selected %dx%d fps=%.0f.

Verzija 1. Petlja je preskakala svaki format čiji se flag multiCamSupported razlikovao od flaga uređaja. Zaključili smo da to na iPhone-ima sa više kamera odbacuje obične formate i uklonili filter. Sledeći poziv izabrao je 4032×3024 za cilj od 720p, format još dalji od 720p nego 4K.

Verzija 2. Dodali smo tvrdo ograničenje koje preskače sve što je šire ili više od 1920 piksela. Rezultat: nula kadrova. Ograničenje je odbacilo sve formate kamere iPhone-a.

Ta kontradikcija bila je prekretnica. Filter ne može da odbaci sve formate prave kamere, osim ako brojevi koje poredi nisu pravi. Obe verzije uzimale su veličine iz iste linije:

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

Verzija 3 je čitala veličine drugačije. Sledeći terenski poziv upisao je u log selected 1280x720 fps=30, a framesEncoded je prešao 705 bez zamrzavanja, naspram stare tačke zastoja od 110–170.

Anatomija baga: pokazivač koji je nadživeo svoj opseg

Evo helpera iz iOS utilita biblioteke:

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 je C funkcija koja vraća strukturu CMVideoDimensions po vrednosti. Kotlin/Native je izlaže kao CValue<CMVideoDimensions>, neprozirnu vrednost koja se ne može čitati direktno. Da bi se došlo do polja, poziva se useContents, koji standardna biblioteka implementira kao memScoped { placeTo(memScope).pointed.block() }: kopira strukturu u privremenu arenu, lambdi prosledi tipizirani pogled na tu memoriju i oslobodi arenu kada se lambda završi.

Ključna reč je pogled. T : CStructVar izgleda kao običan Kotlin objekat sa svojstvima width i height, ali je u stvari tipizirani pokazivač u nativnu memoriju. value = this kopira pokazivač, a ne bajtove. Čim se useContents vrati, copyContents predaje pogled na memoriju koja više nikome ne pripada, i svako kasnije čitanje dimensions.width je nedefinisano ponašanje.

Ta jedna činjenica objašnjava celu sagu. Oslobođena memorija često zadržava stare vrednosti dok je nešto ponovo ne iskoristi, pa su u nekim pozivima veličine bile tačne i video je radio. U drugima je tu bilo smeće: „najbliži 720p” format bio je praktično nasumičan, naši 720p constraints nisu mogli da pomognu, a ograničenje od 1920 odbacivalo je sve.

Napomena

U komentarima zakrpe i u upstream issue-u napisali smo „memorija steka”. Strogo govoreći, u aktuelnom Kotlin/Native-u memScoped uzima memoriju iz nativnog heap-a i oslobađa je na kraju bloka, pa je formalno ovo use-after-free. Efekat je isti.

Ispravka čita polja unutar useContents i vraća obične Kotlin vrednosti:

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 filter je odbacivao sve obične
+            // formate na uređajima sa multiCamSupported=true, preživljavao je samo
+            // 4K i WebRTC enkoder se zamrzavao. RTCCameraVideoCapturer radi sa
+            // običnom AVCaptureSession, filter nije potreban.
+
+            // IDC patch v3: veličine se čitaju unutar useContents (copyContents
+            // je vraćao smeće, vidi startCapture).
+            val (formatWidth, formatHeight) =
+                CMVideoFormatDescriptionGetDimensions(format.formatDescription)
+                    .useContents { width to height }
+            // IDC patch: full-sensor formati (>1920) telefonskom pozivu nisu
+            // potrebni i zamrzavaju HW enkoder, pa ih odbacujemo bez izuzetka.
+            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)

Ista izmena primenjena je i u startCapture, gde veličine izabranog formata završavaju u podešavanjima video tracka. Pravilo koje smo izvukli: ništa što vam daju useContents ili memScoped, ni receiver ni pokazivač, ne sme da napusti lambdu. Vraćajte Int-ove, Pair ili data klasu.

Kako isporučujemo zakrpljeni fork

Fork pomaže samo ako ga svaki build razrešava na isti način: Android Studio, Xcode, release build na drugoj mašini. Evo na čemu smo se zaustavili.

  1. Zakrpa živi u repozitorijumu kao mobile/patches/webrtc-kmp-0.125.11-idc2.patch, diff naspram upstream taga v0.125.11. Pored ispravke nosi i izmene builda koje traži naš toolchain: Kotlin 2.3, bez sample modula, deployment target koji Xcode 27 prihvata.
  2. Fork ima sopstvenu verziju: 0.125.11-idc1, kasnije 0.125.11-idc2, koja je u septembru uključila i multitasking camera access za sistemski Picture-in-Picture. Poseban sufiks znači da Gradle nikada neće tiho povući upstream artefakt umesto njega.
  3. Izgrađeni artefakti su commitovani u mobile/local-maven. Na početku smo koristili mavenLocal(), ali on se razrešava preko user.home: Xcode buildovima je trebalo zaobilazno rešenje sa -Duser.home, a u sandbox IDE-u release build je padao sa „Could not find webrtc-kmp:0.125.11-idc1”. Commitovani artefakti zauzimaju 1,8 MB jer u njima nema WebRTC binarnih fajlova: na iOS-u oni stižu preko Swift Package Managera.
settings.gradle.ktskotlin
dependencyResolutionManagement {
    repositories {
        // Fork webrtc-kmp 0.125.11-idc2 (...): artefakti su commitovani u
        // mobile/local-maven (1.8 MB), jer je mavenLocal zavisio od user.home
        // i gubio se u Xcode-u i sandbox IDE-ovima.
        // Ažuriranje forka: recept u mobile/patches/webrtc-kmp-0.125.11-idc2.patch
        // (Kotlin 2.3 + Xcode 27) → publishToMavenLocal → kopirati
        // ~/.m2/…/com/shepeliev ovde.
        maven(url = File(settingsDir, "local-maven").toURI()) {
            content { includeGroup("com.shepeliev") }
        }
        google()
        mavenCentral()
        // ...
    }
}

Filter content je bitan: iz lokalnog foldera razrešava se samo grupa com.shepeliev, pa ništa drugo ne može da se provuče kroz njega.

Na kraju, upstream. Sedmog jula prijavili smo issue #192 sa dijagnozom, ispravkom i ponudom da pošaljemo pull request. U trenutku pisanja issue je i dalje otvoren, a u našem fajlu statusa projekta stoji trajna stavka: ukloniti fork čim ispravka stigne u upstream.

Zaključci

  • Merite oba kraja pre nego što menjate kod. framesEncoded je stao dok je availableOutgoingBitrate ostao visok, što je ukazalo na pošiljaoca; jedan damp media-source pokazao je 4K.
  • Logujte odluke koje biblioteka donosi umesto vas. Jedna linija [idc-capturer] pretvorila je „kamera se čudno ponaša” u „selektor bira nemoguć format”. Namerno smo je zadržali u release buildovima.
  • Kada ispravka da nemoguć rezultat, sumnjajte u ulazne podatke, a ne u logiku.
  • U Kotlin/Native interopu ništa iz useContents ili memScoped ne napušta blok. Potražite u svom kodu i zavisnostima obrasce poput useContents { x = this }.
  • Proverite da li je zaobilazno rešenje zaista proradilo. Krajem septembra, jureći drugo zamrzavanje, otkrili smo da naša prednost za VP8 na iOS-u nikada nije radila: webrtc-kmp kreira RTCPeerConnectionFactory() bez argumenata, pa je iPhone u offeru nudio samo H.264.
  • I dijagnostika ima životni ciklus. Naša proba statistike nadživela je pc.close() i srušila Android telefon sa SIGSEGV u nativnom getStats; ispravka je bila da se proba otkaže pre zatvaranja veze.
  • Isporučujte fork kao release: zakrpa u repozitorijumu, posebna verzija, lokalni Maven folder u repozitorijumu sa content filterom, issue u upstreamu i zapisan uslov pod kojim se fork uklanja.
PodeliXLinkedIn

ONLYAPPS Team

Novi Sad, Srbija · onlyapps.org

Kontakt

Kolačiće za analitiku koristimo samo uz vašu saglasnost. Neophodni kolačići održavaju rad sajta. Politika privatnosti