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
+252
View File
@@ -0,0 +1,252 @@
package executor
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
// waitOptions are the parsed arguments of `kopsc proc wait`.
type waitOptions struct {
keywords []string // must all match (logical AND)
pids []int // explicit PIDs to watch
interval time.Duration // poll period, 1s by default
timeout time.Duration // 0 means wait forever
once bool // check once and report, do not wait
quiet bool // do not print progress
}
// runProcWait implements `kopsc proc wait`: poll until the matched processes
// are gone, so a script can block like `while pgrep nginx; do sleep 1; done`
// but with an explicit target and optional timeout.
//
// Exit codes: 0 when the processes have exited (or --once found none),
// 2 when --timeout expires first, 1 on usage or runtime errors.
func runProcWait(ctx context.Context, req BuiltinRequest) error {
opts, err := parseProcWaitArgs(req.Args)
if err != nil {
return err
}
if len(opts.keywords) == 0 && len(opts.pids) == 0 {
return errors.New("缺少要等待的进程;用法: kopsc proc wait <关键字...> [--pid <PID>]")
}
describe := describeWaitTarget(opts)
start := time.Now()
remaining, err := findWaiting(ctx, opts)
if err != nil {
return err
}
if len(remaining) == 0 {
if !opts.once {
fmt.Fprintf(req.Stdout, "%s 当前没有运行\n", describe)
}
return nil
}
if !opts.quiet {
fmt.Fprintf(req.Stdout, "等待 %s 退出(共 %d 个进程): %s\n", describe, len(remaining), summarizeProcesses(remaining))
}
if opts.once {
return nil
}
var timeout <-chan time.Time
if opts.timeout > 0 {
timer := time.NewTimer(opts.timeout)
defer timer.Stop()
timeout = timer.C
}
ticker := time.NewTicker(opts.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-timeout:
left, _ := findWaiting(ctx, opts)
return &ExitError{Code: 2, Err: fmt.Errorf(
"等待超时(%s):%s 仍在运行: %s", opts.timeout, describe, summarizeProcesses(left))}
case <-ticker.C:
left, err := findWaiting(ctx, opts)
if err != nil {
return err
}
if len(left) == 0 {
if !opts.quiet {
fmt.Fprintf(req.Stdout, "%s 已退出(等待 %s)\n", describe, time.Since(start).Round(time.Second))
}
return nil
}
}
}
}
// findWaiting returns the processes that still satisfy the wait target.
func findWaiting(ctx context.Context, opts waitOptions) ([]processInfo, error) {
procs, err := processLister(ctx)
if err != nil {
return nil, err
}
var remaining []processInfo
seen := map[int]bool{}
for _, pid := range opts.pids {
ref, ok := processesWithPID(procs, pid)
if ok {
remaining = append(remaining, ref)
seen[pid] = true
}
}
for _, ref := range processesMatching(procs, opts.keywords) {
if !seen[ref.PID] {
remaining = append(remaining, ref)
}
}
return remaining, nil
}
// describeWaitTarget renders what is being waited for, for the progress lines.
func describeWaitTarget(opts waitOptions) string {
var parts []string
if len(opts.keywords) > 0 {
if len(opts.keywords) == 1 {
parts = append(parts, "进程 "+opts.keywords[0])
} else {
parts = append(parts, "进程 "+strings.Join(opts.keywords, " 且 "))
}
}
if len(opts.pids) > 0 {
ids := make([]string, len(opts.pids))
for i, pid := range opts.pids {
ids[i] = strconv.Itoa(pid)
}
parts = append(parts, "PID "+strings.Join(ids, ","))
}
return strings.Join(parts, ",")
}
// summarizeProcesses renders "1234(nginx), 5678(nginx: worker)" for messages,
// keeping the line readable when many processes match.
func summarizeProcesses(procs []processInfo) string {
const max = 5
parts := make([]string, 0, max)
for i, p := range procs {
if i == max {
parts = append(parts, fmt.Sprintf("…共 %d 个", len(procs)))
break
}
name := p.Name
if name == "" {
name = "?"
}
parts = append(parts, fmt.Sprintf("%d(%s)", p.PID, name))
}
return strings.Join(parts, ", ")
}
// parseProcWaitArgs parses kopsc's options; everything else is a keyword.
func parseProcWaitArgs(args []string) (waitOptions, error) {
opts := waitOptions{interval: time.Second}
positionalOnly := false
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" {
positionalOnly = true
continue
}
if !positionalOnly && strings.HasPrefix(arg, "-") && arg != "-" {
name, value := arg, ""
if eq := strings.IndexByte(arg, '='); eq >= 0 && strings.HasPrefix(arg, "--") {
name, value = arg[:eq], arg[eq+1:]
}
takeValue := func() (string, error) {
if value != "" {
return value, nil
}
if i+1 >= len(args) {
return "", fmt.Errorf("%s 缺少参数", name)
}
i++
return args[i], nil
}
switch name {
case "--pid", "-p":
raw, err := takeValue()
if err != nil {
return opts, err
}
// Allow comma-separated lists: --pid 1234,5678
for _, field := range strings.Split(raw, ",") {
pid, err := strconv.Atoi(strings.TrimSpace(field))
if err != nil || pid <= 0 {
return opts, fmt.Errorf("--pid 需要正整数 PID,收到 %q", field)
}
opts.pids = append(opts.pids, pid)
}
case "--interval":
raw, err := takeValue()
if err != nil {
return opts, err
}
d, err := parseWaitDuration(raw)
if err != nil {
return opts, fmt.Errorf("--interval: %w", err)
}
opts.interval = d
case "--timeout":
raw, err := takeValue()
if err != nil {
return opts, err
}
d, err := parseWaitDuration(raw)
if err != nil {
return opts, fmt.Errorf("--timeout: %w", err)
}
opts.timeout = d
case "--once":
opts.once = true
case "-q", "--quiet":
opts.quiet = true
default:
return opts, fmt.Errorf("未知选项 %q;进程关键字请直接作为参数传入,或用 -- 分隔", arg)
}
continue
}
opts.keywords = append(opts.keywords, arg)
}
if opts.interval <= 0 {
return opts, errors.New("--interval 必须大于 0")
}
return opts, nil
}
// parseWaitDuration accepts a Go duration ("1.5s", "500ms") or plain seconds
// ("30"), which is what most users type.
func parseWaitDuration(raw string) (time.Duration, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, errors.New("缺少时长")
}
if secs, err := strconv.Atoi(raw); err == nil {
if secs < 0 {
return 0, fmt.Errorf("时长不能为负: %q", raw)
}
return time.Duration(secs) * time.Second, nil
}
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
return 0, fmt.Errorf("无法识别时长 %q,可用 30(秒)或 30s、500ms、1m", raw)
}
return d, nil
}