Summary
Two symptoms that look unrelated turn out to be the same bug:
- With a Bluetooth headset connected, games play no sound through it (the built-in speaker works fine).
- Disconnecting the headset while a game is running freezes the game permanently — video, input, everything. Only a hard power-off recovers it.
The cause is alsa-lib's per-PCM recursive lock. On the bluealsa sink that lock is never released after the PCM is opened, so SDL's audio thread blocks inside libasound and never runs the audio callback. That silence is symptom 1. Symptom 2 follows from it: on disconnect, minarch resets the audio device from the game loop, and SDL_CloseAudioDevice() waits forever for that already-stuck thread to exit.
Environment
|
|
| Device |
TrimUI Brick (tg5040), Allwinner sun50iw10, kernel 4.9.191 |
| NextUI |
current main |
| SDL |
2.30.8 |
| alsa-lib |
1.1.4.1 (thread-safe PCM locking landed in 1.1.2, so any ≥1.1.2 is affected) |
| bluealsa |
v4.1.0, started as bluealsa -p a2dp-source |
| Cores |
reproduced with gpsp and mgba (likely any core — nothing core-specific) |
Steps to reproduce
- Pair and connect a Bluetooth A2DP headset.
- Launch any game.
- Observe: no audio in the headset.
- Turn the headset off, or disconnect it.
- The game freezes on the current frame and never recovers.
Root cause
Captured from a live frozen process over adb (device still wedged, nothing reconstructed after the fact).
The audio callback had never run. minarch's ring buffer state at freeze time:
snd.frame_in = 6374 snd.frame_out = 0 snd.frame_count = 6375
frame_out never left zero, so SDL consumed nothing for the whole session — the audio thread was already dead in the water while the game was playing normally. That is symptom 1, and it means the thread was stuck before the disconnect ever happened.
The freeze is a deadlock between the game loop and that stuck thread.
Main thread (tid 3958):
pthread_join <- SDL_WaitThread <- SDL_CloseAudioDevice <- minarch (audio device reset)
futex uaddr=…, val=0xfa2 # 0xfa2 = 4002 = the SDL audio thread's tid
SDL audio thread (tid 4002, SDLAudioP2) — stack shows libasound frames above the SDL ones, blocked on a mutex:
__lock=2 __count=1 __owner=3958 __nusers=1 __kind=1
__kind=1 is PTHREAD_MUTEX_RECURSIVE, and the mutex lives on the heap — which is exactly how alsa-lib initialises snd_pcm_t->lock. __owner=3958 is the main thread, which by then has no libasound frames left on its stack: the lock was taken inside alsa-lib and never released.
Ruled out along the way:
- Not SDL's fault. Disassembling
close_audio_device in 2.30.8 shows LockDevice → set atomics → UnlockDevice → then SDL_WaitThread. Symmetric.
- Not the device mixer lock.
device->mixer_lock is a different address than the contended one.
- Not NextUI's own
audio_mutex. Read live at __lock=0 — completely uninvolved.
So: the main thread holds an alsa-lib PCM lock, the audio thread needs it, and the main thread is blocking forever waiting for that thread to exit.
Fix
export LIBASOUND_THREAD_SAFE=0
in skeleton/SYSTEM/<platform>/paks/MinUI.pak/launch.sh, in the top-level environment block so audiomon, nextui.elf and minarch.elf all inherit it. Needed on tg5040 and tg5050 (the platforms that ship bluealsa); desktop doesn't and is untouched.
This is the documented upstream workaround for alsa-lib's thread-safe PCM locking, and it's safe here: NextUI never drives a single PCM from two threads at once — SDL already serialises device open/close against its own audio thread — so alsa's internal locking adds nothing.
Verified
On a Brick (tg5040): sound plays over the headset, and disconnecting mid-game no longer freezes. Both symptoms gone.
Note for maintainers
Bonus: this also explains the commented-out SND_quit()
SND_quit() is commented out in two places — minarch.c (end of main) and ma_core.c (Core_unload) — both carrying the same note:
// Disabling this is a dumb hack for bluetooth, we should really be using
// bluealsa with --keep-alive=-1 - but SDL wont reconnect the stream on next start.
// Reenable as soon as we have a more recent SDL available, if ever.
//SND_quit();
SND_quit() calls SDL_CloseAudioDevice() — the exact call that deadlocks above — so with a headset connected, quitting a game would hang instead of returning to the launcher. The workaround was hiding this same bug, and both halves of the note turn out to be false once the lock is gone. Verified on hardware:
- quitting a game with the headset connected returns to the launcher normally, no hang;
- the next game gets sound over the headset — SDL reconnects the stream fine, contrary to the comment.
So the hack can go. One caveat on placement: only the call at the end of main() should come back. The one in Core_unload() runs before Core_quit(), and SND_quit() frees the sample buffer while the core can still emit audio from unload_game() — a NULL dereference waiting to happen. (Related: SND_quit() leaves snd.frame_count non-zero while setting snd.buffer = NULL, so any stray SND_batchSamples() after teardown crashes. Worth a guard independently of this issue.)
Summary
Two symptoms that look unrelated turn out to be the same bug:
The cause is alsa-lib's per-PCM recursive lock. On the bluealsa sink that lock is never released after the PCM is opened, so SDL's audio thread blocks inside
libasoundand never runs the audio callback. That silence is symptom 1. Symptom 2 follows from it: on disconnect, minarch resets the audio device from the game loop, andSDL_CloseAudioDevice()waits forever for that already-stuck thread to exit.Environment
tg5040), Allwinnersun50iw10, kernel 4.9.191mainbluealsa -p a2dp-sourcegpspandmgba(likely any core — nothing core-specific)Steps to reproduce
Root cause
Captured from a live frozen process over adb (device still wedged, nothing reconstructed after the fact).
The audio callback had never run. minarch's ring buffer state at freeze time:
frame_outnever left zero, so SDL consumed nothing for the whole session — the audio thread was already dead in the water while the game was playing normally. That is symptom 1, and it means the thread was stuck before the disconnect ever happened.The freeze is a deadlock between the game loop and that stuck thread.
Main thread (tid 3958):
SDL audio thread (tid 4002,
SDLAudioP2) — stack showslibasoundframes above the SDL ones, blocked on a mutex:__kind=1isPTHREAD_MUTEX_RECURSIVE, and the mutex lives on the heap — which is exactly how alsa-lib initialisessnd_pcm_t->lock.__owner=3958is the main thread, which by then has nolibasoundframes left on its stack: the lock was taken inside alsa-lib and never released.Ruled out along the way:
close_audio_devicein 2.30.8 showsLockDevice→ set atomics →UnlockDevice→ thenSDL_WaitThread. Symmetric.device->mixer_lockis a different address than the contended one.audio_mutex. Read live at__lock=0— completely uninvolved.So: the main thread holds an alsa-lib PCM lock, the audio thread needs it, and the main thread is blocking forever waiting for that thread to exit.
Fix
export LIBASOUND_THREAD_SAFE=0in
skeleton/SYSTEM/<platform>/paks/MinUI.pak/launch.sh, in the top-level environment block soaudiomon,nextui.elfandminarch.elfall inherit it. Needed ontg5040andtg5050(the platforms that ship bluealsa);desktopdoesn't and is untouched.This is the documented upstream workaround for alsa-lib's thread-safe PCM locking, and it's safe here: NextUI never drives a single PCM from two threads at once — SDL already serialises device open/close against its own audio thread — so alsa's internal locking adds nothing.
Verified
On a Brick (tg5040): sound plays over the headset, and disconnecting mid-game no longer freezes. Both symptoms gone.
Note for maintainers
Bonus: this also explains the commented-out
SND_quit()SND_quit()is commented out in two places —minarch.c(end ofmain) andma_core.c(Core_unload) — both carrying the same note:SND_quit()callsSDL_CloseAudioDevice()— the exact call that deadlocks above — so with a headset connected, quitting a game would hang instead of returning to the launcher. The workaround was hiding this same bug, and both halves of the note turn out to be false once the lock is gone. Verified on hardware:So the hack can go. One caveat on placement: only the call at the end of
main()should come back. The one inCore_unload()runs beforeCore_quit(), andSND_quit()frees the sample buffer while the core can still emit audio fromunload_game()— a NULL dereference waiting to happen. (Related:SND_quit()leavessnd.frame_countnon-zero while settingsnd.buffer = NULL, so any straySND_batchSamples()after teardown crashes. Worth a guard independently of this issue.)