fix(voice): screen share crashed gateway — Streamer never joined voice + unhandledRejection

Root cause: ScreenShareController created @dank074 Streamer but never called
streamer.joinVoiceChannel() — playStream threw 'Bot is not connected to a
voice channel', and since the code only used .finally() (no .catch), the
rejection became an unhandledRejection that took down the whole gateway
(graceful shutdown triggered, systemd restarted).

Fixes:
1. Resolve active channel + streamer.joinVoiceChannel(channel) before
   prepareStream/playStream (dank074 needs its OWN WebRTC voice connection).
2. .catch() on the playStream done promise — log + kill ffmpeg instead of
   crashing the process.
3. .catch() on playback.done in media.handler too.
4. stop() now kills ffmpeg AND stops the streamer's voice connection.
This commit is contained in:
asepharyana
2026-08-03 08:05:02 +07:00
parent a57eeb2e22
commit 53f26185bc
3 changed files with 67 additions and 10 deletions
@@ -178,12 +178,19 @@ export class MediaHandler {
addedAt: Date.now(), addedAt: Date.now(),
status: "playing", status: "playing",
}; };
playback.done.finally(() => { playback.done
this.screenPlayback = null; .catch((err) => {
if (currentTrackItem?.mode === "screen") { this.logger.error(
currentTrackItem = null; { error: err instanceof Error ? err.message : String(err) },
} "Screen playback promise rejected",
}); );
})
.finally(() => {
this.screenPlayback = null;
if (currentTrackItem?.mode === "screen") {
currentTrackItem = null;
}
});
this.logger.info({ url }, "Screen share started"); this.logger.info({ url }, "Screen share started");
return { id: cmd.id, success: true, data: buildStatusPayload() }; return { id: cmd.id, success: true, data: buildStatusPayload() };
} catch (err) { } catch (err) {
@@ -55,6 +55,12 @@ export interface ScreenSharePlayback {
stop(): void; stop(): void;
} }
export interface ScreenShareVoiceStatus {
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
}
export interface ScreenShareController { export interface ScreenShareController {
isActive(): boolean; isActive(): boolean;
start(source: string): Promise<ScreenSharePlayback>; start(source: string): Promise<ScreenSharePlayback>;
@@ -60,6 +60,22 @@ export class ScreenShareController {
this.streamer = new Streamer(this.client); this.streamer = new Streamer(this.client);
} }
// The dank074 Streamer needs its OWN voice connection — it cannot reuse
// the @discordjs/voice connection owned by VoiceController/recorder.
// Resolve the channel object and join via the Streamer's raw gateway
// path (joinVoiceChannel → joinVoice → WebRTC).
const guild = this.client.guilds.cache.get(status.activeGuildId);
const channel = guild?.channels.cache.get(status.activeChannelId);
if (
!channel ||
(channel.type !== "GUILD_VOICE" && channel.type !== "GUILD_STAGE_VOICE")
) {
throw new Error(
`Voice channel ${status.activeChannelId} not found for screen share`,
);
}
await this.streamer.joinVoiceChannel(channel);
const { command, output } = prepareStream(directUrl, { const { command, output } = prepareStream(directUrl, {
encoder: Encoders.software({ x264: { preset: "superfast" } }), encoder: Encoders.software({ x264: { preset: "superfast" } }),
width: 1280, width: 1280,
@@ -74,15 +90,43 @@ export class ScreenShareController {
let stopped = false; let stopped = false;
const done = playStream(output, this.streamer, { const done = playStream(output, this.streamer, {
type: "go-live", type: "go-live",
}).finally(() => { })
this.active = null; .catch((err) => {
}); // Never let a stream failure become an unhandledRejection — that
// crashed the whole gateway. Log + surface via the done promise.
const message = err instanceof Error ? err.message : String(err);
this.logger.error(
{ error: message, source },
"Screen stream failed during playback",
);
if (!stopped) {
stopped = true;
try {
command.kill("SIGTERM");
} catch {
/* already dead */
}
}
})
.finally(() => {
this.active = null;
});
this.active = { this.active = {
done, done,
stop: () => { stop: () => {
if (stopped) return; if (stopped) return;
stopped = true; stopped = true;
command.kill("SIGTERM"); try {
command.kill("SIGTERM");
} catch {
/* already dead */
}
// Leave the voice channel the Streamer joined (its own connection).
try {
this.streamer?.voiceConnection?.stop();
} catch {
/* already gone */
}
this.active = null; this.active = null;
}, },
}; };