mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-01 14:53:12 +00:00
fix(example/bili): 进程组隔离 + 可取消的下载 —— 修拖死内核关停
## 现象
线上关停必超时:systemd 报 `State 'stop-sigterm' timed out. Killing.`,
其中只有 bili 报 `[proc] bili SIGKILL 后 2s 仍未被收割`,之后近 90 秒无日志。
## 根因
本插件用 `exec.Command("yt-dlp", ...)` + `cmd.Run()`:
- 无 CommandContext ⇒ Stop 无法取消
- 无 Setpgid ⇒ yt-dlp 与本插件同进程组
- Stop() 是 `return nil` ⇒ 内核 Kill 插件本体时,yt-dlp 变孤儿
yt-dlp 还会再 fork ffmpeg,**孙进程继承本插件的 stdout 管道写端**。
插件被 SIGKILL 后孙进程仍持有写端 ⇒ 内核 readLoop 永远等不到 EOF
⇒ `Kill()` 末尾的 readerWG.Wait 永不返回 ⇒ 整个关停挂死。
## 改法
1. 两处 `exec.Command` → `exec.CommandContext`,Stop 里 cancel 能掐掉
2. `setPgid` 让命令自成进程组:yt-dlp 拉起的 ffmpeg 也在组内,
kill(-pgid) 能一次带走整棵子进程树,不留孤儿
3. `Stop()` 不再是空实现:cancel 之后**必须 wait**。
只 cancel 不 wait 的话内核会先释放共享段,而 yt-dlp 还在写 stdout ——
那正是内核 readLoop 挂死的成因。`runWG` 等它真正退出。
顺带:取消时返回明确文案("已取消(插件停止)")而不是含糊的 exec 错误。
内核侧的三处修法(Setpgid / 杀进程组 / readerWG 超时)在主仓 ceeef0b,
对所有 8 个同样 exec.Command 且无进程组隔离的插件都有效。
验证:hmapdev build 通过;主仓部署后实测关停 90s → 1s、
`[homed] stopped` 打出、孙进程无残留。
This commit is contained in:
@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user