Files
kira1928andClaude Fable 5 d5d8b797e4 fix: 处理第三轮 code review 意见(并发安全、路径语义一致性、后处理等待)
Gemini 高危:
- FFmpegAsyncInit 增加 ffmpegInitRunning CAS 守卫,并发调用(如 e2e
  多次 reinit)时只有第一个生效,避免并发 tool.Install()
- setFFmpegStatus 用 ffmpegStatusMu 串行化存储、变化通知与回调执行,
  保证订阅者看到的状态顺序与实际更新顺序一致
- ./ffmpeg 兜底改为 LookPath 任何失败都尝试:Unix 下当前目录不在 PATH,
  返回 ErrNotFound 而非 ErrDot,原来只处理 ErrDot 在 Unix 上不生效;
  该逻辑收敛到 utils.LookupSystemFFmpeg 供状态检测与实际查找共用

Gemini 中危:
- tool.Install() 前检查 ctx.Err(),程序退出中不再发起耗时下载

Codex P2:
- 配置了无效 ffmpeg_path 时不再走 remotetools 下载(下载了实际录制也
  不会用),直接置 not_found 并提示修正配置,状态与实际查找语义一致;
  e2e 相应改为 ffmpeg_path 留空 + BILILIVE_IGNORE_SYSTEM_FFMPEG=1
  环境变量屏蔽系统 FFmpeg(CI 预装 ffmpeg,无法用真实 PATH 模拟)
- 等待逻辑上移为 tools.WaitFFmpegAsyncInitDone 供多处复用:ffmpeg
  parser、pipeline 后处理阶段(convert_mp4 / burn_subtitles /
  custom_command)及 recorder 的 legacy custom commandline 在获取
  FFmpeg 路径前等待后台初始化结束,短直播后处理不再因下载未完成而失败

Copilot:
- WaitForToolsInit 轮询改用 time.NewTicker 复用计时器
- FFmpegBanner 初始状态查询失败时打印 console.warn,不再静默吞掉
- mock server 检查 w.Write 错误,客户端断开后立即停止发送

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:16:30 +09:00

