商业产业 Hacker News Show HN AI 2026-09-29T00:35 正文 ok 1214 words

Show HN:Corral——终止 Agent 启动的每个命令及其全部进程

英文标题:🔶 Show HN: Corral – Kill every command your agent starts

Corral 是一款 Linux 命令运行器,能够在命令结束或超时后验证其启动的所有进程均已退出;若无法证明这一点,将以退出码 120 结束。它提供基于 cgroup v2 的强制模式,以及通过子进程收割和 `/proc` 扫描实现的回退模式,旨在解决 AI 编程 Agent 和 CI 任务遗留守护进程、占用输出管道或忽略 SIGTERM 等问题。基准测试显示,Corral 在十项故障测试中均未遗留存活进程,强制模式还能在 0.12 秒内触发 64 MiB 内存限制。该工具不属于安全沙箱,需 Linux 5.11 及以上版本,强制模式则需要 Linux 5.14 及以上版本和已委派的 cgroup。
打开原文相关 3 · 机会 1
media47.5 KB

中文

corral 会在时间限制内运行一条命令。当 corral 返回时,该命令启动的所有进程都不会仍然存活。corral 会在返回前验证这一点;如果无法证明,它会以退出码 120 退出。

AI 编程 Agent 和 CI 任务会运行并非由自己编写的 shell 命令。典型的运行器会向其子进程或该子进程所属的进程组发送信号,然后等待输出管道关闭。该模型在三种常见情况下会失效:

  • 守护进程会两次 fork 并调用 setsid()。此时它位于不同的进程组和会话中,且其父进程变为 init,因此信号无法到达它。
  • 后台进程持续打开 stdout 或 stderr。运行器永远无法读到文件结束符,因此会在命令退出后卡住。
  • 进程忽略 SIGTERM 并继续运行。

遗留进程会持续占用端口、文件锁和 CPU,导致下一次运行可能因此失败。

corral 控制的是完整的进程树,而不只是其直接子进程。在强制模式下,命令会在自己的 cgroup v2 组中运行;向 cgroup.kill 写入一次即可停止该组中的每一个进程。在没有 cgroup 的情况下,corral 会充当子进程收割器(child subreaper),因此孤儿进程会成为它的子进程。它通过 /proc 查找剩余进程,并通过 pidfd 向它们发送信号。在两种模式下,运行会在命令退出时结束,而不是在管道关闭时结束。

corral 不是安全沙箱。它不会限制文件访问、网络访问或权限。

corral 需要 Linux 5.11 或更高版本;强制模式则需要 Linux 5.14 或更高版本。

预构建二进制文件面向使用 glibc 2.36 或更高版本的 x86-64 系统,例如 Ubuntu 22.10、Debian 12、Fedora 37 或后续版本:

curl -LO https://github.com/Cardinal44/corral/releases/latest/download/corral-linux-x86_64.tar.gz
tar -xzf corral-linux-x86_64.tar.gz
sudo cp corral-linux-x86_64/corral corral-linux-x86_64/corral-enforced /usr/local/bin/

如需从源码构建,你需要 glibc 2.36、CMake 3.22、Ninja,以及 GCC 11 或 Clang 14。测试还需要 Python 3.10。

cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build --output-on-failure


corral --wall 30s -- ./build.sh
corral --wall 5m --max-output 1M --json run.json -- make test
corral-enforced --wall 30s --mem 512M --pids 64 -- ./build.sh

corral 有两种模式:

  • 强制模式将命令置于自己的 cgroup v2 组中。内核会将每一个后代进程保留在该组内,而向 cgroup.kill 写入一次即可停止所有进程。此模式还会应用 --mem 和 --pids。它需要已委派的 cgroup。scripts/corral-enforced 会使用 systemd-run 启动一个这样的 cgroup。
  • 回退模式不需要 cgroup。corral 会根据父进程、进程组和会话查找进程,并扫描 /proc,直到没有任何进程剩余。

