This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+168
View File
@@ -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() }
+143
View File
@@ -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)
}