Skip to content

Engineering8 min read

Frozen iPhone video calls traced to a use-after-scope bug in webrtc-kmp

Outgoing iPhone video froze a few seconds into some calls. WebRTC stats, failed workarounds and three versions of a fork led us to a Kotlin/Native helper that read camera format sizes from freed memory.

ID Caller is our app for private audio and video calls without a phone number, built with Kotlin and Compose Multiplatform on top of webrtc-kmp. In July 2026, during field tests on real phones, outgoing video from the iPhone started freezing a few seconds into a call. Not every call: some ran fine, which made it look like a flaky network or a moody encoder.

The real cause was a four-line helper inside the library that read camera format sizes from memory that had already been freed. In our git history the hunt runs from a diagnostics commit on the evening of 5 July to the fix shortly after 1 a.m. on 7 July. Here is how it went, what the bug looks like, and how we ship a patched fork.

The symptom: video that stops after a hundred frames

Our field bench was an Android phone on adb and an iPhone 14 Pro Max (iOS 18) on devicectl. The bench narrowed the freeze down to one direction: video sent from the iPhone.

Instead of guessing, we added a stats probe to the call engine: three getStats() snapshots, eight seconds apart, after the connection reached Connected. It logged counters only. No addresses, no SDP, nothing that identifies a call: keeping that out of logs is one of the project's invariants.

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

The numbers were strange. The iOS encoder stopped at roughly 110–170 encoded frames while the channel looked healthy: availableOutgoingBitrate around 3.3 Mbps, camera and local preview alive, transceivers in SendRecv. A camera switch pushed frames through again. We ruled out our own code by experiment: with setParameters and our quality-adaptation ticker turned off, the freeze still reproduced.

Workarounds that did not hold

The first suspect was the hardware H.264 encoder, so we moved VP8 to the top of the m=video line. A dead end: as we learned much later, the change never even took effect on iOS.

The second clue came from a deeper dump of the media-source stats: the camera was capturing at 3840×2160. With 4K frames, the delay-based bandwidth estimate collapsed to about 28 kbps at zero packet loss, and the hardware encoder froze after about five seconds. The flakiness was really a roulette of capture formats.

So we asked for 1280×720@30 in the getUserMedia constraints. On iOS they were ignored: media-source stayed at 4K. Forcing a downscale through the native RTCVideoSource.adaptOutputFormat gave two crashes in a row during negotiation (an invalid page fault and a SIGBUS) and later a SIGSEGV after a re-offer, so we removed it. Exact constraints were worse still: capture dropped to zero frames.

By late evening the conclusion was clear: the format had to be chosen correctly where it is chosen, in the library's camera capturer. ADR-009, written when we picked webrtc-kmp, named a much bigger exit path: native WebRTC SDKs on each platform, about two extra weeks. A fork was a smaller step, and since our CallEngine interface is the only boundary between call logic and webrtc-kmp, nothing above it had to change.

Inside the fork: one patch, three versions

Around noon the next day we had a fork of webrtc-kmp 0.125.11 and were reading CameraVideoCapturerController for iOS. It walks the device's formats and picks the one whose width and height are closest to the target, 1280×720@30 by default. We also made it log its choice in one line: [idc-capturer] selected %dx%d fps=%.0f.

Version 1. The loop skipped every format whose multiCamSupported flag differed from the device's. We concluded that on multi-camera iPhones this threw away the ordinary formats, and removed the filter. The next call selected 4032×3024 for a 720p target, a format even further from 720p than 4K.

Version 2. We added a hard cap that skips anything wider or taller than 1920 pixels. The result was zero frames. The cap rejected every format of the iPhone camera.

That contradiction was the turning point. A filter cannot reject every format of a real camera unless the numbers it compares are not real. Both versions got their sizes from the same line:

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

Version 3 read the sizes differently. The next field call logged selected 1280x720 fps=30, and framesEncoded went past 705 with no freeze, against the old stall point of 110–170.

Anatomy of the bug: a pointer that outlived its scope

Here is the helper from the library's iOS utilities:

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 is a C function that returns a CMVideoDimensions struct by value. Kotlin/Native exposes it as CValue<CMVideoDimensions>, an opaque value you cannot read directly. To read its fields you call useContents, which the standard library implements as memScoped { placeTo(memScope).pointed.block() }: copy the struct into a temporary arena, pass a typed view of that memory to the lambda, free the arena when the lambda returns.

The key word is view. T : CStructVar looks like an ordinary Kotlin object with width and height properties, but it is a typed pointer into native memory. value = this copies the pointer, not the bytes. Once useContents returns, copyContents hands back a view of memory that no longer belongs to anyone, and every later read of dimensions.width is undefined behaviour.

