Skip to content

fix: changeDevice() device selection and error reporting - #532

Merged
alnitak merged 2 commits into
alnitak:mainfrom
Colton127:claude/changedevice-correctness-5alroq
Aug 5, 2026
Merged

fix: changeDevice() device selection and error reporting#532
alnitak merged 2 commits into
alnitak:mainfrom
Colton127:claude/changedevice-correctness-5alroq

Conversation

@Colton127

Copy link
Copy Markdown
Contributor

Description

Fixes two correctness defects in SoLoud.changeDevice(), plus three related ones found in the same code while making those correct.

changeDevice() could never select the default device

SoLoud.instance.changeDevice() with no argument is documented to select the system default output device, but it always threw SoLoudNoPlaybackDevicesFoundCppException. Dart passes -1, and Player::changeDevice() compared that int against devices.size():

if (devices.size() == 0 || deviceID >= devices.size())
    return noPlaybackDevicesFound;

-1 is promoted to SIZE_MAX by the usual arithmetic conversions, so the guard always fired. The device list is now consulted only for explicit (non-negative) IDs, and -1 passes nullptr straight through to deviceConfig.playback.pDeviceID, which is miniaudio's default-device selector. This mirrors what Player::init() already did. IDs below -1 are rejected as invalidParameter before any conversion to an unsigned type.

A failed device change reported success

if (result != SoLoud::SO_NO_ERROR)
    result = backendNotInited;   // assigns to a local
return noError;                  // ...then returns success regardless

If miniaudio could not initialize or start the replacement device, the caller was told the switch worked. It now returns audioDeviceFailedToStart, which Dart already maps to SoLoudAudioDeviceFailedToStartCppException. backendNotInited would be misleading here: the engine can stay initialized while its output device could not be replaced.

Note this PR only makes the failure visible — it deliberately does not add rollback, retry, or fallback-to-default.

Also fixed in the same code

  • Use-after-free in Player::init(). With an explicit device ID, the device list was built inside an if block and playbackInfos_id pointed at one of its elements. The vector was destroyed at the end of that block, so soloud.init() handed miniaudio a pointer into freed heap. Reproduced under AddressSanitizer (details below).
  • init() had the same signed/unsigned comparison, reporting IDs below -1 as noPlaybackDevicesFound. Now invalidParameter, matching changeDevice().
  • listPlaybackDevices() leaked on every call. PlaybackDevice::name was a strdup()ed char * that nothing ever freed — one allocation per device per call, including the internal calls from init() and changeDevice(). It is now a std::string, which also lets the dangling pPlaybackInfos member become a local: it points at context-owned memory that ma_context_uninit() frees before the function returns.
  • Out-of-bounds read in the device-name filter. bindings.cpp scanned the first 5 bytes of each name for control characters before checking strlen(name) <= 5, over-reading the buffer for short names. The length check now runs first; filtering behaviour is unchanged.
  • Soloud::miniaudio_changeDevice() returned 0 when the audio thread mutex was null, reporting a successful device change when nothing had happened. It now returns UNKNOWN_ERROR (surfacing as audioDeviceFailedToStart), and NOT_IMPLEMENTED when built without the miniaudio backend.

Verification

