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);

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

View File

@@ -0,0 +1,53 @@
import { test, expect } from "bun:test";
import crypto from "node:crypto";
import {
decodeVncPassword,
vncChallengeResponse,
vncPortForDisplay,
resolveVncPassword,
} from "./vnc-keepalive.ts";
// Independent reference for VNC's bit-reversed-key DES, to cross-check the module.
const rev = (b: number) => {
let r = 0;
for (let i = 0; i < 8; i++) r = (r << 1) | ((b >> i) & 1);
return r & 0xff;
};
const vncKey = (buf: Buffer) => Buffer.from([...buf.subarray(0, 8)].map(rev));
const desEnc = (key: Buffer, data: Buffer) => {
const c = crypto.createCipheriv("des-ecb", key, null);
c.setAutoPadding(false);
return Buffer.concat([c.update(data), c.final()]);
};
const FIXED_KEY = Buffer.from([23, 82, 107, 6, 35, 78, 88, 7]);
test("decodeVncPassword inverts the fixed-key obfuscation", () => {
const pw = Buffer.from("s3cr3t\0\0", "binary"); // 8 bytes, trailing nulls
const obf = desEnc(vncKey(FIXED_KEY), pw); // how vncpasswd stores it
expect(decodeVncPassword(obf).toString()).toBe("s3cr3t");
});
test("vncChallengeResponse encrypts both challenge blocks with the bit-reversed password key", () => {
const pw = Buffer.from("hunter12");
const challenge = crypto.randomBytes(16);
const expected = desEnc(vncKey(pw), challenge);
const got = vncChallengeResponse(pw, challenge);
expect(got.length).toBe(16);
expect(got.equals(expected)).toBe(true);
});
test("vncPortForDisplay maps an X display to its RFB port", () => {
expect(vncPortForDisplay(":1")).toBe(5901);
expect(vncPortForDisplay(":0")).toBe(5900);
expect(vncPortForDisplay(":5")).toBe(5905);
});
test("resolveVncPassword prefers the VNC_PASSWORD env var", () => {
const pw = resolveVncPassword({ VNC_PASSWORD: "letmein9" } as NodeJS.ProcessEnv);
expect(pw?.toString()).toBe("letmein9");
});
test("resolveVncPassword returns null when nothing is available", () => {
const pw = resolveVncPassword({ VNC_PASSWD_FILE: "/nonexistent/path/xyz" } as NodeJS.ProcessEnv);
expect(pw).toBeNull();
});

View File

