Fix SDL3 audio shutdown race during Engine teardown

Quit only the SDL video subsystem when destroying the window backend,
leaving audio shutdown to MojoAL. This prevents concurrent audio device
cleanup from calling cleared SDL driver callbacks.

Add regressions for playback across Engine recreation and last-resource
release on a worker during shutdown, using public APIs without SDL headers.

Validated in Linux Docker: 2,140 tests passed, one font-dependent skip.
Repeated shutdown stress tests and MinGW syntax checking also passed.
This commit is contained in:
Martín Lucas Golini
2026-10-26 01:09:29 -03:00
parent 7069bb03e6
commit 2f3f663cbb
2 changed files with 104 additions and 1 deletions
+4 -1
View File
@@ -47,7 +47,10 @@ WindowBackendSDL3::~WindowBackendSDL3() {
Private::WaylandDragAndDrop::shutdown();
#endif
#if EE_PLATFORM != EE_PLATFORM_MACOS
SDL_Quit();
// Audio belongs to MojoAL, independently of the Engine, and its last resource can be
// released on a worker. Global SDL_Quit() ignores those references and races with
// alcCloseDevice(), leaving SDL audio shutdown to use already-cleared driver callbacks.
SDL_QuitSubSystem( SDL_INIT_VIDEO );
#endif
}
@@ -0,0 +1,100 @@
#include "utest.h"
#include <array>
#include <condition_variable>
#include <eepp/audio/sound.hpp>
#include <eepp/audio/soundbuffer.hpp>
#include <eepp/system/clock.hpp>
#include <eepp/system/sys.hpp>
#include <eepp/window/engine.hpp>
#include <mutex>
#include <thread>
using namespace EE;
using namespace EE::Audio;
using namespace EE::System;
using namespace EE::Window;
namespace {
struct AudioWindowFixture {
AudioWindowFixture() { Engine::destroySingleton(); }
~AudioWindowFixture() { Engine::destroySingleton(); }
static bool isSDL3() {
// The settings factory resolves the backend compiled into the library without
// requiring backend-specific headers or defines in the test executable.
return Engine::instance()->createWindowSettings( std::string{} ).Backend ==
WindowBackend::SDL3;
}
static EE::Window::Window* createWindow() {
return Engine::instance()->createWindow( WindowSettings{
64, 64, "Audio/window lifetime", WindowStyle::Default, WindowBackend::Default } );
}
};
} // namespace
UTEST( AudioWindowLifetime, PlaybackContinuesAcrossEngineRecreation ) {
AudioWindowFixture fixture;
auto* window = fixture.createWindow();
ASSERT_TRUE( window != nullptr );
if ( !fixture.isSDL3() )
UTEST_SKIP( "SDL3 lifecycle regression" );
std::array<Int16, 8192> samples{};
SoundBuffer buffer;
ASSERT_TRUE( buffer.loadFromSamples( samples.data(), samples.size(), 1, 44100 ) );
Sound sound( buffer );
sound.setLoop( true );
sound.play();
for ( int cycle = 0; cycle < 3; ++cycle ) {
Engine::destroySingleton();
const auto previous = sound.getPlayingOffset();
Clock clock;
while ( sound.getPlayingOffset() == previous && clock.getElapsedTime() < Seconds( 2 ) )
Sys::sleep( Milliseconds( 5 ) );
EXPECT_EQ( sound.getStatus(), SoundSource::Playing );
EXPECT_TRUE( sound.getPlayingOffset() != previous );
ASSERT_TRUE( fixture.createWindow() != nullptr );
}
sound.stop();
}
UTEST( AudioWindowLifetime, WorkerCanReleaseLastAudioResourceDuringEngineShutdown ) {
AudioWindowFixture fixture;
auto* window = fixture.createWindow();
ASSERT_TRUE( window != nullptr );
if ( !fixture.isSDL3() )
UTEST_SKIP( "SDL3 lifecycle regression" );
for ( int cycle = 0; cycle < 16; ++cycle ) {
std::mutex mutex;
std::condition_variable condition;
bool ready = false;
bool release = false;
bool loaded = false;
std::thread worker( [&] {
SoundBuffer buffer;
std::array<Int16, 64> samples{};
loaded = buffer.loadFromSamples( samples.data(), samples.size(), 1, 44100 );
std::unique_lock<std::mutex> lock( mutex );
ready = true;
condition.notify_one();
condition.wait( lock, [&] { return release; } );
// Both native context and device are released here, on the worker.
} );
{
std::unique_lock<std::mutex> lock( mutex );
condition.wait( lock, [&] { return ready; } );
release = true;
}
condition.notify_one();
Engine::destroySingleton();
worker.join();
// Join before asserting so a failure cannot leave a joinable thread behind.
ASSERT_TRUE( loaded );
ASSERT_TRUE( fixture.createWindow() != nullptr );
}
}