默认选项 --cgroup-mode=auto 会在可行时使用强制模式。

选项 默认值 含义
--wall DURATION 无 运行的时间限制。
--grace DURATION 2s SIGTERM 与 SIGKILL 之间的等待时间。
--verify-timeout DURATION 2s 最终检查的时间限制。
--mem SIZE 无 内存限制(强制模式)。
--pids COUNT 无 进程数量限制(强制模式)。
--max-output SIZE 无 stdout 和 stderr 合计的输出限制。
--cgroup-mode MODE auto auto、enforced 或 fallback。
--cgroup-parent PATH 无 供 corral 创建组使用的已委派 cgroup。
--stdin POLICY null null 或 inherit。
--json PATH 无 将审计记录写入 PATH。
--quiet 关闭 不输出摘要行。

DURATION 是带有 ms、s、m 或 h 单位的数字。SIZE 是字节数,可选单位为 K、M 或 G。

使用 --json 时,corral 会为每次运行写入一条 JSON 记录。该记录包含运行模式、限制条件以及运行结束原因,也会给出每一步的时间,以及任何未停止进程的 pid。

代码 含义
命令的退出码 命令已退出。
128 + N 信号 N 停止了命令,或者 corral 收到信号 N(Ctrl-C 对应 130)。
124 时间限制到期。
121 达到内存限制。
122 达到输出限制。
126、127 corral 无法启动命令(127 表示未找到命令)。
125 设置失败或选项不正确。命令未启动。
120 corral 无法证明所有进程均已停止。该退出码会覆盖所有其他退出码。
  • 命令会在新会话中启动,因此向其进程组发送的信号永远不会到达 corral。
  • corral 是一个子进程收割器。孤儿进程会成为 corral 的子进程,因此 corral 可以停止它们。
  • corral 仅会在再次检查进程后,通过 pidfd 向非自身子进程的进程发送信号。被复用的 PID 永远不会收到该信号。
  • 运行会在命令退出时结束,而不是在其输出管道关闭时结束。
  • 每次运行后,corral 都会停止并回收进程,直到没有任何进程残留。在强制模式下,内核还必须报告该组为空,且该组的 rmdir 必须成功。

bench/compare.py 使用四种运行器,对 faults/ 中的十个测试程序进行测试:

  • CE:强制模式下的 corral
  • CF:回退模式下的 corral
  • TO:timeout -k 1s 2s
  • NV:仅杀死其子进程的 Python 运行器

每次运行的时间限制均为 2 秒。每个单元格展示 5 次运行中的退出状态,以及持续存活进程数量的最大值。

测试程序 CE CF TO NV
F1:每 100 ms 输出一行 124 / 0 124 / 0 124 / 0 137 / 0
F2:等待 stdin 0 / 0 0 / 0 124 / 0 137 / 0
F3:启动一个后台任务 0 / 0 0 / 0 0 / 1 hung / 1
F4:启动守护进程后退出 0 / 0 0 / 0 0 / 1 hung / 1
F5:启动守护进程并持续运行 124 / 0 124 / 0 124 / 1 hung / 1
F6:忽略 SIGTERM 124 / 0 124 / 0 137 / 0 137 / 0
F7:fork 200 个子进程 124 / 0 124 / 0 124 / 1 hung / 192
F8:子进程保持 stdout 打开 0 / 0 0 / 0 0 / 1 hung / 1
F9:使用 256 MiB 内存 121 / 0 124 / 0 124 / 0 137 / 0
F10:收到 SIGTERM 时清理资源 124 / 0 124 / 0 124 / 0 137 / 0

corral 在任何测试中均未遗留存活进程。CE 还在 0.12 秒内于 F9 达到其 64 MiB 内存限制时将其停止。TO 在 5 次运行中的 1 次 F7 测试里遗留了一个存活进程。在这台机器上,timeout 来自 uutils coreutils 0.8.0,而非 GNU coreutils。