That one fact explains the whole saga. Freed memory often keeps its old values until something reuses it, so on some calls the sizes were right and video worked. On others they were garbage: the "closest to 720p" format was effectively random, our 720p constraints could not help, and the 1920 cap rejected everything.

Note

In the patch comments and the upstream issue we wrote "stack memory". Strictly speaking, in current Kotlin/Native memScoped allocates from the native heap and frees it at the end of the block, so formally this is a use-after-free. The effect is the same.

The fix reads the fields inside useContents and returns plain Kotlin values:

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): the multiCam filter dropped all regular
+            // formats on devices with multiCamSupported=true, only 4K survived and
+            // the WebRTC encoder froze. RTCCameraVideoCapturer works with a plain
+            // AVCaptureSession, the filter is not needed.
+
+            // IDC patch v3: sizes are read inside useContents (copyContents
+            // returned garbage, see startCapture).
+            val (formatWidth, formatHeight) =
+                CMVideoFormatDescriptionGetDimensions(format.formatDescription)
+                    .useContents { width to height }
+            // IDC patch: full-sensor formats (>1920) are useless for a phone call
+            // and freeze the HW encoder, so we drop them outright.
+            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)

The same change goes into startCapture, where the chosen format's sizes end up in the track settings. The rule we took away: nothing useContents or memScoped gives you, receiver or pointer, may escape the lambda. Return Ints, a Pair or a data class.

Shipping a patched fork

A fork only helps if every build resolves it the same way: Android Studio, Xcode, a release build on another machine. This is what we settled on.

  1. The patch lives in the repo as mobile/patches/webrtc-kmp-0.125.11-idc2.patch, a diff against the upstream tag v0.125.11. Besides the fix it carries the build changes our toolchain needs: Kotlin 2.3, no sample module, a deployment target that Xcode 27 accepts.
  2. The fork has its own version: 0.125.11-idc1, later 0.125.11-idc2, which in September also enabled multitasking camera access for system Picture-in-Picture. A distinct suffix means Gradle can never quietly resolve the upstream artifact instead.
  3. The built artifacts are committed to mobile/local-maven. At first we used mavenLocal(), but it resolves through user.home: Xcode builds needed a -Duser.home workaround, and a sandboxed IDE failed a release build with "Could not find webrtc-kmp:0.125.11-idc1". The committed artifacts take 1.8 MB, because WebRTC binaries are not included; on iOS they arrive through Swift Package Manager.
settings.gradle.ktskotlin
dependencyResolutionManagement {
    repositories {
        // webrtc-kmp 0.125.11-idc2 fork (...): artifacts are committed to
        // mobile/local-maven (1.8 MB), because mavenLocal depended on user.home
        // and got lost in Xcode and sandboxed IDEs.
        // Updating the fork: recipe in mobile/patches/webrtc-kmp-0.125.11-idc2.patch
        // (Kotlin 2.3 + Xcode 27) → publishToMavenLocal → copy
        // ~/.m2/…/com/shepeliev here.
        maven(url = File(settingsDir, "local-maven").toURI()) {
            content { includeGroup("com.shepeliev") }
        }
        google()
        mavenCentral()
        // ...
    }
}

The content filter matters: only the com.shepeliev group resolves from the local folder, so nothing else can slip in through it.

Finally, upstream. On 7 July we filed issue #192 with the diagnosis, the fix and an offer to send a pull request. At the time of writing it is still open, and our project status file keeps a standing item: drop the fork once the upstream fix lands.

Takeaways

  • Measure both ends before changing code. framesEncoded stopping while availableOutgoingBitrate stayed high pointed at the sender; one media-source dump showed 4K.
  • Log the decisions a library makes for you. The single [idc-capturer] line turned "the camera behaves oddly" into "the selector picks an impossible format". We kept it in release builds on purpose.
  • When a fix produces an impossible result, suspect the input data, not the logic.
  • In Kotlin/Native interop, nothing from useContents or memScoped leaves the block. Search your code and dependencies for patterns like useContents { x = this }.
  • Check that a workaround actually took effect. In late September, chasing a different freeze, we found our VP8 preference had never worked on iOS: webrtc-kmp creates RTCPeerConnectionFactory() without arguments, so the iPhone offered only H.264.
  • Diagnostics have a lifecycle too. Our stats probe outlived pc.close() and crashed an Android phone with a SIGSEGV in native getStats; the fix was to cancel it before closing the connection.
  • Ship a fork like a release: a patch in the repo, a distinct version, a repo-local Maven folder with a content filter, an upstream issue and a written condition for removing the fork.
ShareXLinkedIn

ONLYAPPS Team

Novi Sad, Serbia · onlyapps.org

Contact

We use analytics cookies only with your consent. Essential cookies keep the site working. Privacy policy