From 72910e7006058c8caa69c66ff64b64f56f460306 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Sat, 11 Jul 2026 10:16:15 -0500 Subject: [PATCH] fix: prevent Play() deadlock when device is not open Play() held mu then called SwitchDevice() which also tried to acquire mu, causing a deadlock. Replace with direct call to openDeviceLocked() which does not lock. Add regression test that verifies Play() returns an error (not deadlock) when called on an uninitialized mixer. --- internal/mixer/mixer.go | 11 ++++---- internal/mixer/mixer_internal_test.go | 37 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/internal/mixer/mixer.go b/internal/mixer/mixer.go index 04fb162..0031141 100644 --- a/internal/mixer/mixer.go +++ b/internal/mixer/mixer.go @@ -112,14 +112,15 @@ func Play(path string, loopForever bool, cfg *db.AppConfig) error { defer mu.Unlock() // Check if mixer has been initialized and that a device is open - if !inited || !deviceOpen { - if !deviceOpen { - SwitchDevice("") // Re-switch to the default device - Play(path, loopForever, cfg) // Re-run the function - } + if !inited { logger.Error("audio mixer not initialized") return errors.New("audio mixer not initialized") } + if !deviceOpen { + if err := openDeviceLocked(""); err != nil { + return fmt.Errorf("failed to open audio device: %w", err) + } + } if path == "" { logger.Error("empty audio path") return errors.New("empty audio path") diff --git a/internal/mixer/mixer_internal_test.go b/internal/mixer/mixer_internal_test.go index 9232b75..5c6553a 100644 --- a/internal/mixer/mixer_internal_test.go +++ b/internal/mixer/mixer_internal_test.go @@ -2,8 +2,45 @@ package mixer import ( "testing" + "time" + + "rhemawavetech.com/rhemawave/internal/db" ) +// TestPlayDoesNotDeadlockWhenUninitialized verifies that Play() returns an error +// (not a deadlock) when the mixer has not been initialized. +// The old code would deadlock because Play() held mu then called SwitchDevice() +// which also tried to acquire mu. +func TestPlayDoesNotDeadlockWhenUninitialized(t *testing.T) { + // Ensure mixer is in uninitialized state + mu.Lock() + wasInited := inited + inited = false + deviceOpen = false + mu.Unlock() + + defer func() { + mu.Lock() + inited = wasInited + mu.Unlock() + }() + + cfg := &db.AppConfig{AudioEnabled: true} + done := make(chan error, 1) + go func() { + done <- Play("nonexistent.wav", false, cfg) + }() + + select { + case err := <-done: + if err == nil { + t.Fatal("Play() returned nil error on uninitialized mixer, want error") + } + case <-time.After(5 * time.Second): + t.Fatal("Play() deadlocked on uninitialized mixer (did not return within 5s)") + } +} + func TestParseSinkLine(t *testing.T) { tests := []struct { name string