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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user