Skip to content

Robust voice/gateway reconnection with backoff, keepalive, and async recording#18

Merged
ncoop57 merged 11 commits into
mainfrom
voice-robustness
Jul 8, 2026
Merged

Robust voice/gateway reconnection with backoff, keepalive, and async recording#18
ncoop57 merged 11 commits into
mainfrom
voice-robustness

Conversation

@ncoop57

@ncoop57 ncoop57 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Major reliability overhaul for both the gateway and voice websocket lifecycles.

Gateway ()

  • Exponential-backoff reconnect: _reconnect now retries 2**tries (capped 60s) instead of a single attempt; close failures are swallowed so a half-dead socket
    never blocks reconnection.
  • Structured _listen error handling: ConnectionClosed is caught explicitly; fatal codes (4004, 4010–4014) stop the client, everything else resumes (with
    session-id guard). op 7 now resumes with session. op 9 respects evt.d.
  • max_size=None on all websockets.connect calls to avoid frame-size limits on large gateway payloads.

Voice ()

  • Reconnect with rejoin retry loop: _reconnect(resume, rejoin) retries the gateway voice-state update until the main gateway can carry it, with backoff.
    on_state_update now detects kicks (channel_id=Nonedisconnect()) and channel moves; on_server_update detects server swaps and triggers a fresh
    (non-resume) reconnect.
  • join() stale-state recovery: on TimeoutError, detaches the old voice state and retries.
  • _listen fatal-code handling: 4014 (kicked/moved) stops the listener; other closes reconnect with appropriate resume strategy.
  • UDP keepalive: new _keepalive task pings the voice UDP socket every 5s so NAT mappings survive listen-only sessions.
  • CLIENT_DISCONNECT (op 13): drops the departed user's decoder/ssrc state.

DAVE / E2EE

  • _enc_opus and decrypt guard on self.dave existence + .ready/.epoch so non-E2EE sessions work cleanly.
  • decrypt parses RTP extension header length in 32-bit words instead of blindly skipping 4 bytes.
  • _wait_dave_ready treats epoch == 0 as "no E2EE" instead of hanging.

Recording & playback

  • _get_proc / _recv_audio / stop_recording are now async with asyncio.to_thread for all blocking ffmpeg/IO, so the event loop no longer stalls during
    multi-speaker capture.
  • send_frames(end=True) flushes 5 Opus silence frames before clearing the speaking flag, preventing unintended interpolation into the next transmission. Adds
    opus_silence constant.
  • disconnect() vs leave(): disconnect tears down ws/UDP/recording without notifying the gateway (used when Discord already removed us); leave notifies
    then delegates to disconnect.

API

  • VoiceClient constructor now takes guild_id explicitly (Bot.join_voice updated accordingly).

ncoop57 and others added 10 commits July 6, 2026 09:17
_listen scheduled _reconnect inline, whose first act was cancelling the
task it was running in, so voice reconnects died mid-flight. Recovery now
runs as a separate task, classifies close codes per the Discord docs
(4006/4009 rejoin, 4014/4004 stop and wait for gateway events, else
resume), retries failed connects with capped exponential backoff, and
handler errors no longer silently kill the listener.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A mid-session VOICE_SERVER_UPDATE (region change/failover) now triggers a
re-identify against the new endpoint+token without a gateway rejoin; a
null endpoint waits for the follow-up update instead of connecting to
'None'. VOICE_STATE_UPDATE for our own user now detects being kicked
(channel_id null -> clean disconnect via new VoiceClient.disconnect) and
being dragged to another channel (track the new channel id). leave() now
reuses disconnect() for teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Op 7 RECONNECT and unexpected closes (incl. the 4000 zombie close from
the heartbeat watchdog) now resume instead of re-identifying, so a flaky
network no longer burns the 1,000/day identify budget or drops the
session. Close codes 4007/4009 re-identify; 4004/4010-4014 (bad token/
shard/intents) stop instead of looping. Reconnects retry with capped
exponential backoff (reset on dispatch). websockets max_size=None so
large-guild GUILD_CREATE payloads can't kill the socket, and the
resume_url fallback no longer doubles the query string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
stdin.write into an ffmpeg pipe blocks when the pipe buffer fills, which
froze the event loop and zombied both gateways mid-recording. Packet
writes, the late-speaker silence prefix (now chunked instead of one
potentially huge allocation), p.wait(), and the final mix all run via
asyncio.to_thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rtpsize modes encrypt the extension payload, whose size is declared
in the extension preamble (32-bit words). The fixed [8:] skip corrupted
audio whenever the extension wasn't exactly two words and ate real Opus
data on extension-less packets. Also return the transport plaintext
directly when no DAVE session exists instead of crashing on self.dave.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per the voice docs, receivers interpolate the last frames into whatever
comes next unless the sender finishes with five 0xF8 0xFF 0xFE frames,
which was audible as a garbled tail after bot speech. send_frames/
send_pcm gain end= so chunked/streaming playback can hold one continuous
transmission and only close it out on the final chunk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A recorder-style bot sends nothing after IP discovery, so NAT/conntrack
entries expire after a few minutes and inbound audio silently stops. Send
an 8-byte counter ping every 5s (as discord.js does); restarted on every
UDP renegotiation and cancelled on disconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… op-13 cleanup

Bot.join_voice was missing the guild id and crashed with a TypeError.
disconnect() now stops an in-flight recording (mix=False) so ffmpeg
processes finalize their files instead of being orphaned. CLIENT_DISCONNECT
(op 13) now drops the departing user's decoder, timestamp, and SSRC state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live validation on a real guild surfaced three bugs: encode() DAVE-
wrapped frames whenever a protocol version was negotiated, but a bot
alone in a channel (or one mid-transition) has no MLS epoch and davey
raises NotReady - gate on session readiness instead and send plain opus
until the epoch starts, as official clients do. _wait_dave_ready had the
same flaw and made every resume time out for a solo bot. And when Discord
closed both websockets at once, the voice rejoin died on the main
gateway's dead socket - it now retries with backoff until the gateway can
carry it, and send_frames' silence-tail cleanup no longer raises from an
already-dead connection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ncoop57 ncoop57 added the enhancement New feature or request label Jul 6, 2026
@ncoop57 ncoop57 merged commit 543fab1 into main Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant