fix: changeDevice() device selection and error reporting - #532
Merged
alnitak merged 2 commits intoAug 5, 2026
Conversation
`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
Owner
|
Hi @Colton127, again, thank you very much for your support!
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 |
7 tasks
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
7 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 deviceSoLoud.instance.changeDevice()with no argument is documented to select the system default output device, but it always threwSoLoudNoPlaybackDevicesFoundCppException. Dart passes-1, andPlayer::changeDevice()compared thatintagainstdevices.size():-1is promoted toSIZE_MAXby the usual arithmetic conversions, so the guard always fired. The device list is now consulted only for explicit (non-negative) IDs, and-1passesnullptrstraight through todeviceConfig.playback.pDeviceID, which is miniaudio's default-device selector. This mirrors whatPlayer::init()already did. IDs below-1are rejected asinvalidParameterbefore any conversion to an unsigned type.A failed device change reported success
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 toSoLoudAudioDeviceFailedToStartCppException.backendNotInitedwould 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
Player::init(). With an explicit device ID, the device list was built inside anifblock andplaybackInfos_idpointed at one of its elements. The vector was destroyed at the end of that block, sosoloud.init()handed miniaudio a pointer into freed heap. Reproduced under AddressSanitizer (details below).init()had the same signed/unsigned comparison, reporting IDs below-1asnoPlaybackDevicesFound. NowinvalidParameter, matchingchangeDevice().listPlaybackDevices()leaked on every call.PlaybackDevice::namewas astrdup()edchar *that nothing ever freed — one allocation per device per call, including the internal calls frominit()andchangeDevice(). It is now astd::string, which also lets the danglingpPlaybackInfosmember become a local: it points at context-owned memory thatma_context_uninit()frees before the function returns.bindings.cppscanned the first 5 bytes of each name for control characters before checkingstrlen(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 returnsUNKNOWN_ERROR(surfacing asaudioDeviceFailedToStart), andNOT_IMPLEMENTEDwhen 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:
...and
132 byte(s) leaked in 2 allocation(s), bothstrdupfromPlayer::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 returnnoErrorand play normally.Tests
example/tests/tests/playback_devices.dartnow covers the default selector, an enumerated device, rejected native selectors (-2→invalidParameter, out-of-range →noPlaybackDevicesFound), and initializing on an explicit device — the use-after-free path, which nothing exercised before.Two things to note
The WASM artifact has not been rebuilt.
src/player.cppis compiled intoweb/libflutter_soloud_plugin.wasm, so Web keeps the old behaviour until it is regenerated. I hit an unrelated failure building the Xiph libs locally (vorbisconfigure: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 regenerateweb/libflutter_soloud_plugin.{js,wasm}on merge? No exported signatures changed.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