Skip to content

Android - JNI crash on low memory/performance devices #368

Description

@delphinius81

Summary

On Android, AdmProxy (webrtc-sys/src/adm_proxy.cpp) runs every public method — including JNI-touching Android ADM calls (StartRecording, InitPlayout, EnableBuiltInAEC, etc.) — directly on whatever thread the caller happens to be on, guarded only by a webrtc::MutexLock. A mutex doesn't confer JNI thread attachment. When one of these lands on an unattached thread, ART hard-aborts:

JNI DETECTED ERROR IN APPLICATION: a thread (tid 15411) is making JNI calls without being attached
    in call to CallBooleanMethodV

The crashing thread (Thread-58 in our logs) is a plain default-named Java thread, not signaling_thread/worker_thread/network_thread — its whole native stack is inside liblivekit_ffi.so, ending in CallBooleanMethodV. This fires shortly after PlatformAudio construction, and reproduces more readily on low-memory/low-performance Android devices (consistent with those devices being more likely to schedule the FFI call off the main thread under memory/GC pressure).

Root cause

AdmProxy accepts and stores a worker_thread_ (webrtc-sys/include/livekit/adm_proxy.h:66-69), but it's referenced exactly once in the whole file (the constructor's initializer list) and is otherwise dead for dispatch. The Android lazy-init path calls straight into JNI with no attach and no thread hop:

// webrtc-sys/src/adm_proxy.cpp:108-135
#if defined(__ANDROID__)
bool AdmProxy::EnsurePlatformAdmCreated() {
  if (platform_adm_) return true;
  platform_adm_ = webrtc::CreateAndroidAudioDeviceModule(
      env_, webrtc::AudioDeviceModule::kPlatformDefaultAudio);
  int32_t init_result = platform_adm_->Init();
  ...
}
#endif

Every other JNI-reaching method (StartRecording, StopRecording, InitPlayout, StartPlayout, SetPlayoutDevice/SetRecordingDevice, EnableBuiltInAEC/AGC/NS, etc.) follows the same pattern: webrtc::MutexLock lock(&mutex_); then straight into platform_adm_->SomeMethod(...), which does raw JNI (e.g. CallBooleanMethodV into WebRtcAudioRecord/WebRtcAudioTrack) on whatever OS thread called the public method.

By contrast, AdmProxy's construction is correctly hopped onto the worker thread (peer_connection_factory.cpp:63-66, via worker_thread()->BlockingCall(...)) — but nothing enforces that subsequent calls happen there too.

Call chain: cabi.rs::livekit_ffi_request() (sync entry point, runs on the caller's thread) → requests.rs::handle_request()platform_audio.rs::on_new_platform_audio()PlatformAudio::new() (fires acquire_platform_admset_adm_recording_enabledset_adm_playout_enabled back-to-back) → lk_runtime.rspeer_connection_factory.rsaudio_device_controller.cppAdmProxy::AcquirePlatformAdm()/EnsurePlatformAdmCreated() — no attach or thread-affinity anywhere in this chain.

Ruled out: no cached/stale JNIEnv* reuse anywhere in the submodule (AdmProxy::env_ is a webrtc::Environment, not a JNIEnv*); init_android_context() runs once on Unity's main thread before any of this and is unrelated (confirmed by testing — widening a startup delay before Room.Connect() from 250ms to 750ms had no effect, ruling out a startup-ordering race).

Known fix (unmerged)

This exact defect looks to already be fixed upstream, but the fix isn't in the tagged release we're using (unity 2.0 sdk release):

  • Commit dbce750a, "Make AdmProxy worker-thread-affine," on branch origin/hiroshi/adm-proxy-worker-thread (2026-07-02):

    Previously AdmProxy called the platform ADM directly on whatever thread the caller was on, guarded only by a mutex, which violates that contract and races with WebRTC's own worker-thread calls. AdmProxy now owns all of its state on the worker thread. Every public method marshals once at the boundary via RunOnWorker...

    This wraps all ~38 public methods to force execution onto worker_thread_.

  • dbce750a is not an ancestor of the vendored ref 267ebb84 — it predates that HEAD's commit date but lives only on the unmerged branch.

  • Corroborating precedent: livekit-ffi/CHANGELOG.md"Fix for raw stream drop called from non tokio thread like Unity .NET GC - #1016" (same bug class: FFI logic invoked from an unexpected thread). Also unmerged: 25864a7d (Android platform-ADM audio-callback backfill), f9576840/2d44908b (ADM teardown/lifecycle ordering fixes) — all suggesting AdmProxy's Android path is under active hardening on branches not yet released.

Ask

  1. Please merge/release dbce750a — it appears to directly fix this crash.
  2. If it's intentionally held back, context would help us decide whether to build against that branch meanwhile.
  3. Once RunOnWorker lands, worth confirming worker_thread_ is itself guaranteed JNI-attached — webrtc-sys/src/webrtc.cpp:61-69 doesn't attach network_thread_/worker_thread_/signaling_thread_ at creation, unlike the explicit AttachCurrentThreadIfNeeded() convention in android.cpp.

Repro notes

  • Only occurs on the PlatformAudio (ADM) path, not the plain Unity Microphone fallback.
  • More frequent on low-memory/low-performance Android devices.
  • Not fixed by adding/widening a fixed delay before Room.Connect() (tried 250ms, 750ms) — consistent with a thread-affinity bug, not a startup race.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions