fix: drain FFmpeg stderr and sanitize playback diagnostics

This commit is contained in:
TIANYAO ZHANG committed 2026-10-03 21:03:04 +08:00
1 parent 5258ba951b
commit a4c0239a59
4 files changed
+489 -11

No files matched your search

+53 -10
View File
@@ -7,6 +7,7 @@ import { join } from "node:path";
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
import type { Readable } from "node:stream";
import type { Logger } from "../logger.js";
import { collectFfmpegDiagnostics, type FfmpegDiagnostics } from "./ffmpeg-diagnostics.js";
const require = createRequire(import.meta.url);
const ffmpegPath: string | null = require("ffmpeg-static");
@@ -52,6 +53,17 @@ export function getFfmpegCommand(): string {
return resolvedFfmpeg;
}
function safeFfmpegSpawnError(err: Error): Error {
// Node spawn errors include spawnargs; Pino's Error serializer copies them,
// including the signed input URL. Preserve a known OS category only.
const allowedCodes = new Set(["ENOENT", "EACCES", "EPERM", "ENOEXEC", "EMFILE", "ENFILE", "ENOMEM", "EAGAIN", "EINVAL"]);
const code = (err as NodeJS.ErrnoException).code;
const safeCode = typeof code === "string" && allowedCodes.has(code) ? code : undefined;
const safeError = new Error(`FFmpeg failed to start${safeCode ? ` (${safeCode})` : ""}`);
if (safeCode) Object.assign(safeError, { code: safeCode });
return safeError;
}
const BROWSER_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
@@ -74,7 +86,7 @@ export function cleanupTempDir(dir: string): void {
}
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
const args: string[] = [];
const args: string[] = ["-nostats"];
const isHttp = /^https?:\/\//i.test(url);
const isBilibili = isHttp && (url.includes("bilivideo") || url.includes("bilibili"));
@@ -167,6 +179,8 @@ const FRAME_DURATION_MS = 20;
export class AudioPlayer extends EventEmitter {
private ffmpeg: ChildProcess | null = null;
private ffmpegDiagnostics: FfmpegDiagnostics | null = null;
private readonly intentionalCleanup = new WeakSet<ChildProcess>();
private encoder: Encoder;
private state: PlayerState = "idle";
private volume = 75;
@@ -258,6 +272,9 @@ export class AudioPlayer extends EventEmitter {
const ffmpegBin = getFfmpegCommand();
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
const child = this.ffmpeg;
const diagnostics = collectFfmpegDiagnostics(child.stderr);
this.ffmpegDiagnostics = diagnostics;
const currentPid = this.ffmpeg.pid;
if (currentPid) {
@@ -280,7 +297,6 @@ export class AudioPlayer extends EventEmitter {
this.ffmpeg.on("exit", (code, signal) => {
if (currentPid) globalActivePids.delete(currentPid);
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
// 只有当前会话的进程结束才置空变量
if (this.sessionId === currentSessionId) {
@@ -288,11 +304,21 @@ export class AudioPlayer extends EventEmitter {
}
});
// close follows stderr's end, so final unterminated diagnostics are ready.
this.ffmpeg.on("close", (code, signal) => {
const context = { pid: currentPid, sessionId: currentSessionId, code, signal };
if (!this.intentionalCleanup.has(child) && ((typeof code === "number" && code !== 0) || signal !== null)) {
this.logger.warn({ ...context, stderr: diagnostics.getTail() }, "FFmpeg exited");
} else {
this.logger.info(context, "FFmpeg exited");
}
});
this.ffmpeg.on("error", (err) => {
if (this.sessionId === currentSessionId) {
this.spawnFailed = true;
this.consecutiveFailures++;
this.emit("error", err);
this.emit("error", safeFfmpegSpawnError(err));
}
});
@@ -332,10 +358,7 @@ export class AudioPlayer extends EventEmitter {
);
this.downloader = ps;
let stderrTail = "";
ps.stderr!.on("data", (chunk: Buffer) => {
stderrTail = (stderrTail + chunk.toString()).slice(-500);
});
const diagnostics = collectFfmpegDiagnostics(ps.stderr);
ps.on("exit", (code, signal) => {
if (this.sessionId !== sessionId) {
@@ -344,7 +367,6 @@ export class AudioPlayer extends EventEmitter {
}
this.downloader = null;
if (code !== 0) {
this.logger.warn({ code, signal, stderr: stderrTail }, "PowerShell download failed");
this.spawnFailed = true;
this.consecutiveFailures++;
this.state = "idle";
@@ -356,6 +378,12 @@ export class AudioPlayer extends EventEmitter {
this.spawnFfmpegFromFile(tempFile, seekSeconds, sessionId);
});
ps.on("close", (code, signal) => {
if (!this.intentionalCleanup.has(ps) && ((typeof code === "number" && code !== 0) || signal !== null)) {
this.logger.warn({ pid: ps.pid, sessionId, code, signal, stderr: diagnostics.getTail() }, "PowerShell download failed");
}
});
ps.on("error", (err) => {
if (this.sessionId !== sessionId) return;
this.downloader = null;
@@ -386,6 +414,9 @@ export class AudioPlayer extends EventEmitter {
const args = buildFfmpegArgs(tempFile, seekSeconds);
const ffmpegBin = getFfmpegCommand();
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
const child = this.ffmpeg;
const diagnostics = collectFfmpegDiagnostics(child.stderr);
this.ffmpegDiagnostics = diagnostics;
const currentPid = this.ffmpeg.pid;
if (currentPid) {
@@ -405,7 +436,6 @@ export class AudioPlayer extends EventEmitter {
this.ffmpeg.on("exit", (code, signal) => {
if (currentPid) globalActivePids.delete(currentPid);
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
if (this.sessionId === sessionId) {
this.ffmpeg = null;
if (this.currentTempDir === tempDirToCleanup) this.currentTempDir = null;
@@ -413,11 +443,20 @@ export class AudioPlayer extends EventEmitter {
if (tempDirToCleanup) cleanupTempDir(tempDirToCleanup);
});
this.ffmpeg.on("close", (code, signal) => {
const context = { pid: currentPid, sessionId, code, signal };
if (!this.intentionalCleanup.has(child) && ((typeof code === "number" && code !== 0) || signal !== null)) {
this.logger.warn({ ...context, stderr: diagnostics.getTail() }, "FFmpeg exited");
} else {
this.logger.info(context, "FFmpeg exited");
}
});
this.ffmpeg.on("error", (err) => {
if (this.sessionId === sessionId) {
this.spawnFailed = true;
this.consecutiveFailures++;
this.emit("error", err);
this.emit("error", safeFfmpegSpawnError(err));
}
});
@@ -543,6 +582,7 @@ export class AudioPlayer extends EventEmitter {
// 立即清空缓冲区,确保切歌瞬间静音 (
this.pcmBuffer = Buffer.alloc(0);
this.ffmpegDiagnostics = null;
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
@@ -557,6 +597,7 @@ export class AudioPlayer extends EventEmitter {
if (this.downloader) {
const ps = this.downloader;
this.downloader = null;
this.intentionalCleanup.add(ps);
try { ps.kill("SIGTERM"); } catch { /* already gone */ }
}
@@ -580,6 +621,7 @@ export class AudioPlayer extends EventEmitter {
}
private forceCleanup(proc: ChildProcess, pid: number): void {
this.intentionalCleanup.add(proc);
if (!globalActivePids.has(pid)) return;
try {
@@ -664,6 +706,7 @@ export class AudioPlayer extends EventEmitter {
duration: this.currentSongDuration,
remaining: Math.round(this.currentSongDuration - elapsed),
nearEnd: isNearEnd,
stderr: this.ffmpegDiagnostics?.getTail() ?? "",
}, "FFmpeg stopped outputting data, ending track");
this.frameLoopRunning = false;
// The outer gate guarantees state==="playing" here, so no !=="idle"