| 项 | 值 |
|---|---|
| 规范编号 | SPEC-003 |
| 标题 | mcpp 的进程退出码:分类、语义与稳定性承诺 |
| 状态 | 评审中 v1.2 |
| 版本 | 1.2 |
| 最后修改 | 2026-10-26 |
| 对应实现 | mcpp >= 2026.9.1.1;5 与信号(§3.6、§3.7)mcpp >= 2026.10.26.1 |
| 相关设计文档 | .agents/docs/2026-08-08-machine-readable-output-protocol-design.md §R4、.agents/docs/2026-08-31-issue540-seven-audit-findings.md §4、.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md §5.4、§5.5(D23) |
| 相关 issue | #379、#540、#839 |
机器可读输出协议的设计记录(§R4)在实测四个退出码之后写下:
光接管 parse error 不够,还要把 usage / runtime / internal 的 rc 映射写成契约, 并覆盖异常边界 —— 否则客户端仍然要靠猜。这条现在是
docs/specs/的内容,不是 代码。
被指派的那份契约一直没有写。docs/50-machine-output.md 落地了其中的
usage / internal 一半(2、70、127),runtime 的一半 —— 也就是命令跑了并且
失败时返回的 1 —— 既不在那张表里,也不在别处。#540 由此把表读成「不完整」,并
提出补 4;而 4 恰恰是那几个带信封的命令给不出的码。
两件事都是同一个缺口的症状:没有一处说明 mcpp 一共会返回哪些码。 这份规范是那 一处。
本规范约束 mcpp 可执行文件自身的进程退出码。
不约束:
- 子进程(编译器、链接器、ninja、xlings、
build.mcpp)的退出码。它们由 mcpp 解释, 不会直接透传; mcpp test所运行的测试二进制的退出码。测试失败在 mcpp 这一层是一次运行期失败, 按 §2 归入1;- 库层 API 的返回值。
status_severity()这类返回0..3的函数是严重性排序, 与退出码无关,禁止被读作退出码。
| 码 | 类别 | 含义 | 通道 |
|---|---|---|---|
0 |
成功 | 命令完成了它承诺的事 | 正常输出走 stdout |
1 |
运行期失败 | 命令跑了、请求合法、结果是失败 | 人类可读的原因走 stderr |
2 |
用法错误 | 未知选项、不支持的选项值、缺少必需参数 | stderr |
4 |
环境未就绪 | 全局配置加载 / 首次初始化失败($MCPP_HOME 不可写、config.toml 损坏、引导 xlings 失败) |
stderr |
5 |
副作用被拒绝 | 运行在调用方拒绝的副作用(--deny、MCPP_DENY_EFFECTS、--offline)之前停下;项目本身没有失败 |
stderr 写明副作用与原因 |
70 |
内部错误 | 未捕获异常。EX_SOFTWARE |
stderr |
101 |
构建失败(仅 mcpp run) |
mcpp run 的规划或构建失败,程序没有启动。Cargo 对 cargo run 构建失败给出同一个值 |
stderr |
127 |
未知命令 | 第一个位置参数不是一个子命令 | stderr |
128 + N |
被信号结束(POSIX) | SIGINT(130)、SIGTERM(143)、SIGHUP(129)结束了这次运行;状态由进程死于该信号得到,不是 mcpp 返回的值 |
结果记录写 cancelled(§3.7) |
4 与 1 的分界是谁需要被修:4 说明 mcpp 自己的家还没有准备好,任何命令都会
撞上同一堵墙;1 说明这一次请求失败了,而 mcpp 是可用的。把二者合并会让「我的工程
有问题」和「我这台机器上的 mcpp 有问题」同读数。
70 与 1 的分界是这是不是一个缺陷:70 一律意味着 mcpp 有 bug,值得开
issue;1 通常不是。
101 只属于 mcpp run。run 透传程序自身的状态(docs/50-machine-output.md
§6),所以它需要一个程序不常返回的值来表示「程序没有被构建出来」,使脚本能把一次
编译错误和一个返回 1 的程序区分开。mcpp build、mcpp test、mcpp pack 的状态
不变。自己返回 101 的程序,或返回 101 的 runner,读起来与构建失败相同;区分
它们靠 stderr,构建失败一定在那里写出原因。
一个已经发布的失败场景禁止在后续版本里改变它所属的类别。新增类别可以引入新
的码;把既有场景从 1 挪到 4(或反向)是破坏性变更。
返回 2 的路径禁止产生任何副作用。一个还不知道自己会被要求做什么的请求,不该
已经写过磁盘。
客户端禁止用退出码判断「这个 mcpp 支不支持某项功能」。理由见
docs/50-machine-output.md §1:在该协议出现之前发布的每个版本上,未知选项本身就是
一次错误,而它当年走的是 stdout + 退出码 1。唯一跨版本成立的判据是解析 stdout。
一次失败可以同时是一份文档。mcpp xpkg parse 对一份违反名字形态的描述符会把
判定作为 JSON 打到 stdout 并且退 1。
因此客户端禁止把非零退出当作「没有输出」而跳过解析。这与 §3.3 是同一条规则的两 个方向:退出码不携带「有没有输出」的信息。
任何非零退出必须在 stderr 上留下至少一行说明。空 stderr 加非零退出是缺陷。
调用方用 --deny <副作用,…>、MCPP_DENY_EFFECTS 或 --offline(等价于拒绝
network)约束一次运行可以产生的副作用。副作用的词汇与信封的 effects 相同
(docs/50-machine-output.md §4):init-mcpp-home、write-project、
write-global-cache、network、exec-build-script;read-project 不可拒绝。未知的
副作用名是用法错误,返回 2。
运行在第一个被拒绝的副作用之前停下,stderr 写明副作用与它要做的事(例如
network: install xim:gcc@16.1.0),退出码为 5。被拒绝的 write-global-cache 不
停下运行:构建改用工程本地的缓存,运行照常完成。
5 与 1 的分界是谁的决定挡住了运行:5 说明调用方的约束挡住了一件运行需要做
的事,去掉约束(或由调用方的宿主审批)后重跑即可;1 说明项目或环境本身失败。只有
shell 的 agent 依靠这一个数就能区分两者。
POSIX 上,SIGINT、SIGTERM、SIGHUP 结束 mcpp 时,mcpp 先结束它启动的进程组(ninja、
钩子),再按信号的默认动作死去,因此调用方读到的状态是 128 + N:130、143、
129。这些值不是 mcpp 返回的,§3.1 的稳定性承诺对它们的含义是"被该信号结束"。
正在写构建目录的运行(从配置目录确定到构建结束)被信号结束时,结果记录
result.json 与 target/.last-result.json 的 status 为 cancelled
(docs/50-machine-output.md §9)。记录的这一形态在运行开始时预先写好,由信号处理器用
异步信号安全的调用落盘。SIGKILL 不经过处理器:一份 status 仍为 running、而 pid
已不存在的记录,说明那次运行被强制结束。构建目录在两种情况下都保持一致,下一次构建
从中断处继续。
Windows 上,Ctrl-C 与 Ctrl-Break 由控制台处理器结束受管的作业、写下同样的
cancelled 记录,然后交给系统的默认处理;进程的状态是系统给出的
STATUS_CONTROL_C_EXIT(0xC000013A)。
无。§2 的码是 src/ 中出现的全部进程退出码(2026-09-01 核对时是六个;1.1 起增加 101,见 execute.cppm 的 kRunBuildFailed;1.2 起增加 5,见 cli.cppm 末尾按 env::effect_refused() 的映射)。128 + N 不是 mcpp 返回的值,不在这张核对表中。
2026-09-01 穷举核对,记录方法与读数,因为「我数了一遍」不是判据:
$ grep -rhoE "return [0-9]+;" src/ --include=*.cppm --include=*.cpp | sort -u
return 0; return 1; return 2; return 3; return 4; return 5;
return 9; return 19; return 20; return 23; return 127; return 1024;
逐个归类:
| 数 | 属于退出码 | 出处 |
|---|---|---|
0 1 2 127 |
是 | 全仓;127 在 cli.cppm |
4 |
是,11 处,全部同一个原因 | config::load_or_init 失败:index_management.cppm×6、doctor.cppm×2、pack/pipeline.cppm、cli/cmd_toolchain.cppm、pm/commands.cppm 各 1 |
70 |
是 | main.cpp 的 rc = 70(EX_SOFTWARE),两处 |
3 |
否 | runtime_validation.cppm 的 status_severity() —— 严重性排序 |
4 5 9 19 20 1024 |
否 | runtime/elf.cppm 的 ELF 重定位类型表(R_RISCV_COPY / R_X86_64_COPY / …) |
23 |
否 | toolchain/msvc.cppm 的版本解析 |
4 同时出现在两栏,这正是为什么这张表按出处而不是按数字归类:同一个字面量在
一个文件里是退出码,在另一个文件里是 EM_RISCV 的重定位类型。仅凭 grep "return 4"
数出来的 13 处里,有 2 处不是退出码。
| 版本 | 日期 | 变更 |
|---|---|---|
| 1.2 | 2026-10-26 | 新增 5:运行在调用方拒绝的副作用之前停下(§3.6)。新增 128 + N:被信号结束,结果记录为 cancelled;Windows 为 STATUS_CONTROL_C_EXIT(§3.7)。 |
| 1.1 | 2026-10-01 | 新增 101:mcpp run 的规划或构建失败(此前为 1 或 2,与返回同一个值的程序无法区分)。 |
| 1.0 | 2026-09-01 | 首版。落地 2026-08-08 协议设计文档 §R4 指派而未写的契约;补上 1 与 4,并说明为什么 4 不属于 docs/11 那张按信封命令划定的表(#540)。 |