子进程安全(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.Commandproc.Command(ctx, ...)
改写 Cancel / WaitDelay不要碰
领了豁免不履行义务闸门正向断言义务
用 grep 结果代替行为验证go test ./internal/infra/proc/
bufio.Scanner 读命令输出ReadSlice 循环 + cappedWriter

闸门

scripts/audit-lock/39-proc-single-spawn.sh