audio: mixer streams arrive only inside the mixer's init..done window

First find of the discovery pass the whole-tree audit pointed at
next (task retirement against subsystem teardown):
audio_driver_mixer_add_stream() trusted its caller's timing. The
mixer's lifecycle is the audio driver's - audio_mixer_init() runs
inside audio_driver_init_internal(), audio_mixer_done() inside
audio_driver_mixer_deinit() - and done() releases every voice and
frees its lock. Two reachable paths deliver a stream outside that
window:

- shutdown tears the drivers down before the task queue, so a
  mixer-load task that finishes late retires its callback into a
  done mixer;
- every failure exit of audio_driver_init_internal() precedes
  audio_mixer_init(), so a session whose audio driver failed to
  come up funnels each menu sound into the same door.

In both cases add_stream claims a voice whose lock is gone -
slock_lock(NULL) returns silently, so nothing crashes; the voice is
written unlocked, its per-voice resources are orphaned when the next
audio_mixer_init() wipes voice types, and a phantom stream entry
survives in mixer_streams[].

AUDIO_FLAG_MIXER_INITED now latches the window: raised right after
audio_mixer_init(), dropped with MIXER_ACTIVE before
audio_mixer_done(), and checked at the top of add_stream - the one
injector; the other public mixer entry points walk mixer_streams[],
which teardown has already emptied. The refusal keeps the ownership
contract: buf_owner is released exactly as on every other failure
path, and out_slot reports -1.

samples/audio/mixer_retirement links the shipping objects with only
main() replaced, boots headless against ALSA's null PCM device (the
"null" audio driver is a stub vtable that cannot initialize), and
drives the seam: a baseline add lands in a slot with the WAV
conversion releasing its owner once; an add after
audio_driver_deinit() is refused with the owner released exactly
once and out_slot -1; an add after CMD_EVENT_AUDIO_REINIT lands
again. With the guard removed the refusal lane fails exactly as the
defect predicts (the stream lands in slot 16 of the torn mixer).

Noted while here, untouched: the SYNC atomic backend's load on a
const-qualified state pointer trips -Wdiscarded-qualifiers at
audio_driver_dev_frame_bytes() on shipping DINGUX lanes; that is
pre-existing and gets its own change.

Verified: thread-read audit 0 findings / 0 allowlisted pairs, the
mixer_retirement harness green and red as stated, playlist_nav
green, host syntax passes under __PS3__, PSP and VITA (the file's
platform gates) with implicit-function-declaration promoted to an
error, the full build scanned clean for implicit-declaration and
static-follows warnings, hot-function and stack budgets, the
74-lane compile matrix, forced atomic backends (SYNC carries the
pre-existing const warning above; the other four clean) and the C89
gate.
This commit is contained in:
LibretroAdmin authored and libretroadmin committed 2026-09-16 09:03:14 +02:00
1 parent a2c94e7e94
commit 2277ad2a58
5 files changed
+345 -2

No files matched your search

