Files
GMW/services/discord-gateway/tests/screenShareInput.test.ts
T
asepharyana 67ab289caa fix(gateway): fail-fast + retry on screen share merge failure (black tile zombie)
Root cause (2026-08-12 11:50 test): merge ffmpeg hit a transient YouTube
403 and exited code 8 BEFORE prepareStream attached its input listeners
(voice release+join takes ~10s). The input's end/error events fired into
the void, the encoder stdin never received EOF, demux resolved with
fallback 0x0 metadata, setSpeaking fired anyway → stream 'started' with
zero frames for 8+ minutes (black tile, both ffmpeg processes hung).

Fixes:
- mediaSource: pass yt-dlp http_headers (UA/referer) to the merge ffmpeg
  via -headers to suppress transient 403s; destroy the returned stream
  with an error when the merge exits non-zero before producing bytes.
- screenShareController: resolveInputWithRetry — tee the merge stream and
  wait for the first readable byte (12s timeout) before proceeding; on
  error/EOF/timeout retry the whole resolution with a FRESH yt-dlp run
  (signed DASH URLs expire fast) up to 3 attempts. Stuck merges get
  EPIPE via input.destroy() so no process leaks per attempt.
- prepareStream: race guard — if the input already ended/destroyed before
  listeners attach, EOF the encoder stdin immediately; first-frame
  watchdog in playStream rejects 'started but nothing flowing' after 10s
  instead of resolving with a silent black stream.

Tests: +2 (merge-fail zero-byte terminal state, -headers forwarding).
2026-08-12 12:17:43 +07:00

237 lines
9.1 KiB
TypeScript

