Summary
Once the last PeerConnection in the process is destroyed, PlatformAudio stops working: the next PlatformAudio::new() logs -1 recording devices, -1 playout devices, and recording_devices() never returns.
Cause
The media engine is reference-counted per PeerConnection (ConnectionContext::MediaEngineReference): WebRtcVoiceEngine::Init() runs when the first PeerConnection is created, and WebRtcVoiceEngine::Terminate() when the last one is destroyed, which calls adm()->Terminate().
AdmProxy::Init() is a no-op (the synthetic and platform ADMs are initialized in the constructor), but AdmProxy::Terminate() forwards to both sub-ADMs. After one Terminate/Init cycle the platform ADM stays uninitialized: RecordingDevices() / PlayoutDevices() return -1, and PlatformAudio::recording_device_count() casts that i16 to usize, so recording_devices() walks 2^64 - 1 indices at 100% CPU. The synthetic ADM loses its playout queue the same way.
Where the Terminate comes from (macOS, lldb, worker thread):
PeerConnection::~PeerConnection
-> ConnectionContext::MediaEngineReference::~MediaEngineReference
-> WebRtcVoiceEngine::Terminate
-> AdmProxy::Terminate
-> AudioDeviceModuleImpl::Terminate
-> AudioDeviceMac::Terminate
When it is reachable
Whenever closed PeerConnections are actually released and no other PeerConnection is alive, for example:
PlatformAudio::new() and publish a track created from audio.rtc_source()
room.close() the only room and drop it
- Use
PlatformAudio again: recording_devices(), or connect a new room and publish the microphone -> hangs
We hit it in a fork that carries #1405, which lets the room's graph be released after close. With #1436 / #1435 it also follows server-ended rooms. It stays hidden while any PeerConnection is kept alive (a leaked one, or a second room connected in the same process).
What we did
AdmProxy::Terminate() no longer terminates the sub-ADMs; they live as long as the proxy and are still terminated in ~AdmProxy. The voice engine already stops playout and recording and unregisters its callback before it calls Terminate(), so there is nothing left to stop there. Re-initializing them in Init() would also work, but would have to restore device selection and the playout/recording mode state.
PlatformAudio treats a negative device count as zero instead of casting it.
- Regression test: connect, publish a
PlatformAudio track, close the last room and wait until it is dropped; the recording-device count must be unchanged and the next room must publish the microphone again (before the fix: 5 devices, then 0).
Environment: macOS 26 on Apple Silicon, libwebrtc webrtc-51ef663 (m144), livekit-server 1.13.6 and LiveKit Cloud.
Summary
Once the last
PeerConnectionin the process is destroyed,PlatformAudiostops working: the nextPlatformAudio::new()logs-1 recording devices, -1 playout devices, andrecording_devices()never returns.Cause
The media engine is reference-counted per PeerConnection (
ConnectionContext::MediaEngineReference):WebRtcVoiceEngine::Init()runs when the first PeerConnection is created, andWebRtcVoiceEngine::Terminate()when the last one is destroyed, which callsadm()->Terminate().AdmProxy::Init()is a no-op (the synthetic and platform ADMs are initialized in the constructor), butAdmProxy::Terminate()forwards to both sub-ADMs. After one Terminate/Init cycle the platform ADM stays uninitialized:RecordingDevices()/PlayoutDevices()return -1, andPlatformAudio::recording_device_count()casts thati16tousize, sorecording_devices()walks 2^64 - 1 indices at 100% CPU. The synthetic ADM loses its playout queue the same way.Where the Terminate comes from (macOS, lldb, worker thread):
When it is reachable
Whenever closed PeerConnections are actually released and no other PeerConnection is alive, for example:
PlatformAudio::new()and publish a track created fromaudio.rtc_source()room.close()the only room and drop itPlatformAudioagain:recording_devices(), or connect a new room and publish the microphone -> hangsWe hit it in a fork that carries #1405, which lets the room's graph be released after close. With #1436 / #1435 it also follows server-ended rooms. It stays hidden while any PeerConnection is kept alive (a leaked one, or a second room connected in the same process).
What we did
AdmProxy::Terminate()no longer terminates the sub-ADMs; they live as long as the proxy and are still terminated in~AdmProxy. The voice engine already stops playout and recording and unregisters its callback before it callsTerminate(), so there is nothing left to stop there. Re-initializing them inInit()would also work, but would have to restore device selection and the playout/recording mode state.PlatformAudiotreats a negative device count as zero instead of casting it.PlatformAudiotrack, close the last room and wait until it is dropped; the recording-device count must be unchanged and the next room must publish the microphone again (before the fix: 5 devices, then 0).Environment: macOS 26 on Apple Silicon, libwebrtc
webrtc-51ef663(m144), livekit-server 1.13.6 and LiveKit Cloud.