bench/bench.py 测得以下时间(P50 / P95):

测量项 强制模式 回退模式
停止 F5:从时间限制触发到完成验证 9 / 12 ms 10 / 12 ms
运行 /bin/true(直接运行:0.83 / 1.88 ms) 11.33 / 19.47 ms 6.64 / 10.01 ms

测试机器:Hetzner 云服务器,2 个共享 vCPU(Intel Xeon,Skylake),Ubuntu 26.04,内核 7.0。完整数据以及 scripts/demo.sh 的终端录制位于 bench/results/。

  • corral 无法看见命令在其进程树之外启动的工作,例如通过 systemd-run、D-Bus 或 at 启动的任务。
  • 在强制模式下,如果进程自行写入 cgroupfs,它可以离开该组。
  • 在回退模式下,corral 无法向 setuid 子进程发送信号。此时它会以 120 退出。
  • 如果使用 SIGKILL 停止 corral,则只有其直接子进程能够确保被停止。
  • corral 不支持 PTY,也没有 CPU 时间限制,并且仅支持 Linux。

MIT。参见 LICENSE。

English / 原文

corral runs a command with a time limit. When corral returns, no process that the command started is still alive. corral verifies this before it returns. If it cannot prove it, corral exits with code 120.

AI coding agents and CI jobs run shell commands that they did not write. A typical runner signals its child process or the child's process group, and then waits until the output pipes close. This model fails in three common cases:

  • A daemon forks twice and calls setsid(). It is then in a different process group and session, and its parent is init, so the signal does not reach it.
  • A background process keeps stdout or stderr open. The runner never reads end-of-file, so it hangs after the command exits.
  • A process ignores SIGTERM and continues to run.

Leftover processes keep ports, file locks, and CPU, and the next run can fail because of them.

corral controls the full process tree, not only its direct child. In enforced mode, the command runs in its own cgroup v2 group, and one write to cgroup.kill stops every process in the group. Without a cgroup, corral is a child subreaper, so orphaned processes become its children. It finds the remaining processes through /proc and signals them through pidfds. In both modes, the run ends when the command exits, not when the pipes close.

corral is not a security sandbox. It does not limit file access, network access, or privileges.

corral needs Linux 5.11 or later, and 5.14 or later for enforced mode.

The prebuilt binary is for x86-64 with glibc 2.36 or later (for example Ubuntu 22.10, Debian 12, or Fedora 37, or a later release):

curl -LO https://github.com/Cardinal44/corral/releases/latest/download/corral-linux-x86_64.tar.gz
tar -xzf corral-linux-x86_64.tar.gz
sudo cp corral-linux-x86_64/corral corral-linux-x86_64/corral-enforced /usr/local/bin/

To build from source, you need glibc 2.36, CMake 3.22, Ninja, and GCC 11 or Clang 14. The tests need Python 3.10.

cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build --output-on-failure


corral --wall 30s -- ./build.sh
corral --wall 5m --max-output 1M --json run.json -- make test
corral-enforced --wall 30s --mem 512M --pids 64 -- ./build.sh

corral has two modes:

  • Enforced mode puts the command in its own cgroup v2 group. The kernel keeps every descendant in that group, and one write to cgroup.kill stops all of them. This mode also applies --mem and --pids. It needs a delegated cgroup. scripts/corral-enforced starts one with systemd-run.
  • Fallback mode needs no cgroup. corral finds the processes by parent, process group, and session, and scans /proc until nothing is left.

The default, --cgroup-mode=auto, uses enforced mode when it can.

