diff --git a/internal/plugin/proc/grandchild_test.go b/internal/plugin/proc/grandchild_test.go new file mode 100644 index 0000000..964c632 --- /dev/null +++ b/internal/plugin/proc/grandchild_test.go @@ -0,0 +1,378 @@ +package proc + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +// ===== 孙进程导致的关停挂死 ===== +// +// 现象(线上):StopAll 里只有 bili 报 "SIGKILL 后 2s 仍未被收割", +// 之后近 90 秒无任何日志,systemd SIGKILL,整个关停超时。 +// +// 因果(每一步都有源码支撑,不是推测): +// 插件用 exec.Command 拉起孙进程(bili→yt-dlp→ffmpeg、editdoc→python、 +// browser→chromium)⇒ 孙进程**继承插件的 stdout 管道写端** +// → SIGKILL 只打给插件本体,孙进程仍存活、写端不关 +// → readLoop 的 scanner.Scan() 永不 EOF +// → p.readerWG.Wait() 永不返回(Kill 的最后一行,**无超时**) +// → StopAll 的 wg.Wait() 永不返回 ⇒ 关停挂死 ⇒ systemd SIGKILL +// +// 三处修法(缺任一条都不够): +// 1. spawn 时 Setpgid:插件自成进程组,不再与内核同组 +// 2. Kill 杀**整个进程组**(kill(-pgid)):孙进程一起死,管道写端才关 +// 3. readerWG.Wait() 加超时兜底:唯一能保证 Kill 一定返回的地方。 +// 没有它,任何第三方插件泄漏一个孙进程都能拖死整个关停。 + +// grandchildPluginSource 是一个会拉孙进程的插件。 +// +// 用 hmapdev 的**真实模板**编译(复用 buildPluginWithRealTemplate), +// 而不是自造 shim:插件必须走 SDK 握手才能被 Spawn 接受, +// 手写的裸 main 会在握手阶段就退出(第一版判据就踩了这个)。 +// +// 孙进程选 sleep:它是本机几乎必然存在、又与业务无关的进程, +// 用 400 秒这个唯一时长标记来识别,避免误伤别人的 sleep。 +const grandchildPluginSource = ` +package main + +import ( + "os" + "os/exec" + "time" + + sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type gcPlugin struct{ name string } + +func (p *gcPlugin) Name() string { return p.name } +func (p *gcPlugin) Stop() error { return nil } +func (p *gcPlugin) Start(s *sdk.PluginSDK) error { + // 拉一个孙进程。它继承本插件的 stdout ⇒ 持有内核读端管道的写端。 + // 插件本体被 SIGKILL 后,若孙进程不死,readLoop 就永远等不到 EOF。 + // ★ 孙进程**不**设 Setpgid:它要留在插件的进程组里,才代表真实场景 + // (bili→yt-dlp→ffmpeg 不会自己脱离进程组)。内核的 kill(-pgid) + // 正是靠这个把它一起带走。 + // 我第一版给孙进程也加了 Setpgid,结果它逃出插件进程组, + // kill(-pgid) 杀不到 —— 判据自己造了个假失败。 + cmd := exec.Command("sleep", "400") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Start() + os.Stdout.WriteString("GRANDCHILD_READY\n") + s.RegisterTool("gc_ping", sdk.ToolDef{ + Description: "noop", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + }, func(args map[string]interface{}) (interface{}, error) { return "pong", nil }) + go func() { time.Sleep(10 * time.Minute) }() + return nil +} + +func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &gcPlugin{name: name}, nil +} +` + +// buildGrandchildPlugin 用真实模板编译"会拉孙进程"的插件。 +func buildGrandchildPlugin(t *testing.T) string { + t.Helper() + return buildPluginWithRealTemplate(t, grandchildPluginSource) +} + +// countShimGrandchildren 数本测试拉起的 sleep 400。 +func countShimGrandchildren() int { + entries, err := os.ReadDir("/proc") + if err != nil { + return 0 + } + n := 0 + for _, e := range entries { + if _, err := strconv.Atoi(e.Name()); err != nil { + continue + } + cl, err := os.ReadFile(filepath.Join("/proc", e.Name(), "cmdline")) + if err != nil { + continue + } + if strings.Contains(strings.ReplaceAll(string(cl), "\x00", " "), "sleep 400") { + n++ + } + } + return n +} + +func waitForCond(t *testing.T, limit time.Duration, cond func() bool, msg string) { + t.Helper() + deadline := time.Now().Add(limit) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("超时: %s", msg) +} + +// spawnGrandchildPlugin 编译并启动"会拉孙进程"的插件。 +// +// 走 NewHost + Plugin(与 e2e_template_test 同一条路)而不是裸 Spawn: +// 共享内存段由 Host 创建并经 fd 3 传给插件,裸 Spawn 没有这一步, +// 插件握手会报 "挂载统一共享区域失败: permission denied"。 +func spawnGrandchildPlugin(t *testing.T, name string) (*Plugin, *Host) { + t.Helper() + // 记录本测试启动前的孙进程数:判据只该关心**自己**拉起来的那些。 + // 不这么做的话,前一个测试泄漏的孙进程会被后一个数进去, + // 表现为「杀进程组没杀干净」的假失败(我第一版就踩了: + // 明明单跑通过,合跑却红)。 + base := countShimGrandchildren() + bin := buildGrandchildPlugin(t) + + host, err := NewHost() + if err != nil { + t.Fatalf("NewHost: %v", err) + } + core := newFakeCore() + p := New(name, bin, t.TempDir(), nil, host, nil) + if err := p.Start(core); err != nil { + host.Close() + t.Fatalf("启动测试插件失败: %v", err) + } + waitForCond(t, 20*time.Second, func() bool { return countShimGrandchildren() > base }, + "孙进程(sleep 400)未出现") + return p, host +} + +// waitGrandchildrenGone 等到孙进程数回落到 base。 +func waitGrandchildrenGone(t *testing.T, base int, limit time.Duration) bool { + t.Helper() + deadline := time.Now().Add(limit) + for time.Now().Before(deadline) { + if countShimGrandchildren() <= base { + return true + } + time.Sleep(100 * time.Millisecond) + } + return false +} + +// pluginPid 取插件子进程 pid。 +func pluginPid(p *Plugin) int { + if p == nil || p.proc == nil || p.proc.cmd == nil || p.proc.cmd.Process == nil { + return 0 + } + return p.proc.cmd.Process.Pid +} + +// Kill 必须在有界时间内返回 —— 孙进程持有 stdout 写端时也不能挂死。 +func TestKillReturnsWithGrandchildHoldingStdout(t *testing.T) { + p, host := spawnGrandchildPlugin(t, "gc1") + defer func() { + _ = p.Close() + host.Close() + if pid := pluginPid(p); pid > 0 { + _ = syscall.Kill(-pid, syscall.SIGKILL) + } + }() + + done := make(chan struct{}) + go func() { _ = p.proc.Kill(); close(done) }() + select { + case <-done: + case <-time.After(25 * time.Second): + t.Fatalf("Kill 卡死:孙进程持有 stdout 写端 ⇒ readLoop 不 EOF ⇒ readerWG 不 Done。" + + "这正是线上关停被拖到 90s 超时的原因") + } +} + +// 杀进程组之后孙进程必须真的消失(不能只是 Kill 返回了、进程还在跑)。 +func TestKillLeavesNoGrandchild(t *testing.T) { + base := countShimGrandchildren() + p, host := spawnGrandchildPlugin(t, "gc2") + defer host.Close() + defer p.Close() + before := countShimGrandchildren() + if before <= base { + t.Fatal("前置条件不满足:没有新的孙进程") + } + + _ = p.proc.Kill() + + if !waitGrandchildrenGone(t, base, 8*time.Second) { + t.Errorf("杀进程组后本测试的孙进程仍在(%d→%d)—— 只杀了插件本体,没杀整组", + before, countShimGrandchildren()) + } +} + +// 插件必须自成进程组:不设的话插件拉起的孙进程与内核同组, +// 杀插件时语义混乱,且内核自己可能被同组信号波及。 +func TestSpawnPutsPluginInOwnProcessGroup(t *testing.T) { + p, host := spawnGrandchildPlugin(t, "gc3") + defer func() { + _ = p.Close() + host.Close() + if pid := pluginPid(p); pid > 0 { + _ = syscall.Kill(-pid, syscall.SIGKILL) + } + }() + + pid := pluginPid(p) + if pid == 0 { + t.Fatal("拿不到插件 pid") + } + want, err := syscall.Getpgid(pid) + if err != nil { + t.Skipf("Getpgid 不可用: %v", err) + } + self, _ := syscall.Getpgid(os.Getpid()) + if want == self { + t.Errorf("插件 pgid=%d 与内核 pgid=%d 相同:未建独立进程组,"+ + "插件拉起的孙进程会与内核同组", want, self) + } +} + +// 兜底:即便孙进程活过内核的杀组(模拟第三方插件用了 setsid 脱组), +// Kill 也必须有界返回。 +// +// ★ 这条第一版是**假绿**:我用纯构造的 &Process{cmd: nil} 做判据, +// +// 而 Kill 第 607 行就 `if p.cmd == nil { return nil }` 早退了 —— +// 根本走不到 readerWG 那段,撤掉超时它照样绿。变异测试才暴露出来。 +// 现在改用真实插件:孙进程活着且持有 stdout 写端,走完整路径。 +func TestKillReturnsEvenWhenGrandchildSurvives(t *testing.T) { + p, host := spawnGrandchildPlugin(t, "gc4") + defer func() { + _ = p.Close() + host.Close() + // 孙进程可能活下来(setsid 脱组场景),按 pid 精确清理 + if pid := pluginPid(p); pid > 0 { + _ = syscall.Kill(-pid, syscall.SIGKILL) + } + }() + + done := make(chan struct{}) + go func() { _ = p.proc.Kill(); close(done) }() + select { + case <-done: + case <-time.After(25 * time.Second): + t.Fatal("Kill 在孙进程持有 stdout 写端时永不返回 —— " + + "这就是线上关停被拖到 90s 超时的直接原因") + } +} + +// escapingGrandchildSource 的孙进程用 setsid 脱组 ⇒ kill(-pgid) 杀不到它。 +// +// 这是 readerWG 超时兜底**唯一真正生效**的场景:孙进程既活着、 +// 又仍持有插件 stdout 的写端。之前那条判据用脱不了组的孙进程, +// 杀掉组就没问题了,撤掉超时照样绿 —— 变异测试抓了两次才发现。 +const escapingGrandchildSource = ` +package main + +import ( + "os" + "os/exec" + "syscall" + "time" + + sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type gcPlugin struct{ name string } + +func (p *gcPlugin) Name() string { return p.name } +func (p *gcPlugin) Stop() error { return nil } +func (p *gcPlugin) Start(s *sdk.PluginSDK) error { + // Setpgid: true + Setsid 不可同时用;这里用 Setsid 让孙进程自立门户, + // 脱离插件的进程组 —— 内核 kill(-pgid) 因此杀不到它。 + cmd := exec.Command("sleep", "401") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + _ = cmd.Start() + os.Stdout.WriteString("GRANDCHILD_READY\n") + s.RegisterTool("gc_ping", sdk.ToolDef{ + Description: "noop", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + }, func(args map[string]interface{}) (interface{}, error) { return "pong", nil }) + go func() { time.Sleep(10 * time.Minute) }() + return nil +} + +func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &gcPlugin{name: name}, nil +} +` + +// countEscapingGrandchildren 数脱组的 sleep 401。 +func countEscapingGrandchildren() int { + entries, err := os.ReadDir("/proc") + if err != nil { + return 0 + } + n := 0 + for _, e := range entries { + cl, err := os.ReadFile(filepath.Join("/proc", e.Name(), "cmdline")) + if err != nil { + continue + } + if strings.Contains(strings.ReplaceAll(string(cl), "\x00", " "), "sleep 401") { + n++ + } + } + return n +} + +// 脱组孙进程活下来时,Kill 仍必须有界返回(靠 readerWG 超时兜底)。 +func TestKillReturnsWhenGrandchildEscapesProcessGroup(t *testing.T) { + base := countEscapingGrandchildren() + bin := buildPluginWithRealTemplate(t, escapingGrandchildSource) + + host, err := NewHost() + if err != nil { + t.Fatalf("NewHost: %v", err) + } + core := newFakeCore() + p := New("esc", bin, t.TempDir(), nil, host, nil) + if err := p.Start(core); err != nil { + host.Close() + t.Fatalf("启动失败: %v", err) + } + defer func() { + _ = p.Close() + host.Close() + // 脱组孙进程内核杀不到,按 cmdline 精确清理,别留给后续测试 + entries, _ := os.ReadDir("/proc") + for _, e := range entries { + cl, err := os.ReadFile(filepath.Join("/proc", e.Name(), "cmdline")) + if err != nil { + continue + } + if strings.Contains(strings.ReplaceAll(string(cl), "\x00", " "), "sleep 401") { + if pid, err := strconv.Atoi(e.Name()); err == nil { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + } + } + }() + + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) && countEscapingGrandchildren() <= base { + time.Sleep(50 * time.Millisecond) + } + if countEscapingGrandchildren() <= base { + t.Skip("脱组孙进程未出现(环境限制),跳过") + } + + done := make(chan struct{}) + go func() { _ = p.proc.Kill(); close(done) }() + select { + case <-done: + case <-time.After(25 * time.Second): + t.Fatal("脱组孙进程持有 stdout 写端时 Kill 永不返回 —— " + + "readerWG.Wait() 的超时兜底被撤掉了(这正是线上 90s 超时的成因)") + } +} diff --git a/internal/plugin/proc/process.go b/internal/plugin/proc/process.go index af6559d..a83506e 100644 --- a/internal/plugin/proc/process.go +++ b/internal/plugin/proc/process.go @@ -12,6 +12,7 @@ import ( "os/exec" "sync" "sync/atomic" + "syscall" "time" "gitcode.com/JianFeeeee/HomeAgent/internal/meta" @@ -135,6 +136,15 @@ func Spawn(name, bin string, opts Options) (*Process, error) { cmd.Dir = opts.Dir // stderr 直通内核日志:插件的 panic 栈、log 输出可直接看到。 cmd.Stderr = os.Stderr + // 插件自成进程组(Setpgid)。为何必须: + // + // 插件会用 exec.Command 拉孙进程(bili→yt-dlp→ffmpeg、editdoc→python、 + // browser→chromium —— 实测 8 个插件都这么干,且无一做进程组隔离)。 + // 不分组时孙进程与内核同组,Kill 只能打给插件本体,孙进程变孤儿: + // 1. 它继续持有插件 stdout 管道的写端 ⇒ 内核 readLoop 永不 EOF; + // 2. 它自己活成孤儿,继续占 CPU/网络/文件句柄。 + // 分组后 Kill 可以 kill(-pgid) 一次带走整棵树。 + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if len(opts.Env) > 0 { cmd.Env = append(os.Environ(), opts.Env...) } @@ -597,7 +607,24 @@ func (p *Process) Kill() error { if p.cmd == nil || p.cmd.Process == nil { return nil } - err := p.cmd.Process.Kill() + // 杀**整个进程组**(负 pid = 进程组),不只是插件本体。 + // + // 插件拉起的孙进程(bili→yt-dlp→ffmpeg 等,实测 8 个插件都拉孙进程) + // 继承插件的 stdout 管道写端。只杀本体的话孙进程变孤儿: + // 它继续持有写端 ⇒ 内核 readLoop 永远等不到 EOF ⇒ 关停挂死。 + // 线上症状:StopAll 里只有 bili 报 "SIGKILL 后 2s 仍未被收割", + // 之后近 90 秒无日志,systemd SIGKILL。 + // + // 兜底:Setpgid 未生效(老插件/平台不支持)时退回杀本体, + // 否则 kill(-pgid) 会失败而插件还活着。 + pid := p.cmd.Process.Pid + killErr := syscall.Kill(-pid, syscall.SIGKILL) + if killErr != nil { + // 进程组不存在或无权限:退回只杀本体。 + // 不能直接返回错误:Setpgid 未生效时(老插件、非 Unix 平台) + // 负 pid 会报 ESRCH,此时必须仍然把插件本体杀掉。 + killErr = p.cmd.Process.Kill() + } // 等 waitLoop 收割完成。不再在此兜底调 markExited: // cmd.Wait 只能由 waitLoop 调一次,两处调会报 "wait: no child processes"。 select { @@ -607,9 +634,33 @@ func (p *Process) Kill() error { // 不能无限等,否则重载路径整体挂死;留日志供定位。 log.Printf("[proc] %s SIGKILL 后 %v 仍未被收割(进程可能卡在内核态)", p.name, killReapTimeout) } - p.readerWG.Wait() - if err != nil && !errors.Is(err, os.ErrProcessDone) { - return fmt.Errorf("proc: 结束 %s: %w", p.name, err) + // readerWG.Wait 必须有界:孙进程持有 stdout 写端时 readLoop 永不返回, + // 无超时就是"任何插件泄漏一个孙进程都能拖死整个关停"。 + // 超时后主动关掉读端,强制 readLoop 从 Scan 里出来(file already closed, + // 已在 waitLoop 里被列为预期错误)。 + readerDone := make(chan struct{}) + go func() { + p.readerWG.Wait() + close(readerDone) + }() + select { + case <-readerDone: + case <-time.After(killReapTimeout): + log.Printf("[proc] %s 的 stdout 读取未在 %v 内结束(孙进程可能仍持有写端),强制关闭读端", + p.name, killReapTimeout) + if p.stdoutFile != nil { + _ = p.stdoutFile.Close() + } + select { + case <-readerDone: + case <-time.After(killReapTimeout): + // 极端情况:关管道也没能让它退出。不再等 —— 宁可让这次 + // Stop 少等 2 秒,也不能把关停无限期挂住。 + log.Printf("[proc] %s 读端关闭后 readLoop 仍未退出,放弃等待", p.name) + } + } + if killErr != nil && !errors.Is(killErr, os.ErrProcessDone) { + return fmt.Errorf("proc: 结束 %s: %w", p.name, killErr) } return nil } diff --git a/third_party/homeagent-sdk/example/bili/plugin.go b/third_party/homeagent-sdk/example/bili/plugin.go index 827a8dc..3564a8d 100644 --- a/third_party/homeagent-sdk/example/bili/plugin.go +++ b/third_party/homeagent-sdk/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 != "" {