8000 fix(voice): start audio loops once the udp conn can back them by phpboyscout · Pull Request #604 · disgoorg/disgo · GitHub
[go: up one dir, main page]

Skip to content

fix(voice): start audio loops once the udp conn can back them - #604

Open
phpboyscout wants to merge 2 commits into
disgoorg:masterfrom
phpboyscout:fix/audio-loop-start-ordering
Open

phpboyscout wants to merge 2 commits into
disgoorg:masterfrom
phpboyscout:fix/audio-loop-start-ordering

Conversation

@phpboyscout
Copy link
Copy Markdown

Fixes #595

@topi314 Following our conversation yesterday I went digging into this more and I identified something interesting when trying to make sense of my AI agents mess of #596.

The original issue was that I originally had a setup I builds the audio pipeline before it connects:

conn := client.VoiceManager.CreateConn(guildID)

// set up where the audio goes, then go and get some
conn.SetOpusFrameReceiver(recorder)

if err := conn.Open(ctx, channelID, false, false); err != nil {
    return err
}

That paniced on a nil pointer and took the process with it. The docs show doing the open first, but I didnt actually see that till this morning, and (at least for me personally) this way round seemed more intuitive. But this is what the original PR was meant to address, the handling of that nil pointer if you order things in the way I do above. (There is probably an easier version of this PR that just involves documentation and something to enforce ordering but I didnt want to implement a contract change and trigger a major release)

Both setters start their loop the moment you call them, but that loop reads and writes the UDP conn, and the conn isn't dialled until the gateway's ready event. The encrypter isn't there either until the session description lands. So if you register audio before Conn.Open then the loop spends the whole handshake running against nils.

I did wonder whether the DAVE().Ready() check at the top of both loops already
covered it. It doesn't! The default is godave.NewNoopSession, whose Ready()
returns true unconditionally, so that gate is open on every default setup.

The solution