The use-after-free and the leak were confirmed with a standalone AddressSanitizer harness built from the real plugin sources (miniaudio's null backend enumerates a device, so the explicit-device path is reachable without audio hardware).

Against the pre-fix code:

ERROR: AddressSanitizer: heap-use-after-free
READ of size 256 at 0x5120000001e8
  #1 ma_device_init            miniaudio.h:43662
  #4 SoLoud::miniaudio_init    soloud_miniaudio.cpp:540
  #5 SoLoud::Soloud::init      soloud.cpp:335
  #6 Player::init              src/player.cpp:273
freed by thread T0 here:
  #5 std::vector<PlaybackDevice>::~vector()
  #6 Player::init              src/player.cpp:269

...and 132 byte(s) leaked in 2 allocation(s), both strdup from Player::listPlaybackDevices(). The patched build is silent on both. Worth stressing that a passing run proves little on its own here — freed storage often still holds the right bytes, so the buggy version could return noError and play normally.

Tests

example/tests/tests/playback_devices.dart now covers the default selector, an enumerated device, rejected native selectors (-2invalidParameter, out-of-range → noPlaybackDevicesFound), and initializing on an explicit device — the use-after-free path, which nothing exercised before.

Two things to note

  1. The WASM artifact has not been rebuilt. src/player.cpp is compiled into web/libflutter_soloud_plugin.wasm, so Web keeps the old behaviour until it is regenerated. I hit an unrelated failure building the Xiph libs locally (vorbis configure: must have Ogg installed!--with-ogg="$OGG_DIR" expects an installed prefix layout, but the script builds ogg in-tree so the archive is at $OGG_DIR/src/.libs/). Since the artifact is normally yours to commit anyway, could you regenerate web/libflutter_soloud_plugin.{js,wasm} on merge? No exported signatures changed.

  2. The changelog entry is filed under a placeholder ##### 4.1.7 (X Xxx 2026) heading — retitle or fold it in as you see fit. No version bump included.

No ABI, enum, exception, or binding signature changes.

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ❌ Breaking change (fix or feature that would cause existing functionality to change)
  • 🧹 Code refactor
  • ✅ Build configuration change
  • 📝 Documentation
  • 🗑️ Chore

claude and others added 2 commits August 4, 2026 00:23
`SoLoud.changeDevice()` without an argument is documented to select the system
default output device, but it was rejected with `noPlaybackDevicesFound`: the
Dart layer passes `-1`, and `Player::changeDevice()` compared that `int`
against `devices.size()`, promoting it to a huge unsigned value. The device
list is now consulted only for explicit (non-negative) IDs, and `-1` passes
`nullptr` straight to miniaudio, which is its default-device selector. IDs
below `-1` are rejected as `invalidParameter` before any unsigned conversion.

A failed device change was also swallowed: the result was assigned to a local
and the method returned `noError` unconditionally. It now returns
`audioDeviceFailedToStart`, which Dart already maps to
`SoLoudAudioDeviceFailedToStartCppException`. `backendNotInited` would be wrong
here since the engine can stay initialized while its output device could not be
replaced.

Fixes found in the same code while making the above correct:

* `Player::init()` with an explicit device ID built the device list inside an
  `if` block and pointed `playbackInfos_id` at one of its elements. The vector
  was destroyed at the end of that block, so `soloud.init()` dereferenced freed
  heap memory. The vector now lives for the whole call. Confirmed under
  AddressSanitizer: the pre-fix code reports a heap-use-after-free, a 256-byte
  read in `ma_device_init()` of storage freed by the vector's destructor.
* `init()` had the same signed/unsigned comparison, reporting IDs below `-1` as
  `noPlaybackDevicesFound`. They are now `invalidParameter`, matching
  `changeDevice()`.
* `PlaybackDevice::name` was a `strdup()`ed `char *` that nothing ever freed, so
  every `listPlaybackDevices()` call leaked one allocation per device, including
  the internal calls from `init()` and `changeDevice()`. It is now a
  `std::string`, which also lets the dangling `pPlaybackInfos` member become a
  local: it points at context-owned memory freed by `ma_context_uninit()` before
  the function returns.
* The device-name filter in the FFI wrapper scanned the first 5 bytes of a name
  before checking that it was longer than 5 characters, over-reading the buffer
  for short names. The length check now runs first.
* `Soloud::miniaudio_changeDevice()` returned 0 when the audio thread mutex was
  null, reporting a successful device change when nothing had happened. It now
  returns `UNKNOWN_ERROR`, surfacing as `audioDeviceFailedToStart`, and
  `NOT_IMPLEMENTED` when built without the miniaudio backend.

The device tests now cover the default selector, an enumerated device, rejected
native selectors, and initializing on an explicit device (the use-after-free
path, which nothing exercised before).

No ABI, enum or binding signature changes. The web WASM artifact still needs a
rebuild to pick this up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwM8amyyBKxw4tEAnySfBR
@alnitak

alnitak commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Hi @Colton127, again, thank you very much for your support!

Since the artifact is normally yours to commit anyway, could you regenerate web/libflutter_soloud_plugin.{js,wasm} on merge?

Sure! I had no problems building wasm. For the record, I am using em++ version 4.0.22-git on macOS, but maybe this is not the problem.

Thanks again. Merging

@alnitak
alnitak merged commit e8d0c6f into alnitak:main Aug 5, 2026
1 check passed
Colton127 pushed a commit to Colton127/flutter_soloud that referenced this pull request Aug 7, 2026
…e ops

Two problems in `miniaudio_changeDevice_impl()`, both only reachable since the
device change started making it to the backend in alnitak#532.

The audio-thread mutex was held across the whole swap. It does not protect
`gDevice` -- the data callback reaches the engine through `pDevice->pUserData`
and takes that mutex itself inside `Soloud::mix()`, and `ma_device_uninit()`
already guarantees the callback has stopped before it returns -- so holding it
only blocks the mixer. On Android that is fatal: on AAudio's legacy (non-MMAP)
path a stream reports STARTED only once its first data callback has run, so the
blocked callback made `ma_device_start()` time out after 5s and the cleanup
`ma_device_uninit()` then waited forever on that same blocked callback. Devices
that get the MMAP path report STARTED without waiting on a callback, which is
why an emulator and an LG V20 froze where an S26 Ultra and an iPhone 11 did not.
The swap no longer takes that mutex.

Nothing serialized the swap against the other operations on the same `gDevice`
either: pause, resume and teardown. Between the uninit and the init there is no
device at all, so a concurrent start/stop runs against a torn struct. It now
takes `gDeviceOpsMutex`, which already existed for exactly this. Two related
adjustments: `soloud_miniaudio_pause()` was the one device operation that never
took that lock -- and the likeliest to collide, since Player's pause scheduler
fires it from its own thread ~500ms after the last voice ends -- and the lock's
declaring comment now names all four operations it covers.

Empirically, on the concurrent test below, dropping just the device-ops lock
segfaults on some runs and hangs on others; with it, 5/5 runs are clean.

Tests:

* `test/change_device_test.cpp` (+ `run_change_device_test.sh`) swaps devices
  under a live voice, asserts no swap blocks for the AAudio timeout, and races
  swaps against each other and against pause/resume. Unlike the other native
  tests here it needs the miniaudio backend and a real output device, so it
  reports SKIPPED and exits 0 where there is none. Verified to hang against the
  unfixed backend -- a deadlock cannot report itself, so the failure is the
  timeout, not a FAILED line.
* `playback_devices.dart` now times every `changeDevice()` against a 2s budget
  (the stall that precedes the freeze), runs ten back-to-back swaps under a
  looping voice, and aims five more at the deferred engine-pause window. The
  Dart side cannot catch a true deadlock: `changeDevice()` is a synchronous FFI
  call on the UI isolate, so once native wedges there is no Dart left to time it
  out.

The web WASM artifact still needs a rebuild to pick this up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6iyp4neA51rNqRULEKVHA
Colton127 added a commit to Colton127/flutter_soloud that referenced this pull request Aug 7, 2026
…serialize against other device ops (#27)

* fix: a failed changeDevice() no longer deadlocks the calling thread

`miniaudio_changeDevice_impl()` held SoLoud's audio-thread mutex across the
whole device swap, including the cleanup that runs when the replacement device
fails to start. That cleanup calls `ma_device_uninit()`, which waits for the
backend's data callback to return -- and that callback
(`soloud_miniaudio_audiomixer` -> `Soloud::mix` -> `mix_internal`) blocks on
the very mutex the caller is holding. The calling thread and the audio thread
wait on each other forever, which on Android is an ANR with no recovery.

The mutex is now released before the failure-path `ma_device_uninit()`, so the
audio callback can drain, the stream closes, and the error propagates:
`UNKNOWN_ERROR` -> `PlayerErrors.audioDeviceFailedToStart` ->
`SoLoudAudioDeviceFailedToStartCppException`. `changeDevice()` throws instead
of hanging.

Why the same mutex also causes the start to fail in the first place: on the
AAudio legacy (non-MMAP) path the stream only transitions from STARTING to
STARTED once the first data callback has run. That callback is blocked on the
audio mutex, so `ma_wait_for_simple_state_transition__aaudio()` times out after
5 seconds and `ma_device_start()` returns `MA_ERROR` (-1) -- the
`ma_device_start failed with error -1` line in the log. Devices that get the
MMAP/exclusive path (S26 Ultra, iPhone 11) report STARTED without waiting on a
callback, never fail the start, and so never reach the deadlocking cleanup.
That underlying start failure is left in place here on purpose: this commit
only makes it observable rather than fatal.

The failure path also resets `gDeviceStopped`, which was left `false` after a
device that never ran. A subsequent `deinit()` would otherwise poll for a
"stopped" notification that can no longer arrive, burning its full 500 ms
timeout.

This path became reachable from Dart in alnitak#532: before it, `changeDevice()` with
no argument was rejected in `Player::changeDevice()` by a signed/unsigned
comparison and never reached the backend at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6iyp4neA51rNqRULEKVHA

* fix: changeDevice() no longer starves the audio thread or races device ops

Two problems in `miniaudio_changeDevice_impl()`, both only reachable since the
device change started making it to the backend in alnitak#532.

The audio-thread mutex was held across the whole swap. It does not protect
`gDevice` -- the data callback reaches the engine through `pDevice->pUserData`
and takes that mutex itself inside `Soloud::mix()`, and `ma_device_uninit()`
already guarantees the callback has stopped before it returns -- so holding it
only blocks the mixer. On Android that is fatal: on AAudio's legacy (non-MMAP)
path a stream reports STARTED only once its first data callback has run, so the
blocked callback made `ma_device_start()` time out after 5s and the cleanup
`ma_device_uninit()` then waited forever on that same blocked callback. Devices
that get the MMAP path report STARTED without waiting on a callback, which is
why an emulator and an LG V20 froze where an S26 Ultra and an iPhone 11 did not.
The swap no longer takes that mutex.

Nothing serialized the swap against the other operations on the same `gDevice`
either: pause, resume and teardown. Between the uninit and the init there is no
device at all, so a concurrent start/stop runs against a torn struct. It now
takes `gDeviceOpsMutex`, which already existed for exactly this. Two related
adjustments: `soloud_miniaudio_pause()` was the one device operation that never
took that lock -- and the likeliest to collide, since Player's pause scheduler
fires it from its own thread ~500ms after the last voice ends -- and the lock's
declaring comment now names all four operations it covers.

Empirically, on the concurrent test below, dropping just the device-ops lock
segfaults on some runs and hangs on others; with it, 5/5 runs are clean.

Tests:

* `test/change_device_test.cpp` (+ `run_change_device_test.sh`) swaps devices
  under a live voice, asserts no swap blocks for the AAudio timeout, and races
  swaps against each other and against pause/resume. Unlike the other native
  tests here it needs the miniaudio backend and a real output device, so it
  reports SKIPPED and exits 0 where there is none. Verified to hang against the
  unfixed backend -- a deadlock cannot report itself, so the failure is the
  timeout, not a FAILED line.
* `playback_devices.dart` now times every `changeDevice()` against a 2s budget
  (the stall that precedes the freeze), runs ten back-to-back swaps under a
  looping voice, and aims five more at the deferred engine-pause window. The
  Dart side cannot catch a true deadlock: `changeDevice()` is a synchronous FFI
  call on the UI isolate, so once native wedges there is no Dart left to time it
  out.

The web WASM artifact still needs a rebuild to pick this up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6iyp4neA51rNqRULEKVHA

* docs: trim the changeDevice CHANGELOG entries to the user-visible change

The mechanics of the freeze and the device-op serialization are implementation
detail; what an app author needs to know is that a failed device change now
throws instead of hanging. The rest lives in the pull request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6iyp4neA51rNqRULEKVHA

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

3 participants