Files
kenaito-kopsc/internal/executor/executor.go
T
odboy 14817bcfe7 feat: 新增 proc wait,等待进程退出
- 每秒检查一次目标进程,存在则继续等待,直到消失为止
- 关键字在进程名与命令行中匹配(忽略大小写),多个关键字为逻辑与;
  --pid 可直接指定进程 ID(逗号分隔可多个)
- 默认不超时,--timeout 限时(退出码 2 并列出残留 PID),--once 只检查一次
- 三平台实现:Linux 读 /proc(回退 ps)、macOS 用 ps、Windows 用 tasklist
- ExitError 改为优先展示具体原因,不再只显示「命令以状态码 N 退出」
- 文档:README.proc.md 新增完整小节,主 README 补充示例与速查
2026-11-01 09:01:32 +08:00

228 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package executor turns a resolved variant into a process, with dry-run
// support and faithful stdio/exit-code passthrough.
package executor
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"runtime"
"strings"
"unicode"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/resolve"
)
// Options controls how a Plan is executed.
type Options struct {
DryRun bool
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Env []string // optional override of the child environment
Dir string // optional working directory
}
// Plan is a resolved variant plus arguments forwarded from the caller.
type Plan struct {
Result resolve.Result
ExtraArgs []string
}
// Argv returns the final argument vector: the variant's argv followed by the
// forwarded arguments.
func (p Plan) Argv() []string {
argv := make([]string, 0, len(p.Result.Variant.Argv)+len(p.ExtraArgs))
argv = append(argv, p.Result.Variant.Argv...)
argv = append(argv, p.ExtraArgs...)
return argv
}
// Display renders the command line the plan would run.
func (p Plan) Display() string {
if p.Result.Variant.Builtin != "" {
parts := append([]string{"(内置)", p.Result.Variant.Builtin}, p.ExtraArgs...)
return strings.Join(parts, " ")
}
argv := p.Argv()
if p.Result.Variant.Shell {
flag := "-c"
if runtime.GOOS == "windows" {
flag = "/c"
}
return shellName() + " " + flag + " " + quoteForShell(strings.Join(argv, " "), runtime.GOOS)
}
return strings.Join(argv, " ")
}
// PreviewBuiltin returns the builtin advertised by the resolved command for
// listing what it would touch, or "" when the command has none.
func (p Plan) PreviewBuiltin() string {
return p.Result.Command.ConfirmBuiltin
}
// Explain writes why this variant was chosen and what will run.
func (p Plan) Explain(w io.Writer) {
if w == nil {
return
}
variant := p.Result.Variant
fmt.Fprintf(w, "将执行: %s\n", p.Display())
fmt.Fprintf(w, "平台: %s\n", resolve.DescribeInfo(p.Result.Info))
switch {
case variant.Builtin != "":
fmt.Fprintf(w, "方式: 内置实现(%s,不启动外部进程)\n", variant.Builtin)
case variant.Shell:
fmt.Fprintf(w, "方式: shell (%s)\n", shellName())
default:
fmt.Fprintf(w, "方式: 直接执行\n")
}
note := variant.Note
if note == "" {
note = "无备注"
}
fmt.Fprintf(w, "命中: 变体 #%d(%d 分)%s\n", p.Result.VariantIdx, p.Result.Score, note)
if p.Result.VariantIdx >= 0 && p.Result.VariantIdx < len(p.Result.Candidates) {
fmt.Fprintf(w, "依据: %s\n", p.Result.Candidates[p.Result.VariantIdx].Reason)
}
if len(p.ExtraArgs) > 0 {
fmt.Fprintf(w, "透传: %s\n", strings.Join(p.ExtraArgs, " "))
}
}
// Run executes the plan. With DryRun it only prints the explanation. A child
// exiting non-zero is reported as *ExitError so callers can preserve the code.
func (p Plan) Run(ctx context.Context, opts Options) error {
if opts.Stdout == nil {
opts.Stdout = io.Discard
}
if opts.Stderr == nil {
opts.Stderr = io.Discard
}
if opts.DryRun {
fmt.Fprintln(opts.Stdout, "[dry-run] 未执行任何命令")
p.Explain(opts.Stdout)
return nil
}
if variant := p.Result.Variant; variant.Builtin != "" {
fn, ok := LookupBuiltin(variant.Builtin)
if !ok {
return fmt.Errorf("未注册的内置动作 %q", variant.Builtin)
}
return fn(ctx, BuiltinRequest{
Args: p.ExtraArgs,
Stdout: opts.Stdout,
Stderr: opts.Stderr,
Info: p.Result.Info,
Plan: p,
})
}
name, args := p.command()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdin = opts.Stdin
cmd.Stdout = opts.Stdout
cmd.Stderr = opts.Stderr
cmd.Env = opts.Env
cmd.Dir = opts.Dir
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return &ExitError{Code: exitErr.ExitCode(), Err: err}
}
return fmt.Errorf("执行 %q 失败: %w", name, err)
}
return nil
}
func (p Plan) command() (string, []string) {
argv := p.Argv()
if !p.Result.Variant.Shell {
return argv[0], argv[1:]
}
shell := shellName()
if runtime.GOOS == "windows" {
return shell, []string{"/c", joinForShell(argv, runtime.GOOS)}
}
return shell, []string{"-c", joinForShell(argv, runtime.GOOS)}
}
func shellName() string {
if runtime.GOOS == "windows" {
return "cmd"
}
return "sh"
}
// joinForShell joins argv with platform-appropriate quoting.
func joinForShell(argv []string, osName string) string {
parts := make([]string, len(argv))
for i, arg := range argv {
parts[i] = quoteForShell(arg, osName)
}
return strings.Join(parts, " ")
}
// quoteForShell leaves safe arguments untouched and quotes the rest.
func quoteForShell(arg, osName string) string {
if osName == "windows" {
if arg != "" && !strings.ContainsAny(arg, " \t\"&|<>^%") {
return arg
}
return `"` + strings.ReplaceAll(arg, `"`, `\"`) + `"`
}
if arg != "" && strings.IndexFunc(arg, unsafeShellRune) < 0 {
return arg
}
return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
}
func unsafeShellRune(r rune) bool {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return false
}
return !strings.ContainsRune("-_.:/=@+,", r)
}
// ExitError carries the child process exit code.
type ExitError struct {
Code int
Err error
}
func (e *ExitError) Error() string {
// When the error carries its own explanation (for example a timeout that
// lists what is still running), show it instead of the bare status code.
if e.Err != nil {
if msg := e.Err.Error(); msg != "" && !isExitStatus(msg) {
return msg
}
}
return fmt.Sprintf("命令以状态码 %d 退出", e.Code)
}
// isExitStatus reports whether msg is just an os/exec exit status, which adds
// nothing to kopsc's own wording.
func isExitStatus(msg string) bool {
return strings.HasPrefix(msg, "exit status ") || strings.HasPrefix(msg, "signal: ")
}
func (e *ExitError) Unwrap() error { return e.Err }
// ExitCode maps an error to the process exit code kopsc should return.
func ExitCode(err error) int {
if err == nil {
return 0
}
var exitErr *ExitError
if errors.As(err, &exitErr) {
return exitErr.Code
}
return 1
}