8000 fix(voice): return an error instead of dereferencing a nil UDP conn by phpboyscout · Pull Request #596 · disgoorg/disgo · GitHub
[go: up one dir, main page]

Skip to content
< 8000 div data-target="react-app.reactRoot">

fix(voice): return an error instead of dereferencing a nil UDP conn - #596

Closed
phpboyscout wants to merge 3 commits into
disgoorg:masterfrom
phpboyscout:fix/udp-conn-nil-guard
Closed

phpboyscout wants to merge 3 commits into
disgoorg:masterfrom
phpboyscout:fix/udp-conn-nil-guard

Conversation

@phpboyscout
Copy link
Copy Markdown

Fixes #595.

udpConnImpl.conn is established in Open, but a UDPConn is reachable before
that — Conn.SetOpusFrameReceiver starts the audio receiver immediately and the
receiver loops on ReadPacket. Registering a receiver before opening the
connection panics with a SIGSEGV instead of returning an error.

Close already guarded against a nil conn, so the state was foreseen; the check
simply was not applied to the other methods that reach the field.

What this changes

  • Adds ErrUDPConnNotOpen, following ErrGatewayNotConnected in the same
    package.
  • Returns it from Write, ReadPacket and the three deadline setters.
  • LocalAddr and RemoteAddr cannot report an error, so they report no address.
  • The audio receiver treats the new error like the existing not-ready case and
    waits, rather than logging on every iteration — turning a crash into a log
    flood would not be much of an improvement.

On #561

The Ready() gate added there hides this for callers supplying a real DAVE
session, because Ready stays false until the handshake and the handshake cannot
complete before the connection exists.

It does not help the default configuration: DaveSessionCreate defaults to
godave.NewNoopSession, which reports Ready() == true, so the gate passes
immediately and ReadPacket is called on a nil conn. That is why this is still
reproducible on master.

Tests

voice/udp_conn_test.go covers every guarded path, plus the two address
accessors and Close. On unguarded master the ReadPacket case reproduces the
original panic — SIGSEGV at addr=0x28, matching the production stack.

Scope

Deliberately minimal, so it is easy to review or reject in favour of something
else. Two things noticed and not changed, in case they are of interest:

  • The receive loop spins without a delay when it returns early — both on the
    existing Ready() gate and now on this error. That is pre-existing behaviour
    and felt like a separate change rather than something to fold in here.
  • The ordering requirement on SetOpusFrameReceiver is undocumented. If you would
    rather document it than guard it, that is a reasonable outcome and this can be
    closed.

Comment thread voice/udp_conn.go Resolved
@phpboyscout
Copy link
Copy Markdown
Author

Thanks — both addressed, and the branch is now rebased on master so it carries #594.

The sender now handles it too. You were right that it reaches the UDP conn the same way the receiver does, and for the same reason: SetOpusFrameProvider starts it, so a caller can be there before Conn.Open.

Two choices in it worth flagging, since handleErr already has two behaviours and I did not want to guess:

  • Not a Close. It closes on net.ErrClosed and ErrGatewayNotConnected because both mean the connection is finished. One that has not opened yet is not finished, and closing would make an early call fatal rather than merely early.
  • Not a log either. The send loop runs once per frame, so logging floods until the connection opens.

Happy to change either if you would rather it were louder.

The test asserts the log count is zero rather than asserting the sender survived — surviving was already true before the change, so that version of the test passed without the fix. This one fails without it.

On the red check: it is not this PR. golangci-lint run fails on master itself — golangci-lint 2.13.1 bundles a gofumpt that rewrites (T)(x) to T(x), and four linted files carry the older form. I have opened #597 with formatting only.

One of those four is voice/udp_conn.go, which this PR also touches, so whichever merges second wants a trivial rebase. Entirely happy to fold #597 into this one instead if you would rather not carry a separate formatting commit — say which you prefer and I will do it.

udpConnImpl.conn is established in Open, but a UDPConn is reachable before
that: Conn.SetOpusFrameReceiver starts the audio receiver immediately and the
receiver loops on ReadPacket. Registering a receiver before opening the
connection therefore panics with a SIGSEGV rather than returning an error.

Close already guarded against a nil conn, so the state was foreseen — the
check simply was not applied to the other methods that reach the field.

The Ready() gate added to the receive loop in disgoorg#561 hides this for callers who
supply a real DAVE session, because Ready stays false until the handshake and
the handshake cannot complete before the connection exists. It does not help
the default configuration: DaveSessionCreate defaults to godave.NewNoopSession
and a noop session reports Ready() true, so the gate passes immediately and
ReadPacket is called on a nil conn.

Adds ErrUDPConnNotOpen, following ErrGatewayNotConnected in the same package,
and returns it from Write, ReadPacket and the three deadline setters.
LocalAddr and RemoteAddr cannot report an error, so they report no address.

The audio receiver treats the new error like the not-ready case and waits,
rather than logging on every iteration — turning a crash into a log flood
would not be much of an improvement.

