Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
359 changes: 359 additions & 0 deletions .agents/docs/2026-09-04-four-gaps-after-the-ecosystem-batch.md

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,52 @@
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。

## [2026.9.4.3] — 2026-09-04

### ⭐⭐ `mcpp run` 报告程序自己的退出码

在此之前所有非零退出码都被折成 `1`,为的是让 `2` 表示「起不来」以区别于「跑了但
失败」。区别值得保留,代价不值得:`main` 返回 `3` 的程序让 `mcpp run` 退 `1`,
qemu 报 `3` 的裸机镜像同样到达为 `1`。一条报不出退出码的命令没法写进脚本,而这
正是 `mcpp run` 的主要用途。

取值空间分三段,只有第一段属于程序:

| 区间 | 含义 |
|---|---|
| `0`–`124` | 程序自己的退出码,原样透传 |
| `125`–`127` | 尝试启动但被拒绝(`127` 找不到 / `126` 不可执行 / `125` 其他) |
| `2` | mcpp 在尝试启动之前就拒绝了(用法、配置、解析) |

中间那段是 `env`、`timeout`、`nice` 早已在用且被 shell 文档化的取值,所以 `126`
与 `127` 带着惯常含义到达。程序自己也可以退 `125`–`127`,mcpp 不靠数字区分 ——
启动失败一定向 stderr 写出原因,程序自己的退出码从不写。

`mcpp test` 不变,仍为 `0`/`1`:它聚合多个程序,没有单一退出码可透传。

**兼容性**:mcpp 自身的配置错误仍是 `2`,与其余所有命令一致 —— 变的只有「尝试
启动后被拒」这一种情况,而那时程序根本没运行。契约写在 `docs/11` §6。

### ⚠️⚠️ 没有构建进程能比启动它的 mcpp 活得更久

实测:每一次被 `timeout` 终止的 `mcpp run` 都留下一个空转占满一个核的 ninja,
其中一个的工作目录已经是 `(deleted)`、比它所属的整个沙箱活得还久。任何用
`timeout` 包住 mcpp 的 CI,每超时一次泄漏一个忙核。

子进程现在进入**自己的进程组**(Windows 上是 job object),mcpp 在收到
SIGINT/SIGTERM/SIGHUP 时对该组发 **SIGKILL**。用 SIGKILL 而不是 SIGTERM 是必要
的:ninja 把信号记进标志位,只在等待子进程处才检查;一个没有命令在跑的 ninja
永远到不了那个检查点,礼貌的信号被记录且永不执行 —— 那正是那些孤儿所处的状态。

守卫从单槽改为**多槽登记表**:一个跨构建的 `[hooks]` 命令与构建自己的 ninja 会
同时被守卫,单槽会让后注册者解除前者的守卫。

### `MCPP_NINJA_DEBUG`

设置后向 ninja 追加 `-d <topics>`(如 `explain`)。用于那个还没定因的 ninja 空转:
`ptrace_scope=1` 与 `perf_event_paranoid=4` 都取不到栈,而把 gdb 变成 ninja 的父
进程之后空转就不再发生 —— `-d explain` 是唯一能对这种进程取证的手段。

## [2026.9.4.2] — 2026-09-04

### ⭐⭐ Cortex-M 有 C 库了:`libdir` 填上,而它的键是**三元组**
Expand Down
41 changes: 36 additions & 5 deletions docs/11-machine-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,38 @@ output, and a warning would land in the middle of it.
Both spellings are produced from the same source, so they always describe the
same thing — one answer, two shapes.

## 6. Stability guarantees
## 6. Exit status

`mcpp run` REPORTS THE PROGRAM'S OWN EXIT STATUS. Three bands divide the space,
and only the first belongs to the program:

| range | meaning |
|---|---|
| `0`–`124` | the program ran; this is its own status, passed through unchanged |
| `125`–`127` | the spawn was attempted and refused — `127` not found, `126` found but not executable, `125` anything else |
| `2` | mcpp refused before attempting anything: a usage, configuration or resolution error |

Until 2026.9.4.3 every non-zero status was folded to `1`, so that `2` could mean
"could not start" as distinct from "ran and failed". The distinction was worth
keeping; the price was not. A program whose `main` returned `3` made `mcpp run`
exit `1`, and a bare-metal image that qemu reported as `3` arrived as `1` as
well — so the command this project tells people to type could not be branched on.

The middle band is the one `env`, `timeout` and `nice` already use and that
shells document, so `126` and `127` arrive with their usual meanings rather than
as numbers this project allocated.

