Documentation
¶
Overview ¶
Package procx 子进程治理三件套:进程**树**终止、子进程**最小环境**、输出 **封顶**。stdlib-only(pkg 零第三方依赖红线),原语下沉自 oagentkit/runtime procx 同款纪律(pkg#284:下游 pigeonbox/core 直接依赖 oagentkit 会新增跨 org 依赖边,决策记录 pigeonbox/core#71)。三件套对应 pkg#294 的承诺: 树终止先行落地(v0.38.2/v0.39.0),最小环境与输出封顶由 pkg#314 补齐—— 下游预览子系统的匿名访客可控输入喂 ffmpeg/ffprobe,三面缺口都撞在同一处: 损坏媒体狂刷 stderr 打爆内存、宿主凭证经全量环境泄漏进子进程、僵死进程树 收不了场。
为什么需要它 ¶
`exec.CommandContext` 在 ctx 到期时**只杀直接子进程**——包装器/脚本型命令 (第三方工具常把真实工作进程 detach 到后台)的孙进程残留即泄漏面,ctx 取消管不到。下游预览子系统的 ffmpeg/ffprobe 包装驱动正撞上这个缺口。
- **Run**:全治理形态——收集 stdout/stderr 返回 Result(退出码与 stdlib 同口径,被信号终止 = -1),ctx 取消导致的终止返回 ctx.Err(); 输出可选限容(WithOutputLimit,截断可判定)、环境可选白名单化 (WithEnv,默认拒绝);
- **Command**:drop-in 形态——存量 `exec.CommandContext` 调用点逐字 替换,I/O 与 Env 配置完全由调用方自理(最小环境用 ChildEnv)。
收场语义(unix) ¶
Setpgid 让子进程自立进程组 → ctx 取消时先向**整组**发 SIGTERM (kill(-pgid),同组后代随 fork 继承一并送达),同时按父链快照清扫 **跨组逃逸的后代**(守护化/setsid 的真孙进程——组击杀够不到的那层, 逐个补发 TERM 并连带各自进程组;linux 走 /proc、darwin 走 ps(1), 仅取消路径调用、带 5s 上限,其余 unix 降级为纯组击杀)→ 宽限轮询 (WithGrace,缺省 5s;每 10ms 探一次,树死干净立即收手——优雅退出的 进程不会被 KILL,也不空等满宽限;0=无宽限立即 KILL,负值构造期 panic) → 宽限耗尽仍有幸存者则重枚举逃逸者后 SIGKILL 整组 + 全部逃逸者。
Run 在进程退出后(无论正常还是被终止)顺手清一次组内幸存者(无宽限 直接 KILL)——守护化残留不因主进程正常退出而泄漏;Command 形态没有 这个挂点(caller 自己调 Wait,exec.Cmd 无 post-exit 钩子)。
收场语义(windows) ¶
无进程组概念,两条机制按形态分工:
- **Run 形态:Job Object**。Start 后立即把子进程挂入 Job (CreateJobObjectW + SetInformationJobObject + AssignProcessToJobObject, 全走 syscall.NewLazyDLL,不引 x/sys),此后子进程 spawn 的一切后代 默认同属该 Job;取消时 TerminateJobObject 整树终止;进程退出后关闭 Job 句柄,**KILL_ON_JOB_CLOSE** 清树——宿主进程崩溃时句柄随进程消亡 同样触发清树(崩溃安全)。Start 与挂入之间有微秒级窗口,窗口内已 spawn 且 detach 的孙进程不被 Job 覆盖(如实声明)。
- **Command 形态:taskkill /T /F**。exec.Cmd 没有 Start 之后的钩子, 无法预先挂 Job ⇒ 取消时按父链快照整树强杀(换组/setsid 的后代只要 还是子进程就会被 /T 走到);没有退出后清扫挂点。
**语义降级如实声明:windows 没有优雅终止档**(无 SIGTERM 等价物)—— WithGrace 在 windows 上退化为立即整树终止,不伪装成有;宽限期在该平台 只兜 WaitDelay 的管道收口。
边界(如实声明,不伪装全知) ¶
- 父链中途断掉的逃逸者(父进程先退出被 reparent)不可达——unix 的 父链清扫是快照,不需要 subreaper 就能做到的是「父链完整」的部分, 守护化孙进程的典型场景(包装器常驻、工作进程 setsid detach)父链 完整、覆盖得到;真·double-fork 断链场景需要 subreaper 全套机制, 超出本原语定位。
- TERM 之后才守护化、且守护化完成前父链在 KILL 重枚举前断掉的孙进程, 同样不可达。
- 跨组逃逸者按「pid + 各自进程组」击杀:若对方把组与无关进程共用 (极罕见),会波及同组进程——进程组语义的固有粒度。
- 若子进程已属于别的 Job(windows),AssignProcessToJobObject 失败: 退化为只杀直接子进程。
最小环境(pkg#294/#314 承诺的第二半) ¶
WithEnv 让 Run 用白名单环境起子进程:环境集 = ChildEnv 白名单 ∪ extra 显式放行,白名单之外的一切变量——包括未来新出现的敏感变量——一律不进 子进程环境。刻意 allowlist 而非 denylist:denylist 只能列已知凭据名,新 凭据永远漏;allowlist 的漏只表现为「少了一个无害变量」,错了也错在安全侧。 代理变量默认放行(内网部署下子进程靠它拿出口)。
缺省(不传 WithEnv)继承父进程全量环境——存量语义不动:子进程是用户主动 发起的可信 CLI 替身(git/ssh/docker 包装)时,父进程环境就是用户的信任 上下文,砍掉它才不对。两种威胁模型在调用点字面可见,不做成一个开关。 Command 形态调用方自理:cmd.Env = procx.ChildEnv(...)。
输出封顶(pkg#294/#314 承诺的第三半) ¶
WithOutputLimit 让 Run 对 stdout/stderr 各自限容:**边读边截 + 继续排空** ——超限部分照常从管道读走并丢弃(writer 恒报全量消费成功),子进程不会卡 在 write 上不退出。最常见错误实现是「读满 cap 就停读」:管道停止排空, 子进程阻塞在 write 上永不退出,Run 挂死——这正是 pkg#294 原单警告过的 形态,有负对照测试钉住该分辨力。截断在 Result.StdoutTruncated / Result.StderrTruncated 里如实标记:「被截断」与「本来就这么短」必须可 区分(正好等于上限不算截断)。缺省(不传)无上限——存量调用不受影响。
与仓内其他包的分工 ¶
shellx 是「字符串怎么写进命令行」(转义/分词,纯函数);本包是「命令怎么 跑、怎么收场」(进程托管)。两者互补:外部输入拼 argv 前先过 shellx, 执行用本包。
用法(替换 exec.CommandContext 逐字迁移):
cmd := procx.Command(ctx, "ffmpeg", "-i", in, out) // unix 整树语义
res, err := procx.Run(ctx, "ffmpeg", args, // 全治理形态:
procx.WithGrace(2*time.Second), // 宽限可调
procx.WithOutputLimit(64<<10), // 输出各限 64KB,截断可判定
procx.WithEnv(nil)) // 白名单环境(默认拒绝)
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func ChildEnv ¶ added in v0.41.0
ChildEnv 构造子进程最小环境(默认拒绝,pkg#314 补齐 pkg#294 承诺的 env 半边):环境集 = baseEnvAllow 白名单 ∪ extra 显式放行,白名单之外的 一切变量——包括未来新出现的敏感变量——一律不进子进程环境。刻意 allowlist 而非 denylist:denylist 只能列已知凭据名,新凭据永远漏;allowlist 的漏 只表现为「少了一个无害变量」,错了也错在安全侧。
经 procx.Run 执行时用 WithEnv 选项(Run 内部应用本函数);自行装配 *exec.Cmd 的调用方直接 cmd.Env = procx.ChildEnv(...)。
extra 两种形态:纯名(如 "HF_TOKEN")从当前进程按名透传(不存在则丢弃); 含 "="(如 "HF_TOKEN=xxx")按 KEY=VALUE 字面注入,且同名父进程值不透传 (每 key 唯一——environ 同名两项时子进程取值依 libc 实现而异)。
尾部固定追加 GIT_TERMINAL_PROMPT=0(git 弹终端提问会挂到超时)与 CI=1 (对非 git 子进程无害);先到先得去重保证每 key 至多一项——显式给的 (字面注入/按名透传/父环境已有同名)压过缺省项,缺省项不得覆盖显式值、 也不得并列出第二项。
与「可信 CLI 全量继承」场景的分工:本函数只服务「子进程会执行不可信 内容」的威胁模型。子进程是用户主动发起的可信 CLI 替身(git/ssh/docker 包装)时,父进程环境就是用户的信任上下文——Run/Command 缺省的继承语义 (cmd.Env 不设即全量继承)就是那条路,不要用白名单「补枚举」可信场景 (GIT_* 开放命名空间、SSH_AUTH_SOCK、credential helper 链是活的,静态 枚举列不全,漏的形态是认证莫名失败而非编译错误)。
func Command ¶
Command 把树终止语义挂到 *exec.Cmd 上(不接管 I/O 与 Env 配置)——现有 exec.CommandContext 调用点的逐字替换形态。ctx 到期/取消时:unix 先向 整个进程组发 TERM 并清扫跨组逃逸后代,宽限(默认 5s)后 KILL 整组; windows 用 taskkill /T /F 按父链整树强杀(无优雅档)。需要最小环境时 caller 自理:cmd.Env = procx.ChildEnv(...)。
Command 形态没有「退出后残留清扫」挂点(caller 自己调 Wait,exec.Cmd 没有 post-exit 钩子),需要该语义用 Run 形态;windows 上 Run 形态另有 Job Object 整树语义(Start 即挂入,覆盖 Start 后 spawn 的全部后代), Command 形态只有取消时点的树强杀,见包文档。
Types ¶
type Result ¶
type Result struct {
Stdout []byte
Stderr []byte
ExitCode int
// StdoutTruncated / StderrTruncated 报告对应流是否因超过 WithOutputLimit
// 上限被截断(未设上限恒 false)。截断是刻意的(限容防内存放大),但
// **必须可观测**——否则调用方会把「半截输出」当完整输出用;截断与本来
// 就这么短必须可区分。正好等于上限不算截断。
StdoutTruncated bool
StderrTruncated bool
}
Result 是 Run 的收场结果。
type RunOption ¶ added in v0.39.0
type RunOption func(*config)
RunOption 定制 Run 形态的治理行为 (仓规:函数选项一律 XxxOption,禁止裸 Option——多包 co-import 冲突)。
func WithEnv ¶ added in v0.41.0
WithEnv 让 Run 用「最小环境」起子进程:cmd.Env = ChildEnv(extra)—— 白名单 ∪ 显式放行、默认拒绝(语义与 extra 形态见 ChildEnv)。执行不可信 内容(外部输入喂的媒体处理、用户工作目录里的 agent 任务)的调用点显式加 本选项;缺省(不传)继承父进程全量环境——存量语义不动,可信 CLI 场景继续 走缺省,两种威胁模型在调用点字面可见。多次调用后者覆盖前者(Go 选项惯例)。 Command 形态不经本选项:caller 自理 cmd.Env = procx.ChildEnv(...)。
func WithGrace ¶
WithGrace 设置优雅终止宽限:先向整组发 TERM,超过宽限仍未退出才 KILL。 默认 5s。windows 上无优雅档,本选项退化为立即整树终止(见包文档)。
- d < 0:立即 panic(仓规总则①,构造期非法值 fail-fast——在选项构造点 报错,而不是等到应用点才炸);
- d == 0:合法,语义为「无宽限,TERM 与 KILL 同拍发出」(要立即收场时用)。
func WithOutputLimit ¶ added in v0.41.0
WithOutputLimit 给 Run 的 stdout/stderr **各设**采集上限(字节)—— **边读边截**:超限部分照常从管道读走并丢弃,子进程不会卡在 write 上、 正常退出;截断在 Result.StdoutTruncated / Result.StderrTruncated 里如实标记(「被截断」与「本来就这么短」必须可区分;正好等于上限不算 截断)。上限按流各自独立,互不挤占。
动机(pkg#314,pkg#294 承诺的输出封顶半边):损坏输入会让 ffmpeg/ffprobe 这类命令往 stderr 狂刷告警,无上限采集即单请求内存放大——限容必须配 「继续排空」:最常见错误实现是「读满 cap 就停读」,子进程阻塞在 write 上 永不退出,Run 挂死(测试有负对照钉住该分辨力)。
- n <= 0:立即 panic(仓规总则①,构造期 fail-fast)。「不设限」的表达 方式是不传本选项(缺省无上限,存量调用不受影响);0 不是合法上限, 静默吞掉全部输出几乎必然是笔误,等应用点才炸就晚了。