@@ -0,0 +1,203 @@
/**
* Headless RFB (VNC) keepalive client.
*
* TigerVNC's Xvnc only flushes pending rendering into the readable framebuffer
* while a VNC client is actively pulling updates. The Discord broadcast reads
* that framebuffer with x11grab (it is NOT a VNC client), so with no viewer
* attached Xvnc idles and the captured screen updates at ~1.5 fps - the stream
* looks badly choppy even though Chrome renders at 60 fps. (Measured: 3/30
* distinct frames without a client, 30/30 with one.)
*
* This client stays connected to the VNC server and continuously requests
* incremental framebuffer updates, keeping the framebuffer fresh for the whole
* duration of a broadcast. It is intentionally fail-open: any connection/auth
* problem is logged and retried, never thrown, so it can never break the stream.
*/
import net from "node:net";
import crypto from "node:crypto";
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
// VNC's DES variant uses each key byte with its bits mirrored.
function revByte(b: number): number {
let r = 0;
for (let i = 0; i < 8; i++) r = (r << 1) | ((b >> i) & 1);
return r & 0xff;
}
function vncKey(buf: Buffer): Buffer {
return Buffer.from([...buf.subarray(0, 8)].map(revByte));
}
function desEcb(key: Buffer, data: Buffer, decrypt = false): Buffer {
const c = decrypt
? crypto.createDecipheriv("des-ecb", key, null)
: crypto.createCipheriv("des-ecb", key, null);
c.setAutoPadding(false);
return Buffer.concat([c.update(data), c.final()]);
}
// The fixed key TigerVNC/RealVNC use to obfuscate the stored password file.
const FIXED_KEY = Buffer.from([23, 82, 107, 6, 35, 78, 88, 7]);
/** Decode an 8-byte obfuscated VNC password file payload to plaintext. */
export function decodeVncPassword(obf: Buffer): Buffer {
const pt = desEcb(vncKey(FIXED_KEY), obf.subarray(0, 8), true);
const z = pt.indexOf(0);
return pt.subarray(0, z < 0 ? 8 : z);
}
/** Compute the 16-byte VncAuth response for a server challenge. */
export function vncChallengeResponse(password: Buffer, challenge: Buffer): Buffer {
const key = Buffer.alloc(8);
password.subarray(0, 8).copy(key);
return desEcb(vncKey(key), challenge.subarray(0, 16));
}
/** Map an X display like ":1" to its TigerVNC RFB port (5900 + n). */
export function vncPortForDisplay(display: string): number {
const n = parseInt(String(display).replace(/^:/, ""), 10);
return 5900 + (Number.isFinite(n) ? n : 0);
}
/**
* Resolve the VNC password: VNC_PASSWORD (plaintext) wins, otherwise decode the
* obfuscated passwd file (VNC_PASSWD_FILE, default ~/.config/tigervnc/passwd).
* Returns null when nothing is available (caller then skips the keepalive).
*/
export function resolveVncPassword(env: NodeJS.ProcessEnv = process.env): Buffer | null {
if (env.VNC_PASSWORD) return Buffer.from(env.VNC_PASSWORD, "utf8").subarray(0, 8);
const file = env.VNC_PASSWD_FILE || `${homedir()}/.config/tigervnc/passwd`;
try {
const obf = readFileSync(file);
if (obf.length >= 8) return decodeVncPassword(obf);
} catch {
/* no file - fall through */
}
return null;
}
export class VncKeepalive {
private sock: net.Socket | null = null;
private timer: ReturnType<typeof setInterval> | null = null;
private retry: ReturnType<typeof setTimeout> | null = null;
private stopped = false;
constructor(
private opts: { host: string; port: number; password: Buffer; fps?: number },
) {}
start(): void {
this.stopped = false;
this.connect();
}
stop(): void {
this.stopped = true;
if (this.timer) clearInterval(this.timer);
if (this.retry) clearTimeout(this.retry);
this.timer = this.retry = null;
try {
this.sock?.destroy();
} catch {
/* ignore */
}
this.sock = null;
}
private scheduleReconnect(): void {
if (this.stopped || this.retry) return;
this.retry = setTimeout(() => {
this.retry = null;
if (!this.stopped) this.connect();
}, 2000);
}
private connect(): void {
const sock = net.connect(this.opts.port, this.opts.host);
this.sock = sock;
sock.setNoDelay(true);
let buf = Buffer.alloc(0);
const waiters: { n: number; res: (b: Buffer) => void }[] = [];
const pump = () => {
while (waiters.length && buf.length >= waiters[0].n) {
const w = waiters.shift()!;
const d = buf.subarray(0, w.n);
buf = buf.subarray(w.n);
w.res(d);
}
};
const onData = (d: Buffer) => {
buf = Buffer.concat([buf, d]);
pump();
};
sock.on("data", onData);
sock.on("error", () => {
/* handled by close */
});
sock.on("close", () => {
if (this.sock === sock) {
this.sock = null;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.scheduleReconnect();
}
});
const read = (n: number) =>
new Promise<Buffer>((res) => {
waiters.push({ n, res });
pump();
});
(async () => {
try {
await read(12); // ProtocolVersion
sock.write("RFB 003.008\n");
const nTypes = (await read(1))[0];
const types = await read(nTypes);
if (types.includes(2)) {
sock.write(Buffer.from([2])); // VNC Auth
const challenge = await read(16);
sock.write(vncChallengeResponse(this.opts.password, challenge));
if ((await read(4)).readUInt32BE(0) !== 0) return sock.destroy();
} else if (types.includes(1)) {
sock.write(Buffer.from([1])); // None
if ((await read(4)).readUInt32BE(0) !== 0) return sock.destroy();
} else {
return sock.destroy();
}
sock.write(Buffer.from([1])); // ClientInit (shared)
const si = await read(24);
const w = si.readUInt16BE(0);
const h = si.readUInt16BE(2);
await read(si.readUInt32BE(20)); // desktop name
sock.write(Buffer.from([2, 0, 0, 1, 0, 0, 0, 0])); // SetEncodings: Raw
// Past the handshake: stop buffering and just drain whatever the server
// sends so its send buffer never blocks (we never decode the pixels).
sock.removeListener("data", onData);
sock.on("data", () => {});
buf = Buffer.alloc(0);
const req = Buffer.from([3, 1, 0, 0, 0, 0, 0, 0, 0, 0]);
req.writeUInt16BE(w, 6);
req.writeUInt16BE(h, 8);
const interval = Math.max(1, Math.round(1000 / (this.opts.fps ?? 60)));
this.timer = setInterval(() => {
try {
if (this.sock === sock && sock.writable) sock.write(req);
} catch {
/* ignore */
}
}, interval);
} catch {
try {
sock.destroy();
} catch {
/* ignore */
}
}
})();
}
}