Discord streams: start without a viewer, and never open a second connection

A stream's encrypted group forms when somebody joins to watch, so waiting
for it before saying the connection is ready meant a stream started into a
room nobody was watching gave up after twenty seconds. Ready now means the
connection is up; frames before the group exists are dropped by the sender
as before.

The guard against a second connection checked readiness too, so a
STREAM_UPDATE in the seconds before the group formed opened a duplicate and
Discord invalidated the first (4006). It now checks for a live connection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-01 09:40:21 -04:00
co-authored by Claude Opus 5.5
parent ac6ef9cbb5
commit 7b6441497f
2 changed files with 24 additions and 25 deletions
+19 -9
View File
@@ -259,7 +259,12 @@ pub async fn note_stream(state: &AppState, account_id: &str, dispatch: &str, d:
// watching - found a complete entry and opened a second connection with
// the same voice session. Discord answers that by invalidating one of
// them, which is where the 4006 in the log came from.
if sending(account_id).await || connecting(account_id, stream_key) {
//
// "Has a live connection", not "is sending": a connection is up for
// some seconds before its encryption group is ready to send, and a
// STREAM_UPDATE in that gap - the other end of the call being told the
// stream exists is enough - opened a duplicate that invalidated the first.
if has_connection(account_id) || connecting(account_id, stream_key) {
let mut all = pending().lock().unwrap();
all.entry(slot(account_id, stream_key)).or_default().absorb(d);
return;
@@ -369,6 +374,12 @@ fn senders() -> &'static std::sync::Mutex<std::collections::HashMap<String, std:
SENDERS.get_or_init(Default::default)
}
/// Whether this account has a stream connection up, whether or not its group
/// is ready yet.
fn has_connection(account_id: &str) -> bool {
senders().lock().unwrap().get(account_id).is_some_and(|s| s.is_live())
}
/// Whether the next stream from each account carries the computer's sound.
fn sound() -> &'static std::sync::Mutex<std::collections::HashMap<String, bool>> {
static SOUND: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, bool>>> = std::sync::OnceLock::new();
@@ -431,15 +442,14 @@ pub fn take_last_error(account_id: &str) -> Option<String> {
/// Whether this account has a stream connection ready for frames.
///
/// Ready means the end-to-end encrypted group has formed, not merely that a
/// socket is open: a picture encrypted for a group that does not exist yet is
/// one nobody can read.
/// Ready means the connection is up, not that its encrypted group has
/// formed. The group forms when somebody joins to watch - a stream nobody is
/// watching has nobody to agree keys with - so waiting for it before
/// encoding meant a stream started into an empty room gave up after twenty
/// seconds. Frames sent before the group exists are dropped by the sender,
/// and the next keyframe is two seconds behind the first viewer.
pub async fn sending(account_id: &str) -> bool {
let sender = senders().lock().unwrap().get(account_id).cloned();
match sender {
Some(sender) => sender.ready().await,
None => false,
}
has_connection(account_id)
}
/// Closes the connection, without telling the gateway - `stop` does that.
+5 -16
View File
@@ -197,26 +197,15 @@ impl StreamSender {
Ok(())
}
/// Whether a frame handed in now would actually reach anybody.
///
/// A connection exists some seconds before its group does, and a picture
/// encrypted for a group that has not formed is one nobody can read - so
/// the window waits for this rather than for the socket.
pub async fn ready(&self) -> bool {
if !self.live.load(Ordering::Relaxed) {
return false;
}
match self.dave.lock().await.as_ref() {
Some(dave) => dave.ready(),
// No group asked for, so nothing to wait on.
None => true,
}
}
pub fn stop(&self) {
self.live.store(false, Ordering::Relaxed);
}
/// Whether the connection is still up, ready or not.
pub fn is_live(&self) -> bool {
self.live.load(Ordering::Relaxed)
}
/// Sends what the computer is playing along with the picture, until the
/// stream ends.
///