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:
javis-bot
2026-06-10 15:21:44 +09:00
parent 8709f40fd6
commit 4176a68873
9 changed files with 410 additions and 3 deletions

View File

@@ -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?.();