A PROGRAM MAY ITSELF EXIT `125`–`127`, AND mcpp DOES NOT TRY TO DISAMBIGUATE BY
NUMBER. What separates the two is that a launcher failure always writes a reason
to stderr and a program's own status never does. A client that must be certain
should read stderr, or use `--format json` where the status is a field rather
than a channel.

`mcpp test` is unchanged and remains `0` or `1`: it aggregates many programs, so
there is no single status to pass through. Per-test codes are in the JSON
stream's `exit_code` field (§8).

## 7. Stability guarantees

For each `kind`, within a `kindVersion`:

Expand All @@ -194,7 +225,7 @@ schema — `xlings interface --list` declares 20 capabilities whose
`outputSchema` is, for all 20, only `{"exitCode": integer}`, and a client that
sees a version number assumes there is a contract behind it.

## 7. Kinds
## 8. Kinds

### `mcpp.env` — where mcpp keeps things

Expand Down Expand Up @@ -316,13 +347,13 @@ them apart:
"impl": "openkal-musl@0.3.5", "origin": "graph" } ]
```

A field was added rather than `cLibrary` renamed or `mode` widened, because §6
A field was added rather than `cLibrary` renamed or `mode` widened, because §7
promises that fields are added and never removed and that a field's meaning
never changes.

⚠️ **`layers[].interface` changed VALUE for a payload-supplied glibc in
2026.9.1.1** — from `gnu` to `glibc`, and on Windows from `gnu` to `ucrt`. The
field's meaning is unchanged (it still names the implementation), so §6 holds;
field's meaning is unchanged (it still names the implementation), so §7 holds;
what changed is that it stopped reporting the triple's env segment, which is a
request rather than an implementation and is not the name of any C library. The
values are now the ones [14 — The Target Side](14-target-side.md) has always
Expand Down Expand Up @@ -367,7 +398,7 @@ mcpp test [pattern] [--workspace] --message-format json

This stream predates the envelope of §2 and is not wrapped in it: it is NDJSON,
one record per test as each finishes, then one summary record per member. A
`--workspace` run ends with one `workspace_summary` record. The §6 guarantees
`--workspace` run ends with one `workspace_summary` record. The §7 guarantees
apply to it — fields are added and never removed, and a field's meaning never
changes — and the fields below are the contract as of 2026.9.2.1.

Expand Down
35 changes: 30 additions & 5 deletions docs/zh/11-machine-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,32 @@ mcpp cache list --json -> {"root": …, "entries": [ … ]}

两种拼写由同一个来源产出,所以永远描述同一件事:一个答案,两种形状。

## 6. 稳定性承诺
## 6. 退出码

`mcpp run` 报告程序自己的退出码。整个取值空间分三段,只有第一段属于程序:

| 区间 | 含义 |
|---|---|
| `0`–`124` | 程序跑过了,这是它自己的退出码,原样透传 |
| `125`–`127` | 尝试启动但被拒绝 —— `127` 找不到,`126` 找到但不可执行,`125` 其他 |
| `2` | mcpp 在尝试启动之前就拒绝了:用法、配置或解析错误 |

2026.9.4.3 之前,所有非零退出码都被折成 `1`,为的是让 `2` 表示「起不来」以区别于
「跑了但失败」。这个区别值得保留,代价不值得:`main` 返回 `3` 的程序会让
`mcpp run` 退 `1`,qemu 报 `3` 的裸机镜像同样到达为 `1` —— 本项目让人使用的这条
命令因此无法用于分支判断。

中间那一段是 `env`、`timeout`、`nice` 早已在用、且被 shell 文档化的取值,所以
`126` 与 `127` 带着它们惯常的含义到达,而不是本项目分配的编号。

**程序自己也可以退 `125`–`127`,mcpp 不试图靠数字区分。** 区分二者的是:启动失败
一定向 stderr 写出原因,而程序自己的退出码从不写。需要确定的客户端应当读 stderr,
或使用 `--format json` —— 那里退出码是一个字段而不是一条通道。

`mcpp test` 不变,仍为 `0` 或 `1`:它聚合多个程序,没有单一退出码可以透传。
每个测试各自的退出码在 JSON 流的 `exit_code` 字段里(§8)。

## 7. 稳定性承诺

对每个 `kind`,在同一 `kindVersion` 内:

Expand All @@ -166,7 +191,7 @@ mcpp cache list --json -> {"root": …, "entries": [ … ]}
capability,其 `outputSchema` 全部只有 `{"exitCode": integer}`,而客户端看到版本号就会
以为背后有契约。

## 7. 各 kind
## 8. 各 kind

### `mcpp.env` —— mcpp 把东西放在哪

Expand Down Expand Up @@ -280,12 +305,12 @@ mcpp why toolchain [--target <triple>] [--toolchain <spec>] --format json
"impl": "openkal-musl@0.3.5", "origin": "graph" } ]
```

