release the audio stream a reconnect replaces

connect assigned b.Stream without releasing the stream already there, and
OnDisconnect started a reconnect loop unconditionally, so two connects could
race to install a stream. The loser was simply overwritten.

An overwritten stream is never destroyed, so it keeps its OpenAL device, its
render thread, and its entry in the shared audio listener list, which only
Destroy removes. It therefore stays subscribed for the life of the process
and every later audio packet from every user is dispatched to it as well: a
goroutine, a packet queue and a set of playback buffers per orphan, per
user. The file playback stream and the tone test saver were replaced the
same way.

Release whatever is being replaced, and allow only one reconnect loop at a
time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon McGinty
2026-08-24 12:32:06 -04:00
co-authored by Claude Opus 5
parent da1c6bdc26
commit 77fad24560
2 changed files with 40 additions and 1 deletions
+2
View File
@@ -91,6 +91,8 @@ type Barnard struct {
reconnectStop chan struct{} reconnectStop chan struct{}
reconnectStopOnce sync.Once reconnectStopOnce sync.Once
reconnectMutex sync.Mutex
reconnecting bool
} }
// cleanupConnectionAudio releases connection-owned audio resources before a // cleanupConnectionAudio releases connection-owned audio resources before a
+38 -1
View File
@@ -104,6 +104,7 @@ func (b *Barnard) connect(reconnect bool) bool {
// Initialize file player // Initialize file player
b.FileStreamMutex.Lock() b.FileStreamMutex.Lock()
previousFile := b.FileStream
b.FileStream = fileplayback.New(b.Client) b.FileStream = fileplayback.New(b.Client)
b.FileStream.SetErrorFunc(func(err error) { b.FileStream.SetErrorFunc(func(err error) {
// Disable stereo when file finishes or errors // Disable stereo when file finishes or errors
@@ -113,9 +114,25 @@ func (b *Barnard) connect(reconnect bool) bool {
stream.SetFilePlayer(b.FileStream) stream.SetFilePlayer(b.FileStream)
b.FileStreamMutex.Unlock() b.FileStreamMutex.Unlock()
b.connectionMutex.Lock() b.connectionMutex.Lock()
previousStream := b.Stream
b.Stream = stream b.Stream = stream
b.connectionMutex.Unlock() b.connectionMutex.Unlock()
// A disconnect that lands while the OpenAL devices are opening starts a
// second reconnect, so two connects can race to install a stream. The one
// that loses must be released here: an orphaned stream keeps its OpenAL
// device, its render thread and — because only Destroy detaches it — its
// entry in the shared audio listener list, so every later audio packet
// from every user is dispatched to it as well, for the life of the
// process. Release outside the locks, since Destroy waits on the
// per-user audio goroutines.
if previousFile != nil {
_ = previousFile.Stop()
}
if previousStream != nil {
previousStream.Destroy()
}
b.setConnected(true) b.setConnected(true)
// Dial delivers OnConnect before connect creates the OpenAL stream, so // Dial delivers OnConnect before connect creates the OpenAL stream, so
// start auto-transmit here as well for initial connections and reconnects. // start auto-transmit here as well for initial connections and reconnects.
@@ -223,7 +240,27 @@ func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) {
b.UiTree.Rebuild() b.UiTree.Rebuild()
b.Ui.Refresh() b.Ui.Refresh()
}) })
go b.reconnectGoroutine() b.startReconnect()
}
// startReconnect launches the reconnect loop unless one is already running.
// Disconnect notifications can arrive more than once for a connection, and
// every extra loop is another connect racing to install its own audio stream.
func (b *Barnard) startReconnect() {
b.reconnectMutex.Lock()
defer b.reconnectMutex.Unlock()
if b.reconnecting {
return
}
b.reconnecting = true
go func() {
defer func() {
b.reconnectMutex.Lock()
b.reconnecting = false
b.reconnectMutex.Unlock()
}()
b.reconnectGoroutine()
}()
} }
func (b *Barnard) reconnectGoroutine() { func (b *Barnard) reconnectGoroutine() {