Option Default Meaning
--wall DURATION none Time limit for the run.
--grace DURATION 2s Time between SIGTERM and SIGKILL.
--verify-timeout DURATION 2s Time limit for the final check.
--mem SIZE none Memory limit (enforced mode).
--pids COUNT none Process limit (enforced mode).
--max-output SIZE none Limit for stdout and stderr together.
--cgroup-mode MODE auto auto, enforced, or fallback.
--cgroup-parent PATH none Delegated cgroup for the groups that corral makes.
--stdin POLICY null null or inherit.
--json PATH none Write the audit record to PATH.
--quiet off Do not print the summary line.

A DURATION is a number with ms, s, m, or h. A SIZE is a number of bytes with an optional K, M, or G.

With --json, corral writes one JSON record for each run. The record gives the mode, the limits, and why the run ended. It also gives the time of each step and the pids of any process that did not stop.

Code Meaning
command's code The command exited.
128 + N Signal N stopped the command, or corral received signal N (130 for Ctrl-C).
124 The time limit expired.
121 The memory limit was reached.
122 The output limit was reached.
126, 127 corral could not start the command (127: not found).
125 Setup failed or an option is not correct. The command did not start.
120 corral could not prove that all processes stopped. This code overrides all other codes.
  • The command starts in a new session, so a signal to its group never reaches corral.
  • corral is a child subreaper. Orphaned processes become children of corral, so corral can stop them.
  • corral signals a process that is not its child only through a pidfd, after it checks the process again. A reused pid never gets the signal.
  • The run ends when the command exits, not when its output pipes close.
  • After each run, corral stops and reaps processes until none are left. In enforced mode, the kernel must also report the group empty, and rmdir of the group must succeed.

bench/compare.py runs ten test programs from faults/ with four runners:

  • CE: corral in enforced mode
  • CF: corral in fallback mode
  • TO: timeout -k 1s 2s
  • NV: a Python runner that kills only its child

Each run has a 2 s time limit. Each cell shows the exit status and the largest number of processes that stayed alive, over 5 runs.

Test program CE CF TO NV
F1 prints a line every 100 ms 124 / 0 124 / 0 124 / 0 137 / 0
F2 waits for stdin 0 / 0 0 / 0 124 / 0 137 / 0
F3 starts a background job 0 / 0 0 / 0 0 / 1 hung / 1
F4 starts a daemon and exits 0 / 0 0 / 0 0 / 1 hung / 1
F5 starts a daemon and stays 124 / 0 124 / 0 124 / 1 hung / 1
F6 ignores SIGTERM 124 / 0 124 / 0 137 / 0 137 / 0
F7 forks 200 children 124 / 0 124 / 0 124 / 1 hung / 192
F8 child holds stdout 0 / 0 0 / 0 0 / 1 hung / 1
F9 uses 256 MiB 121 / 0 124 / 0 124 / 0 137 / 0
F10 cleans up on SIGTERM 124 / 0 124 / 0 124 / 0 137 / 0

corral left no process alive in any test. CE also stopped F9 at its 64 MiB limit in 0.12 s. TO left a process alive in F7 in 1 of 5 runs. On this machine, timeout is from uutils coreutils 0.8.0, not GNU coreutils.

bench/bench.py measured these times (P50 / P95):

Measurement Enforced Fallback
Stop F5: time limit to verified 9 / 12 ms 10 / 12 ms
Run /bin/true (direct: 0.83 / 1.88 ms) 11.33 / 19.47 ms 6.64 / 10.01 ms

Test machine: Hetzner cloud server, 2 shared vCPUs (Intel Xeon, Skylake), Ubuntu 26.04, kernel 7.0. The full data and a terminal recording of scripts/demo.sh are in bench/results/.

  • corral cannot see work that the command starts outside its process tree, for example with systemd-run, D-Bus, or at.
  • In enforced mode, a process can leave the group if it writes to cgroupfs itself.
  • In fallback mode, corral cannot signal a setuid child. It then exits with 120.
  • If you stop corral with SIGKILL, only the direct child is sure to stop.
  • corral has no PTY support and no CPU time limit, and it runs only on Linux.

MIT. See LICENSE.