Robust voice/gateway reconnection with backoff, keepalive, and async recording#18
Merged
Conversation
_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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Major reliability overhaul for both the gateway and voice websocket lifecycles.
Gateway ()
_reconnectnow retries2**tries(capped 60s) instead of a single attempt; close failures are swallowed so a half-dead socketnever blocks reconnection.
_listenerror handling:ConnectionClosedis caught explicitly; fatal codes (4004, 4010–4014) stop the client, everything else resumes (withsession-id guard).
op 7now resumes with session.op 9respectsevt.d.max_size=Noneon allwebsockets.connectcalls to avoid frame-size limits on large gateway payloads.Voice ()
_reconnect(resume, rejoin)retries the gateway voice-state update until the main gateway can carry it, with backoff.on_state_updatenow detects kicks (channel_id=None→disconnect()) and channel moves;on_server_updatedetects server swaps and triggers a fresh(non-resume) reconnect.
join()stale-state recovery: onTimeoutError, detaches the old voice state and retries._listenfatal-code handling:4014(kicked/moved) stops the listener; other closes reconnect with appropriate resume strategy._keepalivetask 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_opusanddecryptguard onself.daveexistence +.ready/.epochso non-E2EE sessions work cleanly.decryptparses RTP extension header length in 32-bit words instead of blindly skipping 4 bytes._wait_dave_readytreatsepoch == 0as "no E2EE" instead of hanging.Recording & playback
_get_proc/_recv_audio/stop_recordingare now async withasyncio.to_threadfor all blocking ffmpeg/IO, so the event loop no longer stalls duringmulti-speaker capture.
send_frames(end=True)flushes 5 Opus silence frames before clearing the speaking flag, preventing unintended interpolation into the next transmission. Addsopus_silenceconstant.disconnect()vsleave():disconnecttears down ws/UDP/recording without notifying the gateway (used when Discord already removed us);leavenotifiesthen delegates to
disconnect.API
VoiceClientconstructor now takesguild_idexplicitly (Bot.join_voiceupdated accordingly).