// Package execx 执行子进程,并保证「超时/取消」真的生效。 // // 标准库的 exec.CommandContext 在取消时只 Kill **直接子进程**。而 // `sh -c "git clone …"` 里 git 还要派生 git-remote-https,孙子继承了 stdout // 管道的写端;只要写端还被人拿着,Go 的输出拷贝 goroutine 就不会结束, // cmd.Run() 也一直不返回——于是"120 秒超时"实测能挂住 23 分钟 // (真实事故:session 20261008-214717,一条 git clone 之后整回合静止)。 // // 本包用两件事收口,缺一不可: // - 子进程自成进程组,取消时 kill(-pgid) 带走整棵树(治孤儿进程); // - WaitDelay 兜底:即使还有别的挂住的 fd 占着管道,Wait 也必定在 // 超时后有限时间内返回(治调用方被钉死)。 package execx import ( "bytes" "context" "os/exec" "time" ) // WaitGrace 是命令已退出/已被杀之后,再等在途输出拷贝收尾的宽限期。 // 超过它就放弃等待直接返回,绝不让调用方被孙进程占住的管道钉死。 const WaitGrace = 2 * time.Second // Options 是一次执行的可选参数;零值即用默认(继承环境、继承 cwd、无输入)。 type Options struct { Dir string // 工作目录 Env []string // 环境表;nil = 继承当前进程环境 // Stdin 标准输入。用 []byte 而不是 io.Reader:调用方大多已经手握着内容, // 而 io.Reader 会让 Run 无法重试语义之外的复用(当前没有复用需求)。 Stdin []byte } // Command 构造一条命令:ctx 取消即杀掉整棵进程树,Wait 有有限上界。 // 需要自定义 Stdout/Stderr 为别的 writer 时用它;一次性取输出用 Run。 func Command(ctx context.Context, name string, args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, name, args...) SeparateGroup(cmd) // 默认 Cancel 只 Process.Kill(),正是孤儿问题的来源。 cmd.Cancel = func() error { return KillTree(cmd) } cmd.WaitDelay = WaitGrace return cmd } // Run 执行命令并返回合并的 stdout+stderr。超时由 ctx 决定。 // 输出需要截断时配合 Truncate(它不会把一个 UTF-8 字符切两半)。 func Run(ctx context.Context, opts Options, name string, args ...string) ([]byte, error) { cmd := Command(ctx, name, args...) if opts.Dir != "" { cmd.Dir = opts.Dir } if opts.Env != nil { cmd.Env = opts.Env } if opts.Stdin != nil { cmd.Stdin = bytes.NewReader(opts.Stdin) } var buf bytes.Buffer cmd.Stdout, cmd.Stderr = &buf, &buf err := cmd.Run() return buf.Bytes(), err } // Shell 执行一段 shell 脚本(等价 sh -c script)。 func Shell(ctx context.Context, opts Options, script string) ([]byte, error) { return Run(ctx, opts, "sh", "-c", script) } // Truncate 把输出截到 maxBytes 以内,并报告是否发生了截断。 // // 必须退到字符边界:中文一个字占 3 字节,按字节硬切会把最后一个字劈开, // 尾部留下非法 UTF-8——它进 JSON 会变成替换字符,也可能被模型网关直接拒掉。 func Truncate(b []byte, maxBytes int) ([]byte, bool) { if maxBytes <= 0 || len(b) <= maxBytes { return b, false } cut := maxBytes for cut > 0 && b[cut]&0xC0 == 0x80 { cut-- // b[cut] 是 UTF-8 续字节:退到本字符的首字节 } return b[:cut], true }