fix(selfbot): smooth VNC capture via keepalive + stop ffmpeg leak on stream end
The Go-Live broadcast looked badly choppy: video and scrolling stuttered while the cursor stayed smooth. Root cause is TigerVNC: it only refreshes its framebuffer while a VNC client is attached, but the broadcast reads that framebuffer with x11grab (not as a VNC client). With no viewer attached the captured screen idled at ~1.5 fps (measured 3/30 distinct frames); the cursor looked smooth only because x11grab overlays the live cursor on every frame. - Add a headless RFB keepalive (vnc-keepalive.ts) that stays connected for the life of the stream and requests incremental framebuffer updates at the stream framerate. SelfbotStreamer starts it on broadcast start and tears it down on stop/self-end. Measured 3/30 -> 57/60 distinct frames at 60 fps. Fail-open; authenticates with VNC_PASSWORD or the ~/.config/tigervnc/passwd file. - Fix a resource leak: when the Go-Live ended on its own, only the active flag was cleared, leaving the x11grab->nvenc ffmpeg running forever (pinning a CPU core while no media was transmitted, with only the gateway TCP left and no UDP media). The self-end path now tears down capture, keepalive and voice like stop() does. - Tests for both paths (self-end teardown; keepalive DES auth, port mapping, password resolution). Add @types/bun so bun:test typechecks; document the keepalive and recommended Chrome flags in README and .env.example. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -19,11 +19,13 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import type { AppConfig } from "../config.ts";
|
||||
import type { ScreenStreamer, StreamContext } from "./index.ts";
|
||||
import { VncKeepalive, resolveVncPassword, vncPortForDisplay } from "./vnc-keepalive.ts";
|
||||
|
||||
export class SelfbotStreamer implements ScreenStreamer {
|
||||
readonly kind = "selfbot" as const;
|
||||
private streamer: any = null;
|
||||
private capture: ChildProcess | null = null;
|
||||
private keepalive: VncKeepalive | null = null;
|
||||
private controller: AbortController | null = null;
|
||||
private active = false;
|
||||
|
||||
@@ -94,6 +96,7 @@ export class SelfbotStreamer implements ScreenStreamer {
|
||||
const signal = controller.signal;
|
||||
let streamer: any = null;
|
||||
let capture: ChildProcess | null = null;
|
||||
let keepalive: VncKeepalive | null = null;
|
||||
|
||||
try {
|
||||
const { selfbot, vs } = await this.loadLib();
|
||||
@@ -157,6 +160,23 @@ export class SelfbotStreamer implements ScreenStreamer {
|
||||
if (!signal.aborted) console.error("[selfbot x11grab]", d.toString().trim());
|
||||
});
|
||||
|
||||
// Keep a VNC client attached for the life of the stream. TigerVNC only
|
||||
// flushes its framebuffer at full rate while a client pulls updates; the
|
||||
// Discord broadcast reads that framebuffer with x11grab (not as a VNC
|
||||
// client), so without this the captured screen would idle at ~1.5 fps and
|
||||
// the stream would look badly choppy. Fail-open: a missing password just
|
||||
// skips it. Matched to the stream framerate so motion stays smooth.
|
||||
const vncPw = resolveVncPassword();
|
||||
if (vncPw) {
|
||||
keepalive = this.keepalive = new VncKeepalive({
|
||||
host: "127.0.0.1",
|
||||
port: vncPortForDisplay(this.config.vncDisplay),
|
||||
password: vncPw,
|
||||
fps: this.config.vncFramerate,
|
||||
});
|
||||
keepalive.start();
|
||||
}
|
||||
|
||||
const { command, output } = prepareStream(
|
||||
capture.stdout,
|
||||
{
|
||||
@@ -182,8 +202,35 @@ export class SelfbotStreamer implements ScreenStreamer {
|
||||
if (!signal.aborted) console.error("[selfbot] playStream:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
// The stream ended on its own (not via stop()); release the lock.
|
||||
if (this.controller === controller) this.active = false;
|
||||
// The stream ended on its own (Discord closed the Go-Live, the voice
|
||||
// UDP dropped, or ffmpeg exited) rather than via stop(). If we are
|
||||
// still the current attempt, tear the pipeline DOWN: kill the capture
|
||||
// ffmpeg and leave voice. Otherwise the x11grab->nvenc encoder keeps
|
||||
// running forever feeding a pipe nobody reads, pinning a CPU core
|
||||
// while no media is actually transmitted. Skip if a concurrent
|
||||
// stop()/start() already replaced the controller (it owns teardown).
|
||||
if (this.controller !== controller) return;
|
||||
try {
|
||||
capture?.kill("SIGKILL");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
keepalive?.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
streamer?.leaveVoice?.();
|
||||
streamer?.client?.destroy?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (this.capture === capture) this.capture = null;
|
||||
if (this.keepalive === keepalive) this.keepalive = null;
|
||||
if (this.streamer === streamer) this.streamer = null;
|
||||
this.controller = null;
|
||||
this.active = false;
|
||||
});
|
||||
|
||||
return "🔴 셀프봇으로 VNC 화면을 음성채널에 실시간 송출 중입니다 (Go Live).";
|
||||
@@ -196,6 +243,11 @@ export class SelfbotStreamer implements ScreenStreamer {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
keepalive?.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
streamer?.leaveVoice?.();
|
||||
streamer?.client?.destroy?.();
|
||||
@@ -208,6 +260,7 @@ export class SelfbotStreamer implements ScreenStreamer {
|
||||
// unlock it mid-startup and let a third start() race in.
|
||||
if (this.controller === controller) {
|
||||
if (this.capture === capture) this.capture = null;
|
||||
if (this.keepalive === keepalive) this.keepalive = null;
|
||||
if (this.streamer === streamer) this.streamer = null;
|
||||
this.controller = null;
|
||||
this.active = false;
|
||||
@@ -226,6 +279,12 @@ export class SelfbotStreamer implements ScreenStreamer {
|
||||
/* ignore */
|
||||
}
|
||||
this.capture = null;
|
||||
try {
|
||||
this.keepalive?.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.keepalive = null;
|
||||
try {
|
||||
this.streamer?.leaveVoice?.();
|
||||
this.streamer?.client?.destroy?.();
|
||||
|
||||
Reference in New Issue
Block a user