From 861538382974b645305e1354516eaa586f456791 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 11 Aug 2026 20:45:00 +0700 Subject: [PATCH] =?UTF-8?q?fix(goLive):=20STREAM=5FCREATE=20handshake=20?= =?UTF-8?q?=E2=80=94=20self=5Fvideo=20voice=20state=20+=20retry=20+=20send?= =?UTF-8?q?=20instrumentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - signalStream: flip voice state to self_video:true/self_deaf:false before STREAM_CREATE (Discord silently ignores the request while video disabled) - createStream: attach dispatch listeners before first signal (race), clean up listeners on timeout, retry STREAM_CREATE every 3s up to 4 attempts (upstream issue #217/#219 — Discord randomly drops the request) - sendOpcode: direct [goLive:Streamer] log bypassing bootstrap debug filter (proves op 18 is actually broadcast) --- .../discord-gateway/src/goLive/Streamer.ts | 126 +++++++++++++----- 1 file changed, 89 insertions(+), 37 deletions(-) diff --git a/services/discord-gateway/src/goLive/Streamer.ts b/services/discord-gateway/src/goLive/Streamer.ts index 1725ae1..844642e 100644 --- a/services/discord-gateway/src/goLive/Streamer.ts +++ b/services/discord-gateway/src/goLive/Streamer.ts @@ -77,6 +77,11 @@ export class Streamer { } sendOpcode(code: number, data: unknown): void { + // Direct instrumentation — bypasses the bootstrap debug filter (which + // drops messages without [VOICE / [ffmpeg / error / stream). + console.log( + `[goLive:Streamer] sendOpcode op=${code} d=${JSON.stringify(data)}`, + ); this.client.ws.broadcast({ op: code, d: data }); } @@ -153,14 +158,6 @@ export class Streamer { ); return; } - this.signalStream(); - const streamTimeout = setTimeout(() => { - reject( - new Error( - "Timed out waiting for STREAM_CREATE/STREAM_SERVER_UPDATE from Discord (stream handshake) — voice media session may not be active", - ), - ); - }, 12_000); const { guildId: clientGuildId, channelId: clientChannelId, @@ -175,40 +172,84 @@ export class Streamer { clientChannelId, (conn) => { clearTimeout(streamTimeout); + clearInterval(retryInterval); resolve(conn); }, ); this.voiceConnection.streamConnection = streamConn; - this._gatewayEmitter.on( - "STREAM_CREATE", - (d: { stream_key: string; rtc_server_id: string }) => { - const { channelId, guildId, userId } = parseStreamKey(d.stream_key); - if ( - clientGuildId !== guildId || - clientChannelId !== channelId || - clientUserId !== userId - ) { - return; - } - streamConn.serverId = d.rtc_server_id; - streamConn.streamKey = d.stream_key; - streamConn.setSession(session_id); - }, - ); - this._gatewayEmitter.on( - "STREAM_SERVER_UPDATE", - (d: { stream_key: string; endpoint: string; token: string }) => { - const { channelId, guildId, userId } = parseStreamKey(d.stream_key); - if ( - clientGuildId !== guildId || - clientChannelId !== channelId || - clientUserId !== userId - ) { - return; - } - streamConn.setTokens(d.endpoint, d.token); - }, + + // Attach listeners BEFORE the first signal so a fast dispatch can't + // be lost between signalStream() and listener registration. + const onStreamCreate = (d: { + stream_key: string; + rtc_server_id: string; + }) => { + const { channelId, guildId, userId } = parseStreamKey(d.stream_key); + if ( + clientGuildId !== guildId || + clientChannelId !== channelId || + clientUserId !== userId + ) { + return; + } + streamConn.serverId = d.rtc_server_id; + streamConn.streamKey = d.stream_key; + streamConn.setSession(session_id); + }; + const onStreamServerUpdate = (d: { + stream_key: string; + endpoint: string; + token: string; + }) => { + const { channelId, guildId, userId } = parseStreamKey(d.stream_key); + if ( + clientGuildId !== guildId || + clientChannelId !== channelId || + clientUserId !== userId + ) { + return; + } + streamConn.setTokens(d.endpoint, d.token); + }; + this._gatewayEmitter.on("STREAM_CREATE", onStreamCreate); + this._gatewayEmitter.on("STREAM_SERVER_UPDATE", onStreamServerUpdate); + + const cleanup = () => { + clearTimeout(streamTimeout); + clearInterval(retryInterval); + this._gatewayEmitter.removeListener("STREAM_CREATE", onStreamCreate); + this._gatewayEmitter.removeListener( + "STREAM_SERVER_UPDATE", + onStreamServerUpdate, + ); + }; + const streamTimeout = setTimeout(() => { + cleanup(); + reject( + new Error( + "Timed out waiting for STREAM_CREATE/STREAM_SERVER_UPDATE from Discord (stream handshake) — voice media session may not be active", + ), + ); + }, 12_000); + + // Discord sometimes drops the STREAM_CREATE request silently (upstream + // issue #217/#219) — resend a few times instead of giving up after one. + let attempt = 0; + const retryInterval = setInterval(() => { + attempt += 1; + if (attempt >= 4) { + clearInterval(retryInterval); + return; + } + console.log( + `[goLive:Streamer] createStream: retrying STREAM_CREATE (attempt ${attempt + 1}/4)`, + ); + this.signalStream(); + }, 3_000); + console.log( + `[goLive:Streamer] createStream: sending STREAM_CREATE (attempt 1/4)`, ); + this.signalStream(); }); } @@ -261,6 +302,17 @@ export class Streamer { channelId: channel_id, botId: user_id, } = this.voiceConnection; + // Mimic the real Discord client when starting Go Live: flip the voice + // state to video-enabled (and un-deafen) BEFORE requesting the stream. + // Discord's gateway silently ignores STREAM_CREATE while the user's + // voice state still has self_video: false. + this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, { + guild_id, + channel_id, + self_mute: false, + self_deaf: false, + self_video: true, + }); this.sendOpcode(GatewayOpCodes.STREAM_CREATE, { type, guild_id,