fix(selfbot): gate broadcast 'live' on real Go-Live WebRTC connect

isActive() was a local flag set at start() time, and start() fired playStream
without awaiting the actual stream connection, so 'broadcasting' was reported
even when nothing reached Discord (auto-start log + ffmpeg/NVENC running are not
proof of transmission).

start() now waits for the streaming library's real readiness signal (the stream
connection's WebRTC reaching "connected") before declaring live. On timeout it
logs a compact connection-state diagnostic, tears the local ffmpeg pipeline down
immediately, and returns an explicit failure. isActive() reports the real live
state. Timeout is config-driven (STREAM_READY_TIMEOUT_MS, default 25s). Adds a
test for the timeout/teardown path and updates the existing leak-teardown test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
javis-bot
2026-06-13 15:22:52 +09:00
parent 6f4464b2c4
commit 5961faed38
3 changed files with 164 additions and 13 deletions

View File

@@ -46,6 +46,11 @@ export const config = {
// selfbot backend (ToS-risk; use a throwaway account token, never your main)
selfbotToken: opt("DISCORD_SELFBOT_TOKEN"),
// How long to wait for the Go-Live stream WebRTC to actually reach
// "connected" before declaring the broadcast failed (and tearing the local
// ffmpeg pipeline down). Guards against reporting "live" when nothing reaches
// Discord.
streamReadyTimeoutMs: parseInt(opt("STREAM_READY_TIMEOUT_MS", "25000"), 10),
// Use NVENC hardware encode + hw-accelerated decode for the stream (RTX 5050).
streamHw: opt("STREAM_HW", "1") !== "0",
// Capture desktop audio into the broadcast so the stream has sound. Pulls the

View File

@@ -33,6 +33,11 @@ test("a self-ended stream tears down the capture pipeline (no ffmpeg leak)", asy
}
async joinVoice() {}
leaveVoice = leaveVoice;
// The Go-Live readiness signal start() now waits on: the stream
// connection's WebRTC reaching "connected".
get voiceConnection() {
return { streamConnection: { webRtcConn: { ready: true } } };
}
},
prepareStream: () => ({ command: { on() {} }, output: {} }),
playStream: () => playPromise,
@@ -46,6 +51,7 @@ test("a self-ended stream tears down the capture pipeline (no ffmpeg leak)", asy
vncFramerate: 60,
vncBitrateKbps: 8000,
streamHw: true,
streamReadyTimeoutMs: 2000,
} as any);
await s.start({ guildId: "g", voiceChannelId: "v" } as any);
@@ -59,3 +65,57 @@ test("a self-ended stream tears down the capture pipeline (no ffmpeg leak)", asy
expect(leaveVoice).toHaveBeenCalled(); // voice connection released
expect(s.isActive()).toBe(false);
}, 30000);
test("a Go-Live that never connects is reported as failed, not live, and is torn down", async () => {
const kill = mock(() => {});
const leaveVoice = mock(() => {});
const destroy = mock(() => {});
// playStream never resolves (the stream is "running" locally) but the WebRTC
// never reaches "connected" -> start() must time out rather than claim live.
const neverEnds = new Promise<void>(() => {});
mock.module("node:child_process", () => ({
spawn: () => ({ stdout: {}, stderr: { on() {} }, kill }),
}));
mock.module("discord.js-selfbot-v13", () => ({
Client: class {
destroy = destroy;
async login() {}
},
}));
mock.module("@dank074/discord-video-stream", () => ({
Streamer: class {
client: any;
constructor(c: any) {
this.client = c;
}
async joinVoice() {}
leaveVoice = leaveVoice;
// Stream connection exists but its WebRTC never connects.
get voiceConnection() {
return { streamConnection: { webRtcConn: { ready: false } } };
}
},
prepareStream: () => ({ command: { on() {} }, output: {} }),
playStream: () => neverEnds,
}));
const { SelfbotStreamer } = await import("./selfbot.ts");
const s = new SelfbotStreamer({
selfbotToken: "token",
vncDisplay: ":1",
vncResolution: "1920x1080",
vncFramerate: 60,
vncBitrateKbps: 8000,
streamHw: true,
streamReadyTimeoutMs: 800,
} as any);
const msg = await s.start({ guildId: "g", voiceChannelId: "v" } as any);
expect(s.isActive()).toBe(false); // never claimed live
expect(msg).toContain("방송을 시작하지 못했습니다"); // explicit failure status
expect(kill).toHaveBeenCalled(); // local ffmpeg torn down on failure
expect(leaveVoice).toHaveBeenCalled();
}, 30000);

View File

