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_adm → set_adm_recording_enabled → set_adm_playout_enabled back-to-back) → lk_runtime.rs → peer_connection_factory.rs → audio_device_controller.cpp → AdmProxy::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
- Please merge/release
dbce750a — it appears to directly fix this crash.
- If it's intentionally held back, context would help us decide whether to build against that branch meanwhile.
- 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.
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 awebrtc::MutexLock. A mutex doesn't confer JNI thread attachment. When one of these lands on an unattached thread, ART hard-aborts:The crashing thread (
Thread-58in our logs) is a plain default-named Java thread, notsignaling_thread/worker_thread/network_thread— its whole native stack is insideliblivekit_ffi.so, ending inCallBooleanMethodV. This fires shortly afterPlatformAudioconstruction, 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
AdmProxyaccepts and stores aworker_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: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 intoplatform_adm_->SomeMethod(...), which does raw JNI (e.g.CallBooleanMethodVintoWebRtcAudioRecord/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, viaworker_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()(firesacquire_platform_adm→set_adm_recording_enabled→set_adm_playout_enabledback-to-back) →lk_runtime.rs→peer_connection_factory.rs→audio_device_controller.cpp→AdmProxy::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 awebrtc::Environment, not aJNIEnv*);init_android_context()runs once on Unity's main thread before any of this and is unrelated (confirmed by testing — widening a startup delay beforeRoom.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 branchorigin/hiroshi/adm-proxy-worker-thread(2026-07-02):This wraps all ~38 public methods to force execution onto
worker_thread_.dbce750ais not an ancestor of the vendored ref267ebb84— 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 suggestingAdmProxy's Android path is under active hardening on branches not yet released.Ask
dbce750a— it appears to directly fix this crash.RunOnWorkerlands, worth confirmingworker_thread_is itself guaranteed JNI-attached —webrtc-sys/src/webrtc.cpp:61-69doesn't attachnetwork_thread_/worker_thread_/signaling_thread_at creation, unlike the explicitAttachCurrentThreadIfNeeded()convention inandroid.cpp.Repro notes
PlatformAudio(ADM) path, not the plain UnityMicrophonefallback.Room.Connect()(tried 250ms, 750ms) — consistent with a thread-affinity bug, not a startup race.