是**新增一个字段**而不是给 `cLibrary` 改名或给 `mode` 加取值,因为 §6 承诺字段
是**新增一个字段**而不是给 `cLibrary` 改名或给 `mode` 加取值,因为 §7 承诺字段
只增不删、且一个字段的含义永不改变。

⚠️ **2026.9.1.1 起,载荷供给的 glibc 让 `layers[].interface` 的**取值**变了** ——
从 `gnu` 变为 `glibc`,Windows 上从 `gnu` 变为 `ucrt`。字段的**含义**没变(它仍然是
「哪个实现」),所以 §6 仍然成立;变的是它不再报三元组的 env 段 —— 那是一次请求而
「哪个实现」),所以 §7 仍然成立;变的是它不再报三元组的 env 段 —— 那是一次请求而
不是一个实现,也不是任何一个 C 库的名字。现在的取值就是
[14 —— 目标侧](14-target-side.md)一直列着的那些,并且包可以在
`cfg(c-abi = …)` 谓词里与它们比较。按字面量 `gnu` 取值的客户端需要更新;
Expand Down Expand Up @@ -324,7 +349,7 @@ mcpp test [pattern] [--workspace] --message-format json
```

这条流早于 §2 的信封,也不被信封包裹:它是 NDJSON,每个测试结束时一条记录,随后每个
成员一条汇总记录。`--workspace` 运行以一条 `workspace_summary` 记录结束。§6 的保证
成员一条汇总记录。`--workspace` 运行以一条 `workspace_summary` 记录结束。§7 的保证
对它同样成立 —— 字段只增不减,字段含义不变 —— 下表是 2026.9.2.1 时的契约。

每个测试:
Expand Down
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mcpp"
version = "2026.9.4.2"
version = "2026.9.4.3"
description = "Modern C++ build & package management tool"
license = "Apache-2.0"
authors = ["mcpp-community"]
Expand Down
85 changes: 76 additions & 9 deletions modules/platform/src/process.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ void stop_background(const BackgroundCommand& child,
// user can no longer name. Only one command is guarded at a time; a build owns
// at most one.
void guard_background_on_signal(const BackgroundCommand& child);
void clear_background_guard();
void clear_background_guard(const BackgroundCommand& child);

// `spawn_error`: run_exec's contract; with it null a refused spawn is
// formatted into `output`, as capture_exec does.
Expand Down Expand Up @@ -596,24 +596,75 @@ int run_exec(const std::vector<std::string>& argv,
for (auto& a : argv) cargv.push_back(const_cast<char*>(a.c_str()));
cargv.push_back(nullptr);

// THE CHILD GETS ITS OWN PROCESS GROUP, AND mcpp KILLS THAT GROUP IF IT IS
// ITSELF KILLED.
//
// Without this, terminating mcpp leaves the child running. Measured: every
// `timeout`-terminated `mcpp run` left an orphaned ninja spinning at 100%
// of a core, and one of them outlived the removal of the entire sandbox it
// belonged to — its working directory read `(deleted)` and it was still
// burning a core half an hour later. Any CI that wraps mcpp in `timeout`
// leaks a busy core per timeout.
//
// The group rather than the pid, because the child starts children of its
// own: killing ninja alone would leave its compilers behind.
//
// A signal is not enough on its own, which is why the guard sends SIGKILL.
// ninja records a signal in a flag and acts on it where it waits for a
// subprocess; a ninja with no command running never reaches that check, so
// a polite signal is recorded and never obeyed.
posix_spawnattr_t attr;
::posix_spawnattr_init(&attr);
::posix_spawnattr_setpgroup(&attr, 0); // 0 ⇒ new group, id == pid
::posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP);

pid_t pid = 0;
if (int sp = ::posix_spawnp(&pid, cargv[0], nullptr, nullptr, cargv.data(), envp.data());
sp != 0) {
int sp = ::posix_spawnp(&pid, cargv[0], nullptr, &attr, cargv.data(), envp.data());
::posix_spawnattr_destroy(&attr);
if (sp != 0) {
// Reported once: by the caller when it asked for the errno, here
// otherwise. Never dropped — the errno in hand at this line is the
// whole difference between "Exec format error" and a blank line.
if (spawn_error) *spawn_error = sp;
else std::fputs(spawn_failure(argv.front(), sp).c_str(), stderr);
return 127;
}
mcpp::platform::unixproc::guard_group_on_signal(pid);
int status = 0;
while (::waitpid(pid, &status, 0) < 0) { /* EINTR retry */ }
mcpp::platform::unixproc::unguard_group(pid);
return normalize_exit_code(status);
#else
// THE SAME OWNERSHIP AS THE POSIX BRANCH, EXPRESSED IN THIS PLATFORM'S TERMS.
//
// `std::system` gave the child away: it runs through a cmd.exe mcpp does not
// hold a handle to, so terminating mcpp left the tree running exactly as the
// POSIX branch did before its process group. A job object with
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE is the equivalent unit — it takes the
// whole tree, which matters here for the same reason the group does there:
// the child starts compilers of its own.
//
// The command line is built by the same `windows_shell_command_line` the
// rest of this file uses, so the cmd.exe quoting rule has ONE derivation.
// Re-deriving it here is how `/d /s /c` loses an argument.
std::string prefix = mcpp::platform::env::build_env_prefix(extraEnv);
// wrap only — run_exec inherits stdio on purpose (see finalize_shell_command).
std::string cmd = wrap_for_cmd_c(prefix + command_from_argv(argv));
return normalize_exit_code(std::system(cmd.c_str()));
std::string cmd = windows_shell_command_line(prefix + command_from_argv(argv));
auto child = mcpp::platform::winproc::spawn_background(cmd.c_str(), nullptr, 1);
if (!child.ok) {
const int refused = static_cast<int>(child.refused);
if (spawn_error) *spawn_error = refused;
else std::fputs(spawn_failure(argv.front(), refused).c_str(), stderr);
return 127;
}
mcpp::platform::winproc::guard_job_on_signal(child.job);
int code = 127;
mcpp::platform::winproc::wait_background(child.process, &code);
mcpp::platform::winproc::unguard_job(child.job);
// Closes both handles; the child has already exited, so this is cleanup
// rather than a kill.
mcpp::platform::winproc::background_stop(child.job, child.process, 0);
return code;
#endif
}

Expand Down Expand Up @@ -652,10 +703,22 @@ RunResult capture_exec(
::posix_spawn_file_actions_addclose(&fa, fds[0]);
::posix_spawn_file_actions_addclose(&fa, fds[1]);

// Owned exactly as `run_exec`'s child is, and for the same reason: this is
// the launcher a FULL build uses, so a `mcpp build` interrupted here is the
// common case rather than the rare one. Fixing only `run_exec` left the
// orphan in place — measured, with the two launchers giving opposite
// answers to the same test.
posix_spawnattr_t attr;
::posix_spawnattr_init(&attr);
::posix_spawnattr_setpgroup(&attr, 0);
::posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP);

pid_t pid = 0;
int sp = ::posix_spawnp(&pid, cargv[0], &fa, nullptr, cargv.data(), envp.data());
int sp = ::posix_spawnp(&pid, cargv[0], &fa, &attr, cargv.data(), envp.data());
::posix_spawnattr_destroy(&attr);
::posix_spawn_file_actions_destroy(&fa);
::close(fds[1]);
if (sp == 0) mcpp::platform::unixproc::guard_group_on_signal(pid);
if (sp != 0) {
::close(fds[0]);
result.exit_code = 127;
Expand All @@ -671,6 +734,7 @@ RunResult capture_exec(
::close(fds[0]);
int status = 0;
while (::waitpid(pid, &status, 0) < 0) { /* EINTR retry */ }
mcpp::platform::unixproc::unguard_group(pid);
result.exit_code = normalize_exit_code(status);
return result;
#else
Expand Down Expand Up @@ -889,11 +953,14 @@ void guard_background_on_signal(const BackgroundCommand& child) {
mcpp::platform::unixproc::guard_group_on_signal(child.group);
}

void clear_background_guard() {
// Releases THIS child, not the guard as a whole: a build's ninja is guarded at
// the same time as a spanning hook, and disarming everything when either
// finishes would leave the other able to outlive mcpp.
void clear_background_guard(const BackgroundCommand& child) {
if constexpr (mcpp::platform::is_windows)
mcpp::platform::winproc::clear_job_guard();
mcpp::platform::winproc::unguard_job(child.job);
else
mcpp::platform::unixproc::clear_group_guard();
mcpp::platform::unixproc::unguard_group(child.group);
}

RunResult capture_exec_deadline(
Expand Down
Loading
Loading