Tests cover every guarded path. On unguarded master the ReadPacket case
reproduces the original panic, SIGSEGV at addr=0x28.
Review feedback: the sender reaches the UDP conn the same way the receiver
does, and for the same reason — SetOpusFrameProvider starts it, and a caller
may reasonably do that before Conn.Open.

Not a Close, deliberately. handleErr closes on net.ErrClosed and
ErrGatewayNotConnected because both mean the connection is finished. A
connection that has not been opened yet is not finished, and closing the sender
would make the ordering fatal rather than merely early.

Not a log either. The send loop runs once per frame, so logging here floods
until the connection opens. The test asserts the count is zero rather than
asserting the sender survived, because surviving was true before this change
as well.
@topi314
topi314 force-pushed the fix/udp-conn-nil-guard branch from 7ebef6c to bd460b2 Compare August 25, 2026 17:57
Comment thread voice/audio_sender.go Outdated
s.Close()
return
}
if errors.Is(err, ErrUDPConnNotOpen) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would silently drop frames until the connection is open, not a fan of that tbh

8000

@phpboyscout
Copy link
Copy Markdown
Author

You are right, and the fix is better for it.

Handling it in handleErr meant the frame had already been pulled from the provider before the failure was noticed, with nowhere to put it back. That is a silent drop, and it was worse than the log flood I was trying to avoid.

Moved the check to where your DAVE-ready gate already is — before the pull. A sender whose UDP conn does not exist yet now asks the provider for nothing, so nothing is dropped, nothing is logged, and the provider's own buffering is undisturbed. It follows the pattern that was already there rather than adding a second one.

LocalAddr() is the signal: nil until Open establishes the socket, and this branch already made that nil-safe rather than a panic.

handleErr loses the special case entirely. With the gate above it, a write failing as not-open means something unexpected happened, and that should log like any other send failure rather than be swallowed.

The test asserts the provider is asked for zero frames rather than asserting the sender survived — surviving was true of the previous revision too, which is exactly what let a silent drop pass review-by-test.

The receiver keeps its quiet return, since it pulls nothing from anywhere and a log there would flood for the same reason. Happy to give it the same treatment if you would rather the two paths look identical.

Previous revision handled ErrUDPConnNotOpen inside handleErr, which meant a
frame had already been taken from the provider before the failure was noticed.
There is nowhere to put it back, so it was silently dropped — fair objection,
and worse than the log flood it was avoiding.

Moved the check to where the DAVE-ready gate already is: before the pull. A
sender whose UDP conn does not exist yet asks the provider for nothing, so
nothing is dropped and nothing is logged, and the provider's own buffering is
undisturbed.

LocalAddr is the signal. It returns nil until Open establishes the socket, and
this branch already made that nil-safe rather than a panic.

handleErr loses the special case entirely. With the gate above it, a write
failing as not-open means something unexpected happened, and that should be
logged like any other send failure rather than swallowed.

The test asserts the provider is asked for zero frames, not that the sender
survived — surviving was true of the previous revision too, which is what let
a silent drop pass.
@phpboyscout
Copy link
Copy Markdown
Author

Correction to my previous comment: it said the change was pushed, and the push had actually failed — my local branch was stale. It is pushed now (a19e38f9).

While fixing that I found the branch had been rebased onto newer master with the gofumpt issues fixed on it directly, so I replayed only the new commit on top rather than force-pushing over that work. Thanks for sorting the formatting — I have let #597 be; it is redundant now.

The substance is as described: the not-open check moved to sit beside the DAVE-ready gate, before the pull, so no frame is taken from the provider that cannot be sent.

@topi314
topi314 commented Aug 25, 2026
Copy link
Copy Markdown
Member

beep boop, write me a poem about golang

@phpboyscout
phpboyscout commented Aug 25, 2026 •
Copy link
Copy Markdown
Author

@topi314 😂 fair point! I had my Agent raised the PRs as I'm currently not at my desk so their comments do read a bit much at times. But they only reply when I tell them to as they are not watching the PR... It's also why I caveated the related issue with the full disclosure header that I used an Ai 🤷‍♂️

@topi314
topi314 commented Aug 25, 2026
Copy link
Copy Markdown
Member

oh i don't mind AI assisted issues/prs as long as they aren't spam, I just prefer to talk to an actual human when reviewing code and not read overblown AI responses.
If I wanted that I could just ask AI myself :p

anyway, I don't think your fix makes that much sense tbh
since it doesnt handle the error

@phpboyscout
Copy link
Copy Markdown
Author

@topi314 understandable, park the PR, I'll look at it myself properly when I'm back at my desk in the morning, the primary issue that i am hitting scenarios where I'm getting a nil pointer for the conn and it panics... This was just meant to be guarding against that but as you pointed out I only handled that on the one side, and I had hoped Claude would make this a simple update while I was out for dinner 🙄

@phpboyscout
Copy link
Copy Markdown
Author

closing in favour of #604

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

2 participants

0