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(),
status: "playing",
};
playback.done.finally(() => {
this.screenPlayback = null;
if (currentTrackItem?.mode === "screen") {
currentTrackItem = null;
}
});
playback.done
.catch((err) => {
this.logger.error(
{ 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");
return { id: cmd.id, success: true, data: buildStatusPayload() };
} catch (err) {
@@ -55,6 +55,12 @@ export interface ScreenSharePlayback {
stop(): void;
}
export interface ScreenShareVoiceStatus {
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
}
export interface ScreenShareController {
isActive(): boolean;
start(source: string): Promise<ScreenSharePlayback>;
@@ -60,6 +60,22 @@ export class ScreenShareController {
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, {
encoder: Encoders.software({ x264: { preset: "superfast" } }),
width: 1280,
@@ -74,15 +90,43 @@ export class ScreenShareController {
let stopped = false;
const done = playStream(output, this.streamer, {
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 = {
done,
stop: () => {
if (stopped) return;
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;
},
};