Instead of guarding the dereference (as in #596 ) and returning an error. This tries to address the allow looser ordering instead. The loops start start conn can actually back them, so registering early works instead of panicing, and there's no error to handle because the state that caused it doesn't arise.

The documented order is untouched. Conn.Open only returns after the session description, so a setter called after it starts its loop straight away, exactly as now. TestAudioLoopsStartWhenConnIsReady passes on master as well as on this branch, which is what pins it.

Two numbers:

  • The example i gave above currently panics on master. TestRealAudioLoopsDoNotRunBeforeOpen reproduces it against the real types.
  • With one SetOpusFrameProvider racing the handshake, over 300,000 test runs,
    the current master starts a loop against a conn that can't back it 3,844 times. My proposed solution in this PR results in zero

To make this work for both audio Sender and Reciever, Open() now creates the cancel func before it spawns the goroutine rather than inside it, and introduced a small single purpose mutex. Without it, Open racing Close panics on a nil cancel func on master, and with just a nil check it still races. -race catches that within a couple of hundred iterations.

It isn't just a case of me tidying up either. Once the loop has started, if later than the setter that registered it, you can end up closing one that never actually ran, and on master that calls a nil func and panics. The two calls also end up on different goroutines: the voice gateway is what starts a loop registered early, while the caller is what closes it. So the field needed guarding either way. Without the mutex, -race picks it up within a couple of hundred iterations.

What it doesn't do

I kept this deliberately narrow. These are all untouched and behave exactly as they do on master today:

  • Audio still doesn't restart by itself after a gateway reconnect. I left that path alone entirely.
  • A loop that's already running when a re-dial fails still panics on its next write.
  • There's no new synchronisation beyond the audio fields themselves.

Happy to look at any of those separately if you want them, but they each felt like a different change to me. Feel free to close/reject #596

SetOpusFrameProvider and SetOpusFrameReceiver started their goroutine
loops immediately, but the UDP conn is not dialled until the gateway's
ready event and its encrypter is not built until the session
description. Registering audio before Conn.Open dereferenced nil in a
background goroutine and killed the process.

The loops now start once both exist, so registering early is supported
rather than fatal. The documented order is unchanged: Conn.Open returns
only after the session description, so a setter called after it starts
its loop straight away as before.

Open on the loops assigns the cancel func before spawning the goroutine,
and a mutex guards it, because deferring the start makes it reachable to
close a loop that never ran. Open and Close also land on different
goroutines once a loop is registered early. On master that pairing
panics on a nil cancel func.
@phpboyscout
phpboyscout force-pushed the fix/audio-loop-start-ordering branch from 9126297 to 1b98946 Compare October 2, 2026 10:59
@phpboyscout
Copy link
Copy Markdown
Author

Rebased onto current master. The only conflict was with disgoorg/disgo#612 in AudioSender.Close, and I've kept both changes: the nil-safe cancel, then closing the opus provider. The reproduction test still panics on master as of today. Happy to adjust anything.

@thomas-vilte
Copy link
Copy Markdown
Contributor

tested this on the branch and with current master merged in (after #588 and #634):

  • build, vet and go test -race ./... all pass.
  • copied conn_test.go onto master: 11 tests fail there, including one with a nil pointer panic. all pass here.
  • calling SetOpusFrameProvider twice and then Close() hits a data race and a nil pointer panic on master. passed 100/100 runs with -race here.
  • the provider gets closed exactly once per sender. a nil provider still panics, but in the caller, so it can be recovered.

I did find one rare race, but I wouldn't block the merge on it. if a sender was registered before Open() and SetOpusFrameProvider gets called again just as the session description arrives, startAudioOn can pick up the old sender before the setter replaces and closes it. then startAudioOn opens that old sender anyway. its loop keeps running, with no reference left to close it.

with a stub sender that records Open() after Close(), I hit this 11 times in 4M runs:

old := &trackSender{}
c := &connImpl{config: config, audioSender: old, udpOpened: true}
go func() { start.Wait(); c.startAudioOn(&c.secretKeySet) }()
go func() { start.Wait(); c.SetOpusFrameProvider(nil) }()
// old.Open() called after old.Close()

a closed flag in the default sender/receiver should cover it: set it in Close() under s.mu, then check it in Open(). that would avoid calling user code under audioMu.

Conn.Close() still doesn't close the sender/receiver. I'll send that fix separately on top of this once it's merged. with closeAudio(), it's a one-liner.

Conn calls Open on a sender or receiver outside audioMu, so a
replacement can close it first. Close then found no cancel func, and
the loop Open started had nothing left to stop it. The default sender
and receiver now record that they were closed: Open does nothing after
Close, and a second Close is a no-op, so the provider or receiver is
closed once even when the loop already closed itself on a closed udp
conn.
@phpboyscout
Copy link
Copy Markdown
Author

Hey @thomas-vilte Thanks for testing this so thoroughly, and for the repro. I’ve added a second commit on top with the closed flag as you suggested: Close sets it under s.mu, and Open returns without starting the loop once it’s set, so nothing user-provided runs under audioMu.

The same flag makes a second Close a no-op. The sender and receiver both close themselves on a closed UDP conn, so before this the conn could close the provider or receiver a second time afterwards.

Two new tests on the real sender and receiver: Open after Close doesn’t start the loop, and closing twice closes the provider and receiver once. I broke the flag six ways (each check dropped, in the sender and the receiver) and the tests caught all six. go test -race ./... passes on the branch and with current master merged in, and golangci-lint is clean.

This covers the default sender and receiver only. A custom AudioSender or AudioReceiver can still be opened after Close in that window, so it needs the same guard. Happy to say so in their doc comments if you think it’s worth it.

Sounds good on Conn.Close(); with closeAudio() in place it should be the one-liner you describe.

sealbro added a commit to sealbro/disgo that referenced this pull request Oct 27, 2026
@thomas-vilte
Copy link
Copy Markdown
Contributor

thanks, retested with the new commit. both new tests fail without the flag and pass with it. my double close and nil provider tests still behave as expected: provider closed once, panic stays in the caller. -race also passes with current master merged in, both with and without #559. lgtm

and yeah, a line in the AudioSender/AudioReceiver docs saying Open() may be called after Close() and should do nothing in that case would help anyone writing a custom implementation.

@topi314
topi314 commented Oct 28, 2026
Copy link
Copy Markdown
Member

this needs to be rebased now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

voice: ReadPacket dereferences a nil UDP conn, panicking if a receiver is registered before Open

3 participants

0