+11 -1
View File
@@ -184,7 +184,17 @@ enum audio_driver_state_flags
* runloop's pace record so "audio is holding the loop" is a fact
* about this iteration rather than a guess about who wrote.
*/
AUDIO_FLAG_WROTE = (1 << 10)
AUDIO_FLAG_WROTE = (1 << 10),
/* The mixer subsystem is between audio_mixer_init() and
* audio_mixer_done(): the window in which claiming voices is
* legal. audio_driver_init_internal() raises it right after the
* mixer comes up; audio_driver_mixer_deinit() drops it before the
* mixer goes down. Streams arriving outside the window - a mixer
* load task retiring after teardown at shutdown, or a menu sound
* during a session whose audio driver failed to initialize - are
* refused at audio_driver_mixer_add_stream() instead of claiming
* voices whose locks are gone. */
AUDIO_FLAG_MIXER_INITED = (1 << 11)
};
typedef struct audio_statistics
+16 -1
View File
@@ -1134,7 +1134,8 @@ static void audio_driver_mixer_deinit(void)
{
unsigned i;
AUDIO_FLAGS_CLEAR(&audio_driver_st, AUDIO_FLAG_MIXER_ACTIVE);
AUDIO_FLAGS_CLEAR(&audio_driver_st, AUDIO_FLAG_MIXER_ACTIVE
| AUDIO_FLAG_MIXER_INITED);
for (i = 0; i < AUDIO_MIXER_MAX_SYSTEM_STREAMS; i++)
{
@@ -4530,6 +4531,7 @@ bool audio_driver_init_internal(void *settings_data, bool audio_cb_inited)
#ifdef HAVE_AUDIOMIXER
audio_mixer_init(settings->uints.audio_output_sample_rate);
AUDIO_FLAGS_SET(&audio_driver_st, AUDIO_FLAG_MIXER_INITED);
#endif
if (!audio_driver_transport_configure(settings))
@@ -7374,6 +7376,19 @@ bool audio_driver_mixer_add_stream(audio_mixer_stream_params_t *params)
* outcome: each failure return releases it. */
if (params->out_slot)
*params->out_slot = -1;
/* Outside the mixer's init..done window - a mixer load task
* retiring after audio teardown at shutdown, or any stream pushed
* during a session whose audio driver failed to initialize -
* claiming a voice would write into a subsystem whose voice locks
* audio_mixer_done() already freed. Refuse; ownership is released
* as on every other failure path. */
if (!(AUDIO_FLAGS_GET(&audio_driver_st) & AUDIO_FLAG_MIXER_INITED))
{
if (params->buf_owner)
params->buf_owner_free(params->buf_owner);
audio_driver_state_unlock();
return false;
}
if (params->stream_type == AUDIO_STREAM_TYPE_NONE)
{
if (params->buf_owner)
+10
View File
@@ -0,0 +1,10 @@
TARGET := mixer_retirement_test
all:
@sh ./build.sh
clean:
rm -f $(TARGET) retroarch_nomain.o retroarch_nomain.d \
harness_main.o harness_main.d
.PHONY: all clean
+71
View File
@@ -0,0 +1,71 @@
#!/bin/sh
# Builds the mixer add-stream init-window harness.
#
# Like playlist_nav, this links the SHIPPING objects of a built
# RetroArch with only main() replaced - nothing is stubbed. The seam
# under test runs through rgui_frame's aspect-change detection, the staged config on the
# rgui handle, and the application rgui_render performs on the main
# thread before CMD_EVENT_VIDEO_SET_ASPECT_RATIO fires.
# real frame and render code can see whether the write lands in the
# configuration or dies with the frame's snapshot copy.
#
# Requires a completed NON-Qt build: with Qt enabled main() lives in
# ui_qt.o, which drags the whole Qt UI in with it. From the repo
# root:
#
# ./configure --disable-qt && make
# samples/audio/mixer_retirement/build.sh
#
# retroarch.c is recompiled here with main renamed, so the harness can
# supply its own without having to exclude the object that holds most
# of the frontend. Both the compile and the link reuse the project's
# own command lines, taken from make -n, so the harness cannot drift
# away from how the program is really built.
set -eu
here=$(cd "$(dirname "$0")" && pwd)
root=$(cd "$here/../../.." && pwd)
cd "$root"
if [ ! -f obj-unix/release/retroarch.o ]; then
echo "build RetroArch first: ./configure --disable-qt && make" >&2
exit 1
fi
cc_line=$(mktemp)
ld_line=$(mktemp)
trap 'rm -f "$cc_line" "$ld_line"' EXIT
# The project's own compile line for retroarch.o ...
touch retroarch.c
make -n 2>/dev/null | grep -E '\-o obj-unix/release/retroarch\.o' | head -1 > "$cc_line"
if [ ! -s "$cc_line" ]; then
echo "could not determine the compile command for retroarch.o" >&2
exit 1
fi
# ... reused twice: once for retroarch.c with main renamed out of the
# way, once for the harness itself, so both are built exactly the way
# the frontend is.
sed 's#-o obj-unix/release/retroarch\.o#-Dmain=rarch_harness_unused_main -o samples/audio/mixer_retirement/retroarch_nomain.o#' \
"$cc_line" | sh
sed -e 's#-o obj-unix/release/retroarch\.o#-o samples/audio/mixer_retirement/harness_main.o#' \
-e 's# retroarch\.c# samples/audio/mixer_retirement/mixer_retirement_test.c#' \
"$cc_line" | sh
# The project's own link line, with retroarch.o swapped for the pair
# above. Removing the binary first is what makes make -n emit it.
rm -f retroarch
make -n 2>/dev/null | grep -E ' -o retroarch ' | tail -1 > "$ld_line"
if [ ! -s "$ld_line" ]; then
echo "could not determine the link command" >&2
exit 1
fi
sed -e 's#obj-unix/release/retroarch\.o#samples/audio/mixer_retirement/retroarch_nomain.o samples/audio/mixer_retirement/harness_main.o#' \
-e 's#-o retroarch #-o samples/audio/mixer_retirement/mixer_retirement_test #' \
"$ld_line" | sh
echo "built samples/audio/mixer_retirement/mixer_retirement_test"
@@ -0,0 +1,237 @@
/* Mixer streams arrive only inside the mixer's init..done window.
*
* Links the shipping RetroArch objects with only main() replaced
* (the playlist_nav pattern) and drives the seam the whole-tree
* audit pointed at next: task retirement against subsystem
* teardown. audio_driver_deinit() runs audio_mixer_done(), which
* releases every voice and frees its lock; a mixer-load task that
* retires after that - RetroArch's shutdown tears the drivers down
* before the task queue - lands its finish callback in
* audio_driver_mixer_add_stream(), which would claim a voice inside
* the torn subsystem. The same door is open for every menu sound in
* a session whose audio driver failed to initialize, since all of
* audio_driver_init_internal()'s failure exits precede
* audio_mixer_init().
*
* The claims: with audio up, a stream adds; after
* audio_driver_deinit(), an add is refused and the ownership
* contract still holds (buf_owner released exactly once, out_slot
* -1); after CMD_EVENT_AUDIO_REINIT, adds work again.
*
* Requires a completed non-Qt build:
*
* ./configure --disable-qt && make
* samples/audio/mixer_retirement/build.sh
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <boolean.h>
#include <time/rtime.h>
#include <file/config_file.h>
#include <streams/file_stream.h>
#include "../../../configuration.h"
#include "../../../retroarch.h"
#include "../../../command.h"
#include "../../../audio/audio_driver.h"
#include "../../../frontend/frontend_driver.h"
static unsigned failures = 0;
#define CHECK(cond, ...) \
do { \
if (!(cond)) \
{ \
fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \
fprintf(stderr, __VA_ARGS__); \
fprintf(stderr, "\n"); \
failures++; \
} \
} while (0)
/* A minimal 16-bit stereo PCM WAV at the configured output rate:
* 44-byte canonical header plus a short run of silence. */
#define WAV_FRAMES 256
#define WAV_RATE 48000
static unsigned char wav_buf[44 + WAV_FRAMES * 4];
static void wav_fill(void)
{
unsigned char *p = wav_buf;
unsigned data_bytes = WAV_FRAMES * 4;
unsigned riff_bytes = 36 + data_bytes;
#define PUT4(s) do { memcpy(p, s, 4); p += 4; } while (0)
#define PUT32(v) do { p[0] = (unsigned char)((v) & 0xff); \
p[1] = (unsigned char)(((v) >> 8) & 0xff); \
p[2] = (unsigned char)(((v) >> 16) & 0xff); \
p[3] = (unsigned char)(((v) >> 24) & 0xff); \
p += 4; } while (0)
#define PUT16(v) do { p[0] = (unsigned char)((v) & 0xff); \
p[1] = (unsigned char)(((v) >> 8) & 0xff); \
p += 2; } while (0)
PUT4("RIFF"); PUT32(riff_bytes); PUT4("WAVE");
PUT4("fmt "); PUT32(16);
PUT16(1); /* PCM */
PUT16(2); /* stereo */
PUT32(WAV_RATE);
PUT32(WAV_RATE * 4); /* byte rate */
PUT16(4); /* block align*/
PUT16(16); /* bits */
PUT4("data"); PUT32(data_bytes);
memset(p, 0, data_bytes);
#undef PUT4
#undef PUT32
#undef PUT16
}
/* Counting owner: the ownership contract says buf_owner is released
* in every outcome - on failure immediately, on success when the
* sound dies (or straight after conversion for WAV). */
static unsigned owner_frees = 0;
static void counting_owner_free(void *owner)
{
(void)owner;
owner_frees++;
}
static bool add_one(void *owner, int *slot_out)
{
audio_mixer_stream_params_t params;
memset(&params, 0, sizeof(params));
params.buf = wav_buf;
params.bufsize = sizeof(wav_buf);
params.basename = NULL;
params.cb = NULL;
params.volume = 1.0f;
params.slot_selection_type = AUDIO_MIXER_SLOT_SELECTION_AUTOMATIC;
params.stream_type = AUDIO_STREAM_TYPE_SYSTEM;
params.type = AUDIO_MIXER_TYPE_WAV;
params.state = AUDIO_STREAM_STATE_PLAYING;
params.buf_owner = owner;
params.buf_owner_free = owner ? counting_owner_free : NULL;
params.out_slot = slot_out;
return audio_driver_mixer_add_stream(&params);
}
int main(int argc, char *argv[])
{
char fixture_dir[512];
char cmd[700];
static char cfg_path[640];
char *rarch_argv[8];
int rarch_argc = 0;
int slot = -2;
(void)argc;
(void)argv;
snprintf(fixture_dir, sizeof(fixture_dir),
"/tmp/mixer_retire_%ld", (long)getpid());
snprintf(cmd, sizeof(cmd), "mkdir -p %s", fixture_dir);
if (system(cmd) != 0)
return 1;
rarch_argv[rarch_argc++] = (char*)"retroarch";
rarch_argv[rarch_argc++] = (char*)"--menu";
rarch_argv[rarch_argc++] = (char*)"--config";
rarch_argv[rarch_argc++] = cfg_path;
config_file_set_io_default(config_file_io_filestream());
rtime_init();
retroarch_config_init();
retroarch_ctl(RARCH_CTL_STATE_FREE, NULL);
frontend_driver_init_first(NULL);
{
FILE *cfg;
snprintf(cfg_path, sizeof(cfg_path), "%s/harness.cfg",
fixture_dir);
if ((cfg = fopen(cfg_path, "wb")))
{
fprintf(cfg, "video_driver = \"null\"\n");
/* The "null" audio driver is a stub vtable that cannot
* initialize; ALSA against its null PCM plugin gives a
* real, headless driver for the audio-up lanes. */
fprintf(cfg, "audio_driver = \"alsa\"\n");
fprintf(cfg, "audio_device = \"null\"\n");
fprintf(cfg, "input_driver = \"null\"\n");
fprintf(cfg, "input_joypad_driver = \"null\"\n");
fprintf(cfg, "menu_driver = \"rgui\"\n");
fprintf(cfg, "audio_enable = \"true\"\n");
fprintf(cfg, "log_verbosity = \"true\"\n");
fprintf(cfg, "audio_out_rate = \"%d\"\n", WAV_RATE);
fclose(cfg);
}
}
if (!retroarch_main_init(rarch_argc, rarch_argv))
{
fprintf(stderr, "FAIL: retroarch_main_init failed\n");
return 1;
}
wav_fill();
/* A menu-only boot defers audio bring-up; the reinit command runs
* the full deinit+init pair and leaves audio initialized, which
* is the state the lanes below need. */
command_event(CMD_EVENT_AUDIO_REINIT, NULL);
CHECK((AUDIO_FLAGS_GET(audio_state_get_ptr())
& AUDIO_FLAG_MIXER_INITED) != 0,
"fixture: mixer not inited after AUDIO_REINIT (flags 0x%x)",
(unsigned)AUDIO_FLAGS_GET(audio_state_get_ptr()));
/* Lane 1: with audio up, the stream adds and lands in a slot.
* The WAV path releases the owner right after conversion, so one
* release is the correct count here, not zero. */
owner_frees = 0;
CHECK(add_one(wav_buf, &slot),
"baseline add_stream failed with audio initialized");
CHECK(slot >= 0, "baseline add reported no slot (%d)", slot);
CHECK(owner_frees == 1,
"WAV conversion owner release: %u frees, want 1",
owner_frees);
/* Lane 2: after teardown, the add is refused and the ownership
* contract still holds - this is the retiring-task shape, the
* callback arriving after audio_driver_deinit(). */
audio_driver_deinit();
owner_frees = 0;
slot = -2;
CHECK(!add_one(wav_buf, &slot),
"add_stream accepted a stream into the torn-down mixer");
CHECK(owner_frees == 1,
"refused add released the owner %u times, want exactly 1",
owner_frees);
CHECK(slot == -1,
"refused add left out_slot at %d, want -1", slot);
/* Lane 3: after a reinit, the window is open again. */
command_event(CMD_EVENT_AUDIO_REINIT, NULL);
owner_frees = 0;
slot = -2;
CHECK(add_one(wav_buf, &slot),
"add_stream still refused after CMD_EVENT_AUDIO_REINIT");
CHECK(slot >= 0, "post-reinit add reported no slot (%d)", slot);
snprintf(cmd, sizeof(cmd), "rm -rf %s", fixture_dir);
if (system(cmd) != 0) { /* fixture dir left behind; harmless */ }
if (failures)
{
fprintf(stderr, "FAILURES (%u)\n", failures);
return 1;
}
printf("mixer_retirement: all lanes passed\n");
return 0;
}