Skip to content

fix: keep audio device change handling from aborting or corrupting state - #337

Merged
devopvoid merged 7 commits into
mainfrom
fix/device-listener-followups
Oct 9, 2026
Merged

devopvoid merged 7 commits into
mainfrom
fix/device-listener-followups

Conversation

@devopvoid

@devopvoid devopvoid commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Follow-ups to #333 (by @Yml-0, already merged as 1bbef2a), which made the macOS CoreAudio listener and the Windows IMMNotificationClient callbacks catch what a device refresh throws instead of letting it end the process. This branch was built on #333's original commit and has main merged back in; its diff against main is the follow-ups alone, so any merge method works, a squash included.

Follow-ups

  • One unreadable audio device no longer ends the refresh (macOS, Windows). With only the catch at the boundary, a device that went away mid-refresh still cut onDevicesChanged() short, so later new devices were not added and removed devices stayed listed with no event. createAudioDevice() now skips such a device. checkDeviceGone() compared the removed device with a newly read default (never equal, and reading a vanished device throws); it now uses the default the manager holds. The device-ID buffer is a std::vector. On Windows, a device whose transport or form factor cannot be read keeps the unknown ones rather than failing the enumeration. The default-device getters return null when there is no such device, on both platforms; before, macOS would have dereferenced a null device and Windows threw.
  • DeviceChangeListener never throws, and clears what a Java listener throws. It used to leave the Java exception pending on the OS-owned thread, for the next listener or the next event to make JNI calls with.
  • The device listener list is locked. It was changed on Java threads while the OS callback walked it. Listeners are called on a copy, outside the lock.
  • JavaContext::removeNativeRef freed a global reference twice on every listener removal (track listeners and device change listeners), and its registry had no lock. Found while testing the item above; -Xcheck:jni aborts on it.
  • Track listeners are found by their track and kind, not by the listener object alone. A listener added to two tracks and removed from the second had the first track's observer removed instead: freed while that track still called it, while the second track went on delivering events. One object registered as both a mute and an ended listener had the wrong kind removed. Registry entries now carry their owner (the native track) and kind; device change listeners keep the defaults. A disposed track also takes its remaining listeners off before it is released, so stale entries can no longer match a later track at the same address, and their Java listeners are released.

Testing

Windows x64: mvn -pl webrtc-jni,webrtc verify builds, and the webrtc suite passes (237 tests, 0 failures, 2 skipped), also under -Pjni-check with no fatal JNI errors. New tests in AudioTrackTests add and remove track listeners, alone and from eight threads; before the JavaContext fix they aborted the JVM under -Pjni-check. Further tests cover one listener on two tracks, one object as both kinds of listener, and disposing a track with listeners; before the keying fix they crashed the JVM, and the test of both kinds failed on its own (no mute event arrived). A local check, not committed, enumerated devices, read the defaults (null for capture on a machine without a microphone) and added and removed device change listeners from eight threads under -Xcheck:jni.

The macOS changes have not been compiled locally; this PR's CI is their first build. Device hotplug cannot be triggered from a test, so the callback paths are covered by review only.

Yml-0 and others added 7 commits October 8, 2026 15:06
CoreAudioDeviceManager::deviceListenerProc and the IMMNotificationClient callbacks of
WindowsAudioDeviceManager run on threads the operating system owns, and call code that
throws jni::Exception when a device's properties cannot be read. A device that disappears
between the device list and that read is an ordinary event, and the exception had no
handler above it: std::terminate, and the JVM process aborted.

The callbacks now log what was thrown and return normally.

Fixes #332
…resh

