feat: 新增 proc wait,等待进程退出

- 每秒检查一次目标进程,存在则继续等待,直到消失为止
- 关键字在进程名与命令行中匹配(忽略大小写),多个关键字为逻辑与;
  --pid 可直接指定进程 ID(逗号分隔可多个)
- 默认不超时,--timeout 限时(退出码 2 并列出残留 PID),--once 只检查一次
- 三平台实现:Linux 读 /proc(回退 ps)、macOS 用 ps、Windows 用 tasklist
- ExitError 改为优先展示具体原因,不再只显示「命令以状态码 N 退出」
- 文档:README.proc.md 新增完整小节,主 README 补充示例与速查
This commit is contained in:
2026-11-01 09:01:32 +08:00
parent 3725f8307a
commit 14817bcfe7
10 changed files with 837 additions and 3 deletions
+178
View File
@@ -0,0 +1,178 @@
package executor
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
)
// processInfo is one running process as kopsc sees it.
type processInfo struct {
PID int
Name string // executable name, e.g. "nginx"
Command string // full command line when available
}
// Matches reports whether the process satisfies every keyword: a keyword hits
// when it appears in the process name or anywhere in its command line. All
// keywords must match (logical AND).
func (p processInfo) Matches(keywords []string) bool {
haystack := strings.ToLower(p.Name + " " + p.Command)
for _, kw := range keywords {
if !strings.Contains(haystack, strings.ToLower(kw)) {
return false
}
}
return true
}
// processLister is how kopsc enumerates processes. It is a variable so the
// wait command can be tested without depending on the host's ps/proc.
var processLister = listProcesses
// listProcesses returns every process visible to the current user, using the
// cheapest mechanism the platform offers.
func listProcesses(ctx context.Context) ([]processInfo, error) {
switch runtime.GOOS {
case "linux":
procs, err := listProcessesProc("/proc")
if err == nil {
return procs, nil
}
// A hardened container may hide /proc entries; fall through to ps.
return listProcessesPS(ctx)
case "windows":
return listProcessesTasklist(ctx)
default:
return listProcessesPS(ctx)
}
}
// listProcessesProc reads /proc/<pid>/comm and cmdline. Unreadable entries
// (other users' processes, kernel threads) are skipped rather than failing.
func listProcessesProc(procRoot string) ([]processInfo, error) {
entries, err := os.ReadDir(procRoot)
if err != nil {
return nil, err
}
var procs []processInfo
for _, entry := range entries {
pid, err := strconv.Atoi(entry.Name())
if err != nil {
continue
}
dir := filepath.Join(procRoot, entry.Name())
name := ""
if data, err := os.ReadFile(filepath.Join(dir, "comm")); err == nil {
name = strings.TrimSpace(string(data))
}
command := ""
if data, err := os.ReadFile(filepath.Join(dir, "cmdline")); err == nil {
// cmdline is NUL-separated.
command = strings.TrimSpace(strings.ReplaceAll(string(data), "\x00", " "))
}
if name == "" && command == "" {
continue
}
procs = append(procs, processInfo{PID: pid, Name: name, Command: command})
}
if len(procs) == 0 {
return nil, fmt.Errorf("无法从 %s 读取任何进程", procRoot)
}
return procs, nil
}
// listProcessesPS parses `ps -eo pid=,args=`, which works on macOS and on Linux
// hosts where /proc is unavailable.
func listProcessesPS(ctx context.Context) ([]processInfo, error) {
out, err := exec.CommandContext(ctx, "ps", "-eo", "pid=,args=").Output()
if err != nil {
return nil, fmt.Errorf("无法列出进程(ps -eo pid=,args= 执行失败: %v);"+
"请确认 ps 可用且当前用户有权限", err)
}
return parsePS(string(out)), nil
}
// parsePS parses rows of " 1234 /usr/sbin/nginx -g daemon off;".
func parsePS(out string) []processInfo {
var procs []processInfo
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
pidField, rest, _ := strings.Cut(line, " ")
pid, err := strconv.Atoi(pidField)
if err != nil {
continue
}
rest = strings.TrimSpace(rest)
name := rest
if i := strings.IndexByte(name, ' '); i >= 0 {
name = name[:i]
}
name = filepath.Base(name)
procs = append(procs, processInfo{PID: pid, Name: name, Command: rest})
}
return procs
}
// listProcessesTasklist parses `tasklist /fo csv /nh` on Windows.
func listProcessesTasklist(ctx context.Context) ([]processInfo, error) {
out, err := exec.CommandContext(ctx, "tasklist", "/fo", "csv", "/nh").Output()
if err != nil {
return nil, fmt.Errorf("无法列出进程(tasklist 执行失败: %v)", err)
}
return parseTasklist(string(out)), nil
}
// parseTasklist parses CSV rows: "nginx.exe","1234","Console","1","12,345 K".
// tasklist does not report the command line, so keywords match the image name.
func parseTasklist(out string) []processInfo {
var procs []processInfo
for _, line := range strings.Split(out, "\n") {
fields := strings.Split(strings.TrimSpace(line), ",")
if len(fields) < 2 {
continue
}
name := strings.Trim(fields[0], `"`)
// The PID column may be quoted and is never comma-grouped.
pid, err := strconv.Atoi(strings.Trim(fields[1], `" `))
if err != nil {
continue
}
procs = append(procs, processInfo{PID: pid, Name: name})
}
return procs
}
// processesMatching returns the processes that satisfy all keywords.
func processesMatching(procs []processInfo, keywords []string) []processInfo {
if len(keywords) == 0 {
return nil
}
var out []processInfo
for _, p := range procs {
if p.Matches(keywords) {
out = append(out, p)
}
}
return out
}
// processesWithPID returns the process with the given PID, if it exists.
func processesWithPID(procs []processInfo, pid int) (processInfo, bool) {
for _, p := range procs {
if p.PID == pid {
return p, true
}
}
return processInfo{}, false
}