diff --git a/example/bili/plugin.go b/example/bili/plugin.go index 827a8dc..3564a8d 100644 --- a/example/bili/plugin.go +++ b/example/bili/plugin.go @@ -2,12 +2,15 @@ package main import ( "bytes" + "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" + "sync" + "syscall" "time" "gitcode.com/JianFeeeee/homeagent-sdk/sdk" @@ -17,6 +20,41 @@ type Plugin struct { name string sdk *sdk.PluginSDK proxy string + + // runCancel 取消**正在跑**的 yt-dlp;runWG 等它真正退出。 + // + // 为何需要:下载是分钟级操作,而 Stop() 必须能把它掐掉。 + // 只 cancel 不 wait 的话内核会在插件死后立刻释放共享段, + // 而 yt-dlp 还在往插件的 stdout 写 —— 那正是内核 readLoop 挂死的成因。 + runMu sync.Mutex + runCancel context.CancelFunc + runWG sync.WaitGroup +} + +// trackRun 登记一次外部命令运行,返回完成时调用 untrack。 +func (p *Plugin) trackRun() (context.Context, func()) { + ctx, cancel := context.WithCancel(context.Background()) + p.runMu.Lock() + p.runCancel = cancel + p.runWG.Add(1) + p.runMu.Unlock() + return ctx, func() { + p.runWG.Done() + p.runMu.Lock() + p.runCancel = nil + p.runMu.Unlock() + } +} + +// killRunGroup 掐掉正在跑的 yt-dlp 及其子进程(ffmpeg 等)。 +func (p *Plugin) killRunGroup(pid int) { + if pid <= 0 { + return + } + // 负 pid = 整个进程组(yt-dlp 拉起的 ffmpeg 也在内) + if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { + _ = syscall.Kill(pid, syscall.SIGKILL) + } } func (p *Plugin) Name() string { return p.name } @@ -30,13 +68,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { Key: "output_dir", Default: "/tmp/bili_videos", Type: "string", DisplayName: "下载目录", Description: "B站视频下载后的保存目录", - Category: p.name, + Category: p.name, }) s.Settings().RegisterDef(sdk.ConfigDef{ Key: "proxy", Default: "", Type: "string", DisplayName: "HTTP 代理", Description: "yt-dlp 下载使用的 HTTP 代理地址(如 http://127.0.0.1:7890),留空则不设置", - Category: p.name, + Category: p.name, }) if v, _ := s.Settings().Get("proxy"); v != nil { if str, ok := v.(string); ok { @@ -67,7 +105,24 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { return nil } -func (p *Plugin) Stop() error { return nil } +// Stop 取消并等待正在跑的下载。 +// +// 空实现的代价(实测):yt-dlp 是分钟级操作,Stop 时它还在跑, +// 而它**继承本插件的 stdout**。内核 Kill 掉本插件后,yt-dlp 变孤儿 +// 且继续持有管道写端 ⇒ 内核 readLoop 永远等不到 EOF ⇒ 整个关停挂死 +// 直到 systemd 90 秒超时 SIGKILL(线上症状:只有 bili 报 +// "SIGKILL 后 2s 仍未被收割",之后近 90 秒无日志)。 +func (p *Plugin) Stop() error { + p.runMu.Lock() + cancel := p.runCancel + p.runMu.Unlock() + if cancel != nil { + cancel() + } + // 等它真的退出:不等的话内核会先释放共享段,孙进程仍在写 stdout。 + p.runWG.Wait() + return nil +} type ytdlpFormat struct { FormatID string `json:"format_id"` @@ -84,11 +139,11 @@ type ytdlpFormat struct { } type ytdlpInfo struct { - Title string `json:"title"` - Duration float64 `json:"duration"` - WebpageURL string `json:"webpage_url"` - Filename string `json:"_filename"` - Formats []ytdlpFormat `json:"formats"` + Title string `json:"title"` + Duration float64 `json:"duration"` + WebpageURL string `json:"webpage_url"` + Filename string `json:"_filename"` + Formats []ytdlpFormat `json:"formats"` } func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, error) { @@ -119,11 +174,20 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro var out bytes.Buffer ytdlpArgs := []string{"--no-warnings", "--dump-json", url} - cmd := exec.Command("yt-dlp", ytdlpArgs...) + ctx, done := p.trackRun() + defer done() + // CommandContext:Stop 里的 cancel 能直接掐掉它。 + // Setpgid:让 yt-dlp 自成进程组,它再拉的 ffmpeg 也在组内, + // killRunGroup 能一次带走整棵子进程树。 + cmd := exec.CommandContext(ctx, "yt-dlp", ytdlpArgs...) cmd.Stdout = &out cmd.Stderr = &out cmd.Env = proxyEnv(p.proxy) + setPgid(cmd) if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("yt-dlp info 已取消(插件停止)") + } return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String())) } @@ -209,12 +273,16 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro dlArgs = append(dlArgs, "-f", format) } dlArgs = append(dlArgs, url) - cmd2 := exec.Command("yt-dlp", dlArgs...) + cmd2 := exec.CommandContext(ctx, "yt-dlp", dlArgs...) cmd2.Env = proxyEnv(p.proxy) var dlOut bytes.Buffer cmd2.Stdout = &dlOut cmd2.Stderr = &dlOut + setPgid(cmd2) if err := cmd2.Run(); err != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("下载已取消(插件停止)") + } return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String())) } @@ -256,6 +324,15 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro }, nil } +// setPgid 让命令自成进程组:它自己拉的子进程(yt-dlp → ffmpeg) +// 都在同一组里,kill(-pgid) 能一次带走,避免孤儿持有 stdout 管道。 +func setPgid(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true +} + func proxyEnv(proxy string) []string { env := os.Environ() if proxy != "" {