release per-user audio resources before tearing down the renderer
Destroy detached the audio listener, which closes each per-user stream channel and ends the goroutines OnAudioStream started, and then immediately destroyed the OpenAL context and closed the renderer without waiting for them. Those goroutines delete their OpenAL source and buffers through s.render, which returns false once the renderer is closed, so the deletes were silently skipped and the resources were left to the device. Track the goroutines and let them finish first, bounded so a wedged renderer cannot hang a reconnect. Both device close calls now report failure as well: alcCloseDevice frees nothing and returns false while a device still has contexts, buffers or sources outstanding, and dropping the handle after that leaks the device and everything it owns somewhere the Go collector cannot see it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
82ecf713ff
commit
7647fdb233
@@ -138,6 +138,10 @@ type Stream struct {
|
|||||||
recorderMu sync.RWMutex
|
recorderMu sync.RWMutex
|
||||||
errorFunc func(error) // called on capture errors
|
errorFunc func(error) // called on capture errors
|
||||||
recorder Recorder
|
recorder Recorder
|
||||||
|
// streamWG tracks the per-user goroutines started by OnAudioStream. They
|
||||||
|
// release their OpenAL source and buffers through the renderer, so Destroy
|
||||||
|
// must let them finish before it tears the renderer down.
|
||||||
|
streamWG sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(client *gumble.Client, inputDevice *string, outputDevice *string, test bool) (*Stream, error) {
|
func New(client *gumble.Client, inputDevice *string, outputDevice *string, test bool) (*Stream, error) {
|
||||||
@@ -393,13 +397,37 @@ func (s *Stream) getRecorder() Recorder {
|
|||||||
return s.recorder
|
return s.recorder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// destroyDrainTimeout bounds how long Destroy waits for the per-user audio
|
||||||
|
// goroutines to finish draining, so a wedged renderer cannot hang a reconnect.
|
||||||
|
const destroyDrainTimeout = 2 * time.Second
|
||||||
|
|
||||||
func (s *Stream) Destroy() {
|
func (s *Stream) Destroy() {
|
||||||
if s.link != nil {
|
if s.link != nil {
|
||||||
|
// Detach closes every per-user stream channel, which ends the
|
||||||
|
// goroutines started by OnAudioStream.
|
||||||
s.link.Detach()
|
s.link.Detach()
|
||||||
}
|
}
|
||||||
|
// Those goroutines delete their OpenAL source and buffers through
|
||||||
|
// s.render, which stops working the moment the renderer is closed below.
|
||||||
|
// Waiting for them here is what keeps the device's sources and buffers
|
||||||
|
// from being orphaned on every reconnect.
|
||||||
|
drained := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
s.streamWG.Wait()
|
||||||
|
close(drained)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-drained:
|
||||||
|
case <-time.After(destroyDrainTimeout):
|
||||||
|
log.Warn("Destroy: timed out waiting for audio stream goroutines; " +
|
||||||
|
"OpenAL sources and buffers may be released only by CloseDevice")
|
||||||
|
}
|
||||||
if s.deviceSource != nil {
|
if s.deviceSource != nil {
|
||||||
s.StopSource()
|
s.StopSource()
|
||||||
s.deviceSource.CaptureCloseDevice()
|
if !s.deviceSource.CaptureCloseDevice() {
|
||||||
|
log.Error("Destroy: closing capture device %q failed",
|
||||||
|
deviceName(s.inputDeviceName))
|
||||||
|
}
|
||||||
s.deviceSource = nil
|
s.deviceSource = nil
|
||||||
}
|
}
|
||||||
if s.deviceSink != nil {
|
if s.deviceSink != nil {
|
||||||
@@ -417,7 +445,14 @@ func (s *Stream) Destroy() {
|
|||||||
<-s.renderDone
|
<-s.renderDone
|
||||||
s.contextSink = nil
|
s.contextSink = nil
|
||||||
}
|
}
|
||||||
s.deviceSink.CloseDevice()
|
// alcCloseDevice returns ALC_FALSE and frees nothing while the device
|
||||||
|
// still has contexts, buffers or sources outstanding. Dropping the
|
||||||
|
// handle after that silently leaks the device and every buffer it
|
||||||
|
// owns, which is invisible to the Go GC, so at least report it.
|
||||||
|
if !s.deviceSink.CloseDevice() {
|
||||||
|
log.Error("Destroy: closing playback device %q failed; its OpenAL "+
|
||||||
|
"buffers cannot be reclaimed", deviceName(s.outputDeviceName))
|
||||||
|
}
|
||||||
s.deviceSink = nil
|
s.deviceSink = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -485,7 +520,9 @@ func (s *Stream) SetMicVolume(change float32, relative bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
|
func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
|
||||||
|
s.streamWG.Add(1)
|
||||||
go func(e *gumble.AudioStreamEvent) {
|
go func(e *gumble.AudioStreamEvent) {
|
||||||
|
defer s.streamWG.Done()
|
||||||
log.Info("audio stream started for user %s", e.User.Name)
|
log.Info("audio stream started for user %s", e.User.Name)
|
||||||
var source openal.Source
|
var source openal.Source
|
||||||
var emptyBufs openal.Buffers
|
var emptyBufs openal.Buffers
|
||||||
|
|||||||
Reference in New Issue
Block a user