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

@@ -0,0 +1,61 @@
// Regression: when the Go-Live stream ends on its own (Discord closes it, the
// voice UDP drops, or ffmpeg exits) instead of via stop(), the capture ffmpeg
// MUST be killed and voice left. Otherwise the x11grab->nvenc encoder keeps
// running forever, feeding a pipe nobody reads, pinning a CPU core while no
// media is actually transmitted (observed live: 0 UDP sockets, 100% CPU).
import { test, expect, mock } from "bun:test";
test("a self-ended stream tears down the capture pipeline (no ffmpeg leak)", async () => {
const kill = mock(() => {});
const leaveVoice = mock(() => {});
const destroy = mock(() => {});
// Controllable Go-Live: resolve endStream() to simulate Discord closing it.
let endStream!: () => void;
const playPromise = new Promise<void>((r) => {
endStream = r;
});
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;
},
prepareStream: () => ({ command: { on() {} }, output: {} }),
playStream: () => playPromise,
}));
const { SelfbotStreamer } = await import("./selfbot.ts");
const s = new SelfbotStreamer({
selfbotToken: "token",
vncDisplay: ":1",
vncResolution: "1920x1080",
vncFramerate: 60,
vncBitrateKbps: 8000,
streamHw: true,
} as any);
await s.start({ guildId: "g", voiceChannelId: "v" } as any);
expect(s.isActive()).toBe(true);
// Discord closes the Go-Live on its own (not a stop() call).
endStream();
await new Promise((r) => setTimeout(r, 0));
expect(kill).toHaveBeenCalled(); // capture ffmpeg killed -> no CPU-burning orphan
expect(leaveVoice).toHaveBeenCalled(); // voice connection released
expect(s.isActive()).toBe(false);
}, 30000);