250 lines
7.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ffmpeg-mock-server 是一个用于 e2e 测试的模拟 FFmpeg 下载服务器。
//
// 它在启动时编译 test/fake-ffmpeg,将其打包成 zip 压缩包,
// 并通过 HTTP 提供下载,支持速度限制和失败模拟以测试各种场景。
//
// API:
//
// GET /health — 健康检查(等待 zip 准备好再返回 200)
// GET /ffmpeg.zip — 下载 fake-ffmpeg zip(?speed=<字节/秒>, ?fail=true)
// POST /control — 运行时修改行为 {"speed": N, "fail": true/false}
package main
import (
"archive/zip"
"bytes"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"sync/atomic"
"time"
)
var (
port = flag.Int("port", 8890, "监听端口")
// 初始限速通过启动参数指定,确保在 bgo 启动前就已生效
// (若依赖测试用例运行时才调用 /control 设置,bgo 可能已经不限速地完成下载)
initialSpeed = flag.Int64("speed", 0, "初始全局限速(字节/秒),0 = 不限速")
// 全局控制状态
globalSpeedBytesPerSec atomic.Int64 // 0 = 不限速
globalFail atomic.Bool
zipData []byte
zipReady = make(chan struct{})
zipBuildErr error
)
func main() {
flag.Parse()
globalSpeedBytesPerSec.Store(*initialSpeed)
go buildZip()
mux := http.NewServeMux()
mux.HandleFunc("/health", handleHealth)
mux.HandleFunc("/ffmpeg.zip", handleDownload)
mux.HandleFunc("/control", handleControl)
addr := fmt.Sprintf("127.0.0.1:%d", *port)
log.Printf("[ffmpeg-mock] 启动服务器: http://%s", addr)
server := &http.Server{Addr: addr, Handler: mux}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[ffmpeg-mock] 服务器错误: %v", err)
}
}
func buildZip() {
defer close(zipReady)
data, err := buildFakeFfmpegZip()
if err != nil {
zipBuildErr = err
log.Printf("[ffmpeg-mock] 构建 fake-ffmpeg zip 失败: %v", err)
return
}
zipData = data
log.Printf("[ffmpeg-mock] fake-ffmpeg zip 已就绪,大小: %d 字节", len(data))
}
// handleHealth 等待 zip 就绪后返回 200,供 Playwright 做启动检查
func handleHealth(w http.ResponseWriter, r *http.Request) {
select {
case <-zipReady:
case <-time.After(120 * time.Second):
http.Error(w, "zip build timeout", http.StatusServiceUnavailable)
return
}
if zipBuildErr != nil {
http.Error(w, "zip build failed: "+zipBuildErr.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
}
// handleDownload 提供 fake-ffmpeg zip 下载,支持速度限制和失败模拟
func handleDownload(w http.ResponseWriter, r *http.Request) {
<-zipReady
if zipBuildErr != nil {
http.Error(w, "zip not available: "+zipBuildErr.Error(), http.StatusInternalServerError)
return
}
// 查询参数中的 speed 覆盖全局限速(仅对本次请求生效)
var querySpeed *int64
if s := r.URL.Query().Get("speed"); s != "" {
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
querySpeed = &v
}
}
// 每次发送 chunk 前重新求值,让 /control 的运行时调速对进行中的下载立即生效
// (例如测试先以 1KB/s 观察 downloading 状态,再调为 0 让下载迅速完成)
currentSpeed := func() int64 {
if querySpeed != nil {
return *querySpeed
}
return globalSpeedBytesPerSec.Load()
}
fail := globalFail.Load()
if r.URL.Query().Get("fail") == "true" {
fail = true
}
if fail {
// 返回 500 模拟下载失败,让 remotetools Install() 报错
http.Error(w, "simulated download failure", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="ffmpeg.zip"`)
w.Header().Set("Content-Length", strconv.Itoa(len(zipData)))
w.WriteHeader(http.StatusOK)
// 限速发送(speed <= 0 时一次性发送剩余数据)。
// 写入失败说明客户端已断开,立即返回以免继续空转 sleep
const chunkSize = 4096
data := zipData
for len(data) > 0 {
speed := currentSpeed()
if speed <= 0 {
w.Write(data)
return
}
n := chunkSize
if n > len(data) {
n = len(data)
}
if _, err := w.Write(data[:n]); err != nil {
log.Printf("[ffmpeg-mock] 客户端中断下载: %v", err)
return
}
data = data[n:]
if flusher, ok2 := w.(http.Flusher); ok2 {
flusher.Flush()
}
if len(data) > 0 {
delay := time.Duration(float64(n) / float64(speed) * float64(time.Second))
time.Sleep(delay)
}
}
}
type controlRequest struct {
Speed *int64 `json:"speed"` // nil = 不修改
Fail *bool `json:"fail"` // nil = 不修改
}
func handleControl(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "only POST", http.StatusMethodNotAllowed)
return
}
var req controlRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid body: "+err.Error(), http.StatusBadRequest)
return
}
if req.Speed != nil {
globalSpeedBytesPerSec.Store(*req.Speed)
}
if req.Fail != nil {
globalFail.Store(*req.Fail)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"speed":%d,"fail":%v}`,
globalSpeedBytesPerSec.Load(), globalFail.Load())
}
// buildFakeFfmpegZip 编译 test/fake-ffmpeg 并打包成 zip
func buildFakeFfmpegZip() ([]byte, error) {
// 支持从项目根或 test/ffmpeg-mock-server 目录运行
fakeFfmpegPkg := "./test/fake-ffmpeg"
if _, err := os.Stat("test/fake-ffmpeg"); os.IsNotExist(err) {
fakeFfmpegPkg = "../fake-ffmpeg"
}
tmpDir, err := os.MkdirTemp("", "ffmpeg-mock-*")
if err != nil {
return nil, fmt.Errorf("创建临时目录失败: %w", err)
}
defer os.RemoveAll(tmpDir)
binaryName := "ffmpeg"
if runtime.GOOS == "windows" {
binaryName = "ffmpeg.exe"
}
outBinary := filepath.Join(tmpDir, binaryName)
args := []string{"build", "-o", outBinary, fakeFfmpegPkg}
cmd := exec.Command("go", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
log.Printf("[ffmpeg-mock] 编译 fake-ffmpeg: go %v", args)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("编译 fake-ffmpeg 失败: %w", err)
}
binaryData, err := os.ReadFile(outBinary)
if err != nil {
return nil, fmt.Errorf("读取编译产物失败: %w", err)
}
// 模拟真实 FFmpeg 发布包结构:单一顶层目录 + bin/ 子目录。
// remotetools 解压时会把单一顶层目录视为冗余目录剥掉,
// 剥掉后剩余的 bin/ffmpeg 需与 config 中的 pathToEntry 一致。
entryPath := "ffmpeg-fake-build/bin/ffmpeg"
if runtime.GOOS == "windows" {
entryPath = "ffmpeg-fake-build/bin/ffmpeg.exe"
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
header := &zip.FileHeader{
Name: entryPath,
Method: zip.Deflate,
}
header.SetMode(0o755)
fw, err := zw.CreateHeader(header)
if err != nil {
return nil, fmt.Errorf("创建 zip 条目失败: %w", err)
}
if _, err = fw.Write(binaryData); err != nil {
return nil, fmt.Errorf("写入 zip 条目失败: %w", err)
}
if err = zw.Close(); err != nil {
return nil, fmt.Errorf("关闭 zip 写入器失败: %w", err)
}
return buf.Bytes(), nil
}