@@ -28,12 +28,56 @@ export class SelfbotStreamer implements ScreenStreamer {
private keepalive: VncKeepalive | null = null;
private helper: ChildProcess | null = null;
private controller: AbortController | null = null;
private active = false;
// `starting` is the in-flight lock (start() is mid-handshake); `live` is the
// REAL Go-Live state — true only once Discord's stream WebRTC actually reaches
// "connected". isActive() reports `live` so the brain's search routing and the
// auto-start retry react to whether we are genuinely broadcasting, not to a
// local "we kicked off ffmpeg" guess.
private starting = false;
private live = false;
constructor(private config: AppConfig) {}
isActive() {
return this.active;
return this.live;
}
/** How long to wait for the Go-Live stream WebRTC to reach "connected". */
private get streamReadyTimeoutMs(): number {
return (this.config as any).streamReadyTimeoutMs ?? 25_000;
}
/**
* Poll the streaming library for the REAL Go-Live readiness signal: the
* stream connection's WebRTC peer reaching "connected" state (set only after
* STREAM_CREATE -> STREAM_SERVER_UPDATE -> protocol/DAVE handshake). Resolves
* true when live, false on timeout or abort.
*/
private async waitStreamReady(streamer: any, signal: AbortSignal, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (signal.aborted) return false;
if (streamer?.voiceConnection?.streamConnection?.webRtcConn?.ready === true) return true;
await new Promise((r) => setTimeout(r, 250));
}
return false;
}
/** A compact snapshot of the connection state, logged when readiness times out. */
private streamDiag(streamer: any): string {
const vc = streamer?.voiceConnection;
const sc = vc?.streamConnection;
try {
return JSON.stringify({
voiceReady: vc?.webRtcConn?.ready ?? null,
voiceDave: vc?.daveReady ?? null,
streamConnCreated: !!sc,
streamReady: sc?.webRtcConn?.ready ?? null,
streamDave: sc?.daveReady ?? null,
});
} catch {
return "{unavailable}";
}
}
/**
@@ -79,7 +123,7 @@ export class SelfbotStreamer implements ScreenStreamer {
}
async start(ctx: StreamContext): Promise<string> {
if (this.active) return "이미 송출 중입니다.";
if (this.live || this.starting) return "이미 송출 중이거나 시작 중입니다.";
// Screen-share gate: STREAM_BROWSER=false means voice + API/MCP only, so we
// never go Live. Enforced HERE (not just in the slash command) so every
// caller - including stream-hold.ts - respects it.
@@ -93,12 +137,14 @@ export class SelfbotStreamer implements ScreenStreamer {
return "셀프봇 송출은 음성 채널 안에서 호출해야 합니다.";
}
// Lock the starting state BEFORE any await: the human-pause delays below
// mean start() is in-flight for several seconds, so a second /stream call
// must be rejected by the `this.active` guard above, and the status must
// read "starting" rather than idle during the wait. Keep controller /
// streamer / capture as LOCAL refs so an interleaved stop() (which nulls the
// instance fields) can't turn our own continuation into a null dereference.
this.active = true;
// mean start() is in-flight for several seconds, so a second start() call
// must be rejected by the guard above, and the status must read "starting"
// rather than live during the wait. `live` is only set once the stream
// WebRTC is actually connected (see the readiness wait below). Keep
// controller / streamer / capture as LOCAL refs so an interleaved stop()
// (which nulls the instance fields) can't turn our own continuation into a
// null dereference.
this.starting = true;
const controller = (this.controller = new AbortController());
const signal = controller.signal;
let streamer: any = null;
@@ -275,9 +321,47 @@ export class SelfbotStreamer implements ScreenStreamer {
if (this.helper === helper) this.helper = null;
if (this.streamer === streamer) this.streamer = null;
this.controller = null;
this.active = false;
this.live = false;
this.starting = false;
});
// Wait for the REAL Go-Live signal before declaring success. playStream
// above only kicks off the pipeline (it resolves when the stream ENDS, not
// when it connects), so without this we'd report "live" while the WebRTC
// handshake is still pending — or never completes. Poll the library's
// stream connection until its WebRTC reaches "connected".
const ready = await this.waitStreamReady(streamer, signal, this.streamReadyTimeoutMs);
if (!ready) {
// Not actually broadcasting: log why and tear the pipeline down NOW so a
// dead x11grab/NVENC encoder isn't left pinning the GPU/CPU. We abort and
// do the teardown with our LOCAL refs here rather than relying on the
// playStream .finally (it early-returns once we null this.controller).
console.error(
`[selfbot] ⚠️ Go-Live did NOT connect within ${this.streamReadyTimeoutMs}ms — ` +
`not broadcasting. state=${this.streamDiag(streamer)}`,
);
controller.abort();
try { capture?.kill("SIGKILL"); } catch { /* ignore */ }
try { keepalive?.stop(); } catch { /* ignore */ }
try { helper?.kill(); } catch { /* ignore */ }
try {
streamer?.leaveVoice?.();
streamer?.client?.destroy?.();
} catch { /* ignore */ }
if (this.controller === controller) {
if (this.capture === capture) this.capture = null;
if (this.keepalive === keepalive) this.keepalive = null;
if (this.helper === helper) this.helper = null;
if (this.streamer === streamer) this.streamer = null;
this.controller = null;
this.live = false;
this.starting = false;
}
return `방송을 시작하지 못했습니다 (Go-Live 연결 타임아웃 ${this.streamReadyTimeoutMs}ms). 동일 계정 음성 충돌 또는 송출 경로 문제로 보입니다.`;
}
this.live = true;
this.starting = false;
console.log("🔴 [selfbot] Go-Live WebRTC connected — broadcasting for real.");
return "🔴 셀프봇으로 VNC 화면을 음성채널에 실시간 송출 중입니다 (Go Live).";
} catch (e) {
// Startup was aborted (stop() during a pause) or failed. Tear down using
@@ -306,7 +390,7 @@ export class SelfbotStreamer implements ScreenStreamer {
}
// Only release the lock / clear instance state if WE are still the
// current attempt. If a concurrent stop()+start() already replaced the
// controller, a newer start() owns `active` — clearing it here would
// controller, a newer start() owns the lock — clearing it here would
// unlock it mid-startup and let a third start() race in.
if (this.controller === controller) {
if (this.capture === capture) this.capture = null;
@@ -314,7 +398,8 @@ export class SelfbotStreamer implements ScreenStreamer {
if (this.helper === helper) this.helper = null;
if (this.streamer === streamer) this.streamer = null;
this.controller = null;
this.active = false;
this.live = false;
this.starting = false;
}
if (signal.aborted) return "송출을 시작하는 중에 중지했습니다.";
throw e;
@@ -349,6 +434,7 @@ export class SelfbotStreamer implements ScreenStreamer {
/* ignore */
}
this.streamer = null;
this.active = false;
this.live = false;
this.starting = false;
}
}