// ═══════════════════════════════════════════════════════════════════════════════
// Screen share input resolution tests
//
// Verifies the decision logic of getDirectScreenInput:
// - merged progressive URL → returned directly
// - video+audio DASH pair → local ffmpeg merge (Readable)
// - neither → rejection
//
// Both yt-dlp and ffmpeg are faked via PATH shim scripts so the test does not
// hit the network or need real binaries.
// ═══════════════════════════════════════════════════════════════════════════════
import {
chmodSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getDirectScreenInput } from "../src/modules/voice-recording/mediaSource.js";
// ─── fake bin dir ──────────────────────────────────────────────────────────────
let fakeBinDir: string | null = null;
const realPath = process.env.PATH;
beforeAll(() => {
fakeBinDir = mkdtempSync(join(tmpdir(), "gmw-fake-bins-"));
// Fake yt-dlp: prints the JSON file named in GMW_FAKE_YTDLP_JSON.
// If the file is missing → exits 1 (mimics yt-dlp failure).
const ytShim = `#!/usr/bin/env bash
if [ -n "$GMW_FAKE_YTDLP_JSON" ] && [ -f "$GMW_FAKE_YTDLP_JSON" ]; then
cat "$GMW_FAKE_YTDLP_JSON"
exit 0
fi
echo "yt-dlp: fake JSON missing" >&2
exit 1
`;
writeFileSync(join(fakeBinDir, "yt-dlp"), ytShim);
chmodSync(join(fakeBinDir, "yt-dlp"), 0o755);
// Fake ffmpeg: writes a small nut-ish payload to stdout so the returned
// Readable actually emits data (the merge path in mergeScreenStreams).
// Modes (env):
// GMW_FAKE_FFMPEG_FAIL=1 → exit 1, no stdout (mimics transient 403)
// GMW_FAKE_FFMPEG_DUMP_ARGS=<file> → append argv to the file (asserts
// flags like -headers are forwarded to the merge process)
const ffShim = `#!/usr/bin/env bash
if [ -n "$GMW_FAKE_FFMPEG_DUMP_ARGS" ]; then
printf '%s\\n' "$*" >> "$GMW_FAKE_FFMPEG_DUMP_ARGS"
fi
if [ "$GMW_FAKE_FFMPEG_FAIL" = "1" ]; then
echo "403 Forbidden" >&2
exit 8
fi
# Fake ffmpeg — ignore args, emit a few bytes so consumers see a live stream.
head -c 4096 /dev/urandom
exit 0
`;
writeFileSync(join(fakeBinDir, "ffmpeg"), ffShim);
chmodSync(join(fakeBinDir, "ffmpeg"), 0o755);
process.env.PATH = `${fakeBinDir}:${process.env.PATH}`;
});
afterAll(() => {
if (fakeBinDir) {
rmSync(fakeBinDir, { recursive: true, force: true });
}
process.env.PATH = realPath;
});
// ─── helpers ───────────────────────────────────────────────────────────────────
function writeFakeJson(payload: Record<string, unknown>): string {
const p = join(
tmpdir(),
`gmw-fake-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
);
writeFileSync(p, JSON.stringify(payload));
return p;
}
function dashPairInfo(videoUrl: string, audioUrl: string) {
return {
url: null,
acodec: "none", // top-level is not a single merged format
vcodec: "av01",
requested_formats: [
{
format_id: "136",
vcodec: "avc1.4d401f",
acodec: "none",
url: videoUrl,
},
{ format_id: "140", vcodec: "none", acodec: "mp4a.40.2", url: audioUrl },
],
};
}
// ─── tests ─────────────────────────────────────────────────────────────────────
describe("getDirectScreenInput", () => {
it("returns the single merged progressive URL when the info has one", async () => {
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson({
url: "https://cdn.example/progressive.mp4",
acodec: "mp4a.40.2",
vcodec: "avc1",
});
const result = await getDirectScreenInput("https://youtu.be/abc");
expect(result).toBe("https://cdn.example/progressive.mp4");
});
it("returns a live Readable when a video+audio DASH pair must be merged", async () => {
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson(
dashPairInfo(
"https://cdn.example/video.mp4",
"https://cdn.example/audio.m4a",
),
);
const result = await getDirectScreenInput("https://youtu.be/abc");
expect(Readable.isReadable(result)).toBe(true);
// The fake ffmpeg emits bytes; collect a chunk to prove the stream flows.
const bytes = await new Promise<number>((resolve, reject) => {
const stream = result as Readable;
let got = 0;
stream.on("data", (chunk: Buffer) => {
got += chunk.length;
});
stream.on("error", reject);
stream.on("end", () => resolve(got));
stream.resume();
});
expect(bytes).toBeGreaterThan(0);
});
it("rejects when yt-dlp returns neither a merged URL nor a format pair", async () => {
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson({
url: null,
acodec: "none",
vcodec: "none",
requested_formats: [],
});
await expect(getDirectScreenInput("https://youtu.be/abc")).rejects.toThrow(
/neither a merged progressive URL nor a video\+audio/,
);
});
it("rejects when yt-dlp exits non-zero", async () => {
process.env.GMW_FAKE_YTDLP_JSON = "/nonexistent/gmw-fake.json";
await expect(getDirectScreenInput("https://youtu.be/abc")).rejects.toThrow(
/screen input resolution exited with code 1/,
);
});
it("terminates with ZERO bytes when the merge ffmpeg fails before producing data (transient 403)", async () => {
// Simulate the 11:50 production failure: yt-dlp resolves fine, but the
// merge ffmpeg hits a transient YouTube 403 and exits non-zero WITHOUT
// emitting a single byte. getDirectScreenInput still resolves (the
// Readable exists) — the fail-fast contract: the stream must terminate
// (error OR end — the end-before-exit ordering makes both possible)
// without ever delivering a frame to a consumer. The controller's
// resolveInputWithRetry turns either signal into a fresh retry.
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson(
dashPairInfo(
"https://cdn.example/video.mp4",
"https://cdn.example/audio.m4a",
),
);
process.env.GMW_FAKE_FFMPEG_FAIL = "1";
try {
const result = await getDirectScreenInput("https://youtu.be/abc");
expect(Readable.isReadable(result)).toBe(true);
const outcome = await new Promise<string>((resolve) => {
const stream = result as Readable;
let got = 0;
stream.on("data", (chunk: Buffer) => {
got += chunk.length;
});
stream.on("error", () => resolve(`error-after-${got}B`));
stream.on("end", () => resolve(`end-after-${got}B`));
stream.resume();
});
// Fail-fast: the consumer must NOT receive any bytes (no black-tile
// zombie stream). Either a destroyed-with-error stream or a clean
// end-before-exit is a valid terminal state — the caller retries.
expect(outcome).toMatch(/^(error|end)-after-0B$/);
} finally {
delete process.env.GMW_FAKE_FFMPEG_FAIL;
}
});
it("forwards yt-dlp http_headers to the merge ffmpeg (-headers)", async () => {
const info = dashPairInfo(
"https://cdn.example/video.mp4",
"https://cdn.example/audio.m4a",
);
// Add the browser-like headers yt-dlp attaches to signed DASH URLs.
(info.requested_formats[0] as Record<string, unknown>).http_headers = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
Referer: "https://www.youtube.com/",
};
const argsDump = join(
tmpdir(),
`gmw-ffargs-${process.pid}-${Date.now()}.txt`,
);
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson(info);
process.env.GMW_FAKE_FFMPEG_DUMP_ARGS = argsDump;
try {
const result = await getDirectScreenInput("https://youtu.be/abc");
// Consume the stream so the merge ffmpeg process runs to completion.
await new Promise<void>((resolve) => {
const stream = result as Readable;
stream.on("data", () => {});
stream.on("error", () => resolve());
stream.on("end", () => resolve());
stream.resume();
});
// Allow the fake ffmpeg to flush its argv dump.
await new Promise((r) => setTimeout(r, 100));
const args = readFileSync(argsDump, "utf8").trim();
expect(args).toContain("-headers");
expect(args).toContain("Mozilla/5.0");
expect(args).toContain("Referer: https://www.youtube.com/");
} finally {
delete process.env.GMW_FAKE_FFMPEG_DUMP_ARGS;
rmSync(argsDump, { force: true });
}
});
});