Skip to content

Bluetooth audio: no game sound, and disconnecting the headset freezes the game #810

Description

@dalexanco

Summary

Two symptoms that look unrelated turn out to be the same bug:

  1. With a Bluetooth headset connected, games play no sound through it (the built-in speaker works fine).
  2. 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

  1. Pair and connect a Bluetooth A2DP headset.
  2. Launch any game.
  3. Observe: no audio in the headset.
  4. Turn the headset off, or disconnect it.
  5. 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 → UnlockDevicethen 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.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions