子进程安全(INV-EXEC-PROC-01)
wesgine 通过 internal/infra/proc 包统一管理所有子进程的创建和清理,确保超时后不留孤儿进程。
核心问题
exec.CommandContext 看起来像守了截止时间,但它只向直接子进程发 SIGKILL:
// sh 被杀了,但 sh fork 的 node 还活着
cmd := exec.CommandContext(ctx, "sh", "-c", "npm ls -g")
node 还持着 stdout 写端 → cmd.Wait() 等一个永不到来的 EOF。
取消返回了,工作没有停。
唯一原语:proc.Command
import "internal/infra/proc"
cmd := proc.Command(ctx, "sh", "-c", "npm ls -g")
// 自动设置:
// - Setpgid: true → 子进程在独立进程组
// - Cancel: SIGKILL 整个组(不只是直接子进程)
// - WaitDelay: 给 Wait() 封顶时间
生产 Go 代码一律用 proc.Command。裸 exec.Command / exec.CommandContext / exec.Cmd{} 只允许出现在 3 个带义务的豁免文件。
禁止的写法
// ❌ 裸 exec.Command
cmd := exec.Command("sh", "-c", "long-running")
// ❌ exec.CommandContext(不设进程组)
cmd := exec.CommandContext(ctx, "sh", "-c", "long-running")
// ❌ 字面量 exec.Cmd(没有 Command 字样,最不像 fork 的拼写)
cmd := &exec.Cmd{Path: "/bin/sh", Args: []string{"sh", "-c", "..."}}
// ❌ 拿到 proc.Command 后改写 Cancel / WaitDelay
cmd := proc.Command(ctx, "sh", "-c", "...")
cmd.WaitDelay = 0 // 关掉了正要修的那件事
豁免文件及其义务
| 文件 | 义务 |
|---|---|
tool/host_shell.go | 必须自设 Cancel + WaitDelay |
adapter/stt/tool.go | 故意脱离 ctx(转写要活过请求),必须设 SysProcAttr 建组 |
| 测试文件 | 标准测试辅助 |
豁免的义务由闸门正向断言——不是检查"有没有用 proc.Command",是检查豁免文件是否履行了自己的义务。
exec 超时后的清理
ctx 超时
↓ proc.Cancel:SIGKILL 整个进程组(含 grandchild)
↓ stdout 排空最多等 5s(INV-EXEC-12)
↓ 5s 后强制 CloseOutput()(解除 scanner 阻塞)
↓ cmd.Wait() 返回
INV-EXEC-11
前台 exec 超时后必须调用 KillProcessGroup() 清理整个进程组。
INV-EXEC-12
进程退出后 stdout 排空最多等 5s,超时强制 CloseOutput()——因为 grandchild 可能持有管道写端。
exec 输出读取(INV-EXEC-DRAIN-01)
必须无条件消费管道直到 EOF。
// ❌ 禁止:bufio.Scanner 读命令输出
scanner := bufio.NewScanner(stdout)
for scanner.Scan() { ... }
// max-token 1MB 遇到无换行大输出 → 提前退出 → 管道无人消费 → 死锁
// ✅ 正确:ReadSlice 循环 + cappedWriter
for {
line, err := reader.ReadSlice('\n')
writer.Write(line) // cappedWriter 限内存
if err == io.EOF { break }
}
runProbeCmd 同样必须在 LimitReader 截断后 io.Copy(io.Discard, ...) 排空剩余管道再 Wait()。
反模式速查
| 反模式 | 正确做法 |
|---|---|
生产代码 exec.Command | proc.Command(ctx, ...) |
改写 Cancel / WaitDelay | 不要碰 |
| 领了豁免不履行义务 | 闸门正向断言义务 |
| 用 grep 结果代替行为验证 | go test ./internal/infra/proc/ |
bufio.Scanner 读命令输出 | ReadSlice 循环 + cappedWriter |
闸门
scripts/audit-lock/39-proc-single-spawn.sh