You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ExecSpec 描述一次一次性(one-shot)命令执行的全部输入。
|
||||
// 调用方(run.ExecService)负责前置鉴权、cwd / env 构造与命令白名单校验。
|
||||
type ExecSpec struct {
|
||||
Command string
|
||||
Args []string
|
||||
Dir string
|
||||
Env []string // 完整环境(宿主白名单 + 覆盖),nil 表示空环境而非继承宿主
|
||||
// Timeout 是硬上限:超时即对整组发起 TerminateGroup(树终止)并置 TimedOut。
|
||||
// <=0 时不设独立超时,仅受 ctx 约束。
|
||||
Timeout time.Duration
|
||||
// KillGrace 是树终止时的优雅宽限(Unix SIGTERM → grace → SIGKILL)。
|
||||
KillGrace time.Duration
|
||||
// MaxOutputBytes 是 stdout / stderr 各自的捕获上限;超出截断并置 truncated。
|
||||
// <=0 时使用 defaultMaxOutputBytes。
|
||||
MaxOutputBytes int
|
||||
}
|
||||
|
||||
// ExecResult 是一次性执行的结构化结果。
|
||||
type ExecResult struct {
|
||||
ExitCode int
|
||||
Stdout string
|
||||
Stderr string
|
||||
Duration time.Duration
|
||||
TimedOut bool
|
||||
Truncated bool // stdout 或 stderr 任一被截断时为 true
|
||||
StdoutSize int // 截断前的实际字节数
|
||||
StderrSize int // 截断前的实际字节数
|
||||
}
|
||||
|
||||
const defaultMaxOutputBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// RunOnce 同步执行 spec 描述的命令直至退出(或超时 / ctx 取消),捕获 stdout /
|
||||
// stderr(各自封顶 MaxOutputBytes),返回退出码、时长、超时标志。
|
||||
//
|
||||
// 进程树管理沿用 RunSupervisor.Spawn 的次序:Prepare(Start 前)→ Start →
|
||||
// Adopt(Start 后)→ 退出或超时时 TerminateGroup 整树清理。这样测试运行器派生的
|
||||
// 子孙进程(go test 编译出的子进程、node 子进程等)在超时时也会被一并终止。
|
||||
//
|
||||
// 与长驻 profile 的差异:本函数阻塞到进程退出后才返回,不接入 relay;输出在内存
|
||||
// 缓冲并随返回值一次性交回。错误仅在无法启动进程(exec.Start 失败)时返回;命令
|
||||
// 以非零码退出不算 error,体现在 ExitCode 上。
|
||||
func RunOnce(ctx context.Context, spec ExecSpec) (ExecResult, error) {
|
||||
maxOut := spec.MaxOutputBytes
|
||||
if maxOut <= 0 {
|
||||
maxOut = defaultMaxOutputBytes
|
||||
}
|
||||
|
||||
cmd := exec.Command(spec.Command, spec.Args...)
|
||||
cmd.Dir = spec.Dir
|
||||
cmd.Env = spec.Env
|
||||
|
||||
outBuf := &cappedBuffer{limit: maxOut}
|
||||
errBuf := &cappedBuffer{limit: maxOut}
|
||||
cmd.Stdout = outBuf
|
||||
cmd.Stderr = errBuf
|
||||
|
||||
group := NewGroup()
|
||||
group.Prepare(cmd)
|
||||
|
||||
started := time.Now()
|
||||
if err := cmd.Start(); err != nil {
|
||||
return ExecResult{}, err
|
||||
}
|
||||
if err := group.Adopt(cmd); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
group.Close()
|
||||
return ExecResult{}, err
|
||||
}
|
||||
|
||||
// waitCh 携带 Wait() 的错误;done 在 Wait 返回后 close,供 TerminateGroup
|
||||
// 感知组长已被回收(语义对齐 RunSupervisor.monitorProcess 的 done channel)。
|
||||
waitCh := make(chan error, 1)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
waitCh <- err
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// 超时 / ctx-cancel watchdog:触发整树终止;result 由下方 select 收尾。
|
||||
var timeoutTimer <-chan time.Time
|
||||
if spec.Timeout > 0 {
|
||||
t := time.NewTimer(spec.Timeout)
|
||||
defer t.Stop()
|
||||
timeoutTimer = t.C
|
||||
}
|
||||
|
||||
var timedOut bool
|
||||
var waitErr error
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case waitErr = <-waitCh:
|
||||
break loop
|
||||
case <-timeoutTimer:
|
||||
timedOut = true
|
||||
group.TerminateGroup(done, spec.KillGrace)
|
||||
waitErr = <-waitCh
|
||||
break loop
|
||||
case <-ctx.Done():
|
||||
group.TerminateGroup(done, spec.KillGrace)
|
||||
waitErr = <-waitCh
|
||||
break loop
|
||||
}
|
||||
}
|
||||
group.Close()
|
||||
|
||||
res := ExecResult{
|
||||
ExitCode: exitCodeOf(waitErr),
|
||||
Stdout: outBuf.String(),
|
||||
Stderr: errBuf.String(),
|
||||
Duration: time.Since(started),
|
||||
TimedOut: timedOut,
|
||||
Truncated: outBuf.truncated || errBuf.truncated,
|
||||
StdoutSize: outBuf.total,
|
||||
StderrSize: errBuf.total,
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// exitCodeOf 从 Cmd.Wait 的返回错误提取退出码。正常退出(nil)= 0;
|
||||
// *exec.ExitError 取其 ExitCode(被信号杀死时为 -1);其它错误统一 -1。
|
||||
func exitCodeOf(waitErr error) int {
|
||||
if waitErr == nil {
|
||||
return 0
|
||||
}
|
||||
if ee, ok := waitErr.(*exec.ExitError); ok {
|
||||
return ee.ExitCode()
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// cappedBuffer 是一个带上限的 io.Writer:累计写入 limit 字节后丢弃后续数据并置
|
||||
// truncated,同时仍记录 total(截断前的总字节数)便于上层提示。
|
||||
type cappedBuffer struct {
|
||||
buf bytes.Buffer
|
||||
limit int
|
||||
total int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (c *cappedBuffer) Write(p []byte) (int, error) {
|
||||
c.total += len(p)
|
||||
if c.buf.Len() >= c.limit {
|
||||
c.truncated = true
|
||||
return len(p), nil // 谎报全写入:避免 cmd 因 short write 报错而中断
|
||||
}
|
||||
remain := c.limit - c.buf.Len()
|
||||
if len(p) > remain {
|
||||
c.buf.Write(p[:remain])
|
||||
c.truncated = true
|
||||
return len(p), nil
|
||||
}
|
||||
c.buf.Write(p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (c *cappedBuffer) String() string { return c.buf.String() }
|
||||
@@ -0,0 +1,143 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// helperShell 返回执行一段 shell 脚本的跨平台命令前缀(command, args...)。
|
||||
func helperShell(script string) (string, []string) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "cmd", []string{"/c", script}
|
||||
}
|
||||
return "sh", []string{"-c", script}
|
||||
}
|
||||
|
||||
// TestRunOnce_ExitCodeAndCapture 验证捕获 stdout / stderr 与非零退出码。
|
||||
func TestRunOnce_ExitCodeAndCapture(t *testing.T) {
|
||||
var script string
|
||||
if runtime.GOOS == "windows" {
|
||||
script = "echo out& echo err 1>&2& exit 3"
|
||||
} else {
|
||||
script = "echo out; echo err 1>&2; exit 3"
|
||||
}
|
||||
cmd, args := helperShell(script)
|
||||
res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, Timeout: 10 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if res.ExitCode != 3 {
|
||||
t.Fatalf("exit code = %d, want 3", res.ExitCode)
|
||||
}
|
||||
if !strings.Contains(res.Stdout, "out") {
|
||||
t.Fatalf("stdout = %q, want contains 'out'", res.Stdout)
|
||||
}
|
||||
if !strings.Contains(res.Stderr, "err") {
|
||||
t.Fatalf("stderr = %q, want contains 'err'", res.Stderr)
|
||||
}
|
||||
if res.TimedOut {
|
||||
t.Fatal("TimedOut should be false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOnce_Success 验证零退出码路径。
|
||||
func TestRunOnce_Success(t *testing.T) {
|
||||
cmd, args := helperShell("echo hello")
|
||||
res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, Timeout: 10 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if res.ExitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0", res.ExitCode)
|
||||
}
|
||||
if !strings.Contains(res.Stdout, "hello") {
|
||||
t.Fatalf("stdout = %q", res.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOnce_Truncation 验证 stdout 超过 MaxOutputBytes 时截断并置标志。
|
||||
func TestRunOnce_Truncation(t *testing.T) {
|
||||
// 打印约 1000 字节,但上限设为 100。
|
||||
var cmd string
|
||||
var args []string
|
||||
if runtime.GOOS == "windows" {
|
||||
// PowerShell 直接调用避免 cmd 转义;-NoProfile 加快启动。
|
||||
cmd, args = "powershell", []string{"-NoProfile", "-Command", "Write-Output ('a' * 1000)"}
|
||||
} else {
|
||||
cmd, args = "sh", []string{"-c", "for i in $(seq 1 1000); do printf a; done"}
|
||||
}
|
||||
res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, MaxOutputBytes: 100, Timeout: 15 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if !res.Truncated {
|
||||
t.Fatalf("Truncated should be true; stdout len=%d size=%d", len(res.Stdout), res.StdoutSize)
|
||||
}
|
||||
if len(res.Stdout) > 100 {
|
||||
t.Fatalf("captured stdout len=%d exceeds limit 100", len(res.Stdout))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOnce_Timeout 验证超时触发整树终止并置 TimedOut。
|
||||
//
|
||||
// Unix:父 shell trap 掉 TERM 并 sleep,再后台派生一个 grandchild sleep;超时后
|
||||
// TerminateGroup 必须 SIGKILL 整组,确保父退出(done 被关闭)。
|
||||
// Windows:直接 timeout 长睡,Job-close 终止。
|
||||
func TestRunOnce_Timeout(t *testing.T) {
|
||||
var cmd string
|
||||
var args []string
|
||||
if runtime.GOOS == "windows" {
|
||||
// ping -n N localhost 是无需控制台的可靠延时(timeout /t 在无 stdin 下立即返回)。
|
||||
cmd, args = "cmd", []string{"/c", "ping -n 30 127.0.0.1 >nul"}
|
||||
} else {
|
||||
// trap '' TERM 忽略 SIGTERM;后台派生 grandchild;wait 阻塞存活。
|
||||
cmd, args = "sh", []string{"-c", "trap '' TERM; sleep 30 & sleep 30; wait"}
|
||||
}
|
||||
start := time.Now()
|
||||
res, err := RunOnce(context.Background(), ExecSpec{
|
||||
Command: cmd,
|
||||
Args: args,
|
||||
Timeout: 1 * time.Second,
|
||||
KillGrace: 500 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if !res.TimedOut {
|
||||
t.Fatal("TimedOut should be true")
|
||||
}
|
||||
// 必须在远小于 30s 内返回(超时 + grace + kill),证明整树被回收而非等自然退出。
|
||||
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
||||
t.Fatalf("RunOnce blocked %v, expected prompt tree-kill", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOnce_CtxCancel 验证 ctx 取消亦触发树终止并尽快返回。
|
||||
func TestRunOnce_CtxCancel(t *testing.T) {
|
||||
cmd, args := helperShell(fmt.Sprintf("%s", sleepScript(30)))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
start := time.Now()
|
||||
res, err := RunOnce(ctx, ExecSpec{Command: cmd, Args: args, KillGrace: 500 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
||||
t.Fatalf("RunOnce blocked %v after ctx cancel", elapsed)
|
||||
}
|
||||
_ = res
|
||||
}
|
||||
|
||||
func sleepScript(sec int) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return fmt.Sprintf("ping -n %d 127.0.0.1 >nul", sec)
|
||||
}
|
||||
return fmt.Sprintf("sleep %d", sec)
|
||||
}
|
||||
Reference in New Issue
Block a user