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).
This commit is contained in:
@@ -206,9 +206,19 @@ export function prepareStream(
|
||||
: spawn(FFMPEG_BIN, args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
|
||||
if (proc.stdin && !isUrl) {
|
||||
input.on("data", (chunk: Buffer) => proc.stdin?.write(chunk));
|
||||
input.on("end", () => proc.stdin?.end());
|
||||
input.on("error", () => proc.stdin?.destroy());
|
||||
// Race guard: the merge ffmpeg may have already exited (transient 403
|
||||
// or stream death) before this function attaches its listeners — the
|
||||
// input's 'end'/'error' events then fire into the void and the encoder
|
||||
// stdin NEVER receives EOF, leaving an encoder that waits forever and a
|
||||
// screen share that shows a black tile with zero frames. Check the
|
||||
// terminal state eagerly and EOF the encoder immediately.
|
||||
if (input.readableEnded || input.destroyed) {
|
||||
proc.stdin.end();
|
||||
} else {
|
||||
input.on("data", (chunk: Buffer) => proc.stdin?.write(chunk));
|
||||
input.on("end", () => proc.stdin?.end());
|
||||
input.on("error", () => proc.stdin?.destroy());
|
||||
}
|
||||
}
|
||||
|
||||
proc.stdout?.pipe(output);
|
||||
@@ -319,14 +329,81 @@ export async function playStream(
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
vStream.once("finish", () => {
|
||||
cleanup();
|
||||
// First-frame watchdog: if the encoder never delivers a single frame
|
||||
// (dead merge input, empty stream, codec mismatch), fail fast instead of
|
||||
// "playing" a black tile forever. The demuxer resolves with fallback
|
||||
// metadata even when no frame ever arrives, so this timeout is the only
|
||||
// place that detects "started but nothing flowing".
|
||||
let firstFrameTimer: NodeJS.Timeout | null = null;
|
||||
let gotFirstFrame = false;
|
||||
const firstFrame = new Promise<void>((resolve, reject) => {
|
||||
firstFrameTimer = setTimeout(() => {
|
||||
if (!gotFirstFrame) {
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
"No video frames within 10s of stream start — input stream failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
}, 10000);
|
||||
video.stream.once("data", () => {
|
||||
gotFirstFrame = true;
|
||||
if (firstFrameTimer) clearTimeout(firstFrameTimer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (fn: () => void) => () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (firstFrameTimer) clearTimeout(firstFrameTimer);
|
||||
fn();
|
||||
};
|
||||
|
||||
vStream.once("finish", () => {
|
||||
settle(() => {
|
||||
cleanup();
|
||||
if (!gotFirstFrame) {
|
||||
reject(new Error("Screen video stream ended without any frame"));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
})();
|
||||
});
|
||||
vStream.once("error", () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
settle(() => {
|
||||
cleanup();
|
||||
if (!gotFirstFrame) {
|
||||
reject(new Error("Screen video stream errored before first frame"));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
})();
|
||||
});
|
||||
// The stream may end without ever producing a frame (input was
|
||||
// silently dead) — surface that instead of resolving "successfully".
|
||||
video.stream.once("end", () => {
|
||||
settle(() => {
|
||||
cleanup();
|
||||
if (!gotFirstFrame) {
|
||||
reject(new Error("Screen video stream ended before any frame"));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
})();
|
||||
});
|
||||
// Watchdog timeout: no frame arrived within 10s — fail fast instead of
|
||||
// "playing" a black tile forever. cleanup() kills the encoder so the
|
||||
// vStream finish/error handlers above still fire, but the settled guard
|
||||
// ensures this rejection wins.
|
||||
firstFrame.catch((err) => {
|
||||
settle(() => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
})();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user