The device change callbacks now catch what a refresh throws (#333), but on
macOS a throw still cut the refresh short. One device that went away between
reading the device list and reading its name ended onDevicesChanged() there:
the devices after it were not added, and neither list was checked for removed
devices, so a disconnected device stayed listed with no event for it. On a
Java thread the same throw failed MediaDevices.getAudioCaptureDevices() and
getAudioRenderDevices() as a whole.

createAudioDevice() now logs such a device and returns nullptr, which both
enumerations already skip. createDefaultAudioDevice() returns nullptr when
there is no default device instead of reading device 0, and the default
device getters return null to Java rather than dereferencing it.

checkDeviceGone() looked up the default device by reading it again from
CoreAudio, which fails when the default is the device that has just gone,
and threw after removing the device but before reporting it. The device it
read was a new object and so never equal to the removed one, which made the
update it guarded dead code. It now compares with the default the manager
holds, and clears it and takes the system's new default from the list when
the removed device was the default.

The device IDs are held in a vector, which a throw no longer leaks, and only
as many are read as CoreAudio returned on the second call.

On Windows, fillAdditionalTypes() threw when an endpoint could not be read,
which failed the whole enumeration for the transport and form factor of one
device. Such a device now keeps the unknown ones. The default device getters
threw when there is no device of the kind, as on a machine without a
microphone; they now return null there too, as they do on macOS.
DeviceChangeListener calls into Java on a thread the operating system owns,
and did not look for an exception afterwards. One thrown by a listener stayed
pending on that thread, so the next listener and the next device event made
their JNI calls with an exception pending, which JNI does not allow and which
-Xcheck:jni reports as fatal. The exception is now described and cleared.

Converting the device to Java can throw a C++ exception too, and on the
PulseAudio, udev and AVFoundation threads nothing above the listener catches
it. The listener now logs it and returns.
MediaDevices.addDeviceChangeListener() and removeDeviceChangeListener() change
a device manager's listener list on a Java thread, while the operating system
reports device changes on its own thread, which walks the list and erases
expired entries from it. Nothing synchronized the two. A listener could also
expire between the check for it and its lock(), which then returned null.

The list is now guarded by a mutex. The listeners are called on a copy taken
under it, outside the lock, so that one may remove itself from within its
callback.
…registry

JavaContext::removeNativeRef() made a JavaGlobalRef to compare with, deleted
it with DeleteGlobalRef() and then let its destructor delete it again. Every
removal of a track or device change listener freed a global reference twice:
-Xcheck:jni aborts on it, and with several threads the second delete can free
a reference another thread has just been given. The listener is now compared
with the reference the caller passed, so there is nothing to delete.

The registry of listeners was not synchronized either, while listeners are
added and removed on any Java thread. It now has a mutex; the class name of
the listener is looked up before it is taken.

AudioTrackTests adds and removes track listeners, alone and from eight
threads at once. Before the fix, under -Pjni-check, they aborted the JVM with
"Bad global or local ref passed to JNI".
… alone

The native registry of Java listeners found an entry by the listener object
only, and took the first match. A listener added to two tracks and removed
from the second had the first track's observer removed instead: it was freed
while the first track still called it, and the second track went on
delivering events to a listener that had been removed. The same happened
with one object registered as both a mute and an ended listener, where
removing it as one kind took the other.

An entry now also carries its owner, the native track, and its kind, and is
only found by all three. Device change listeners keep the defaults, as they
have no owner.

A disposed track now takes its remaining listeners off before it is
released. Their entries were left behind with the track's address, which a
later track can be given, and the Java listeners they hold were never
released.

AudioTrackTests covers a listener on two tracks, a listener of both kinds,
and disposing a track with listeners. Before the fix, running them crashed
the JVM, and the test of a listener of both kinds failed on its own: no mute
event arrived.
# Conflicts:
#	webrtc-jni/src/main/cpp/src/media/audio/macos/CoreAudioDeviceManager.cpp
@devopvoid
devopvoid merged commit 2b78158 into main Oct 9, 2026
22 checks passed
@devopvoid
devopvoid deleted the fix/device-listener-followups branch October 9, 2026 23:20
SendableMetatype added a commit to EduGeyser/webrtc-java that referenced this pull request Oct 10, 2026
Brings in the work upstream added on October 10: the missing peer
connection signaling and control calls (devopvoid#334), callback exceptions kept
from unwinding through WebRTC (devopvoid#335), first-packet observers, sender and
receiver IDs and track volume (devopvoid#338), video processors for camera and
desktop frames (devopvoid#342), and fixes to audio device changes, camera
hotplug and media stream views (devopvoid#333, devopvoid#337, devopvoid#341, devopvoid#343).

Fork-side adaptations:
- setAudioPlayout and setAudioRecording reach the voice engine, so the
  data channels variant leaves them out, like the other audio natives.
- The new tests that need audio or video carry the media tag.
- The Maven publish action stays deleted.
SendableMetatype added a commit to EduGeyser/webrtc-java that referenced this pull request Oct 10, 2026
Brings in the work upstream added on October 10: the missing peer
connection signaling and control calls (devopvoid#334), callback exceptions kept
from unwinding through WebRTC (devopvoid#335), first-packet observers, sender and
receiver IDs and track volume (devopvoid#338), video processors for camera and
desktop frames (devopvoid#342), and fixes to audio device changes, camera
hotplug and media stream views (devopvoid#333, devopvoid#337, devopvoid#341, devopvoid#343).

Fork-side adaptations:
- setBitrate, setAudioPlayout and setAudioRecording reach the call and
  the voice engine, which a factory without a media engine does not
  create, so the data channels variant leaves them out, like the other
  media natives.
- The new tests that need audio or video carry the media tag.
- The Maven publish action stays deleted.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants