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
1,142 changes: 1,142 additions & 0 deletions .agents/docs/2026-09-02-issue544-runner-implementation-plan.md

Large diffs are not rendered by default.

751 changes: 751 additions & 0 deletions .agents/docs/2026-09-02-runner-beyond-baremetal-design.md

Large diffs are not rendered by default.

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

## [2026.9.2.1] — 2026-09-02

`[target.<triple>].runner` 对每一个目标生效,启动失败不再无声,`mcpp test` 把跑不起来
的测试报成 not run 并退 2,`[xlings]` 的值可以按宿主平台给出。

设计与实测见
[`.agents/docs/2026-09-02-runner-beyond-baremetal-design.md`](.agents/docs/2026-09-02-runner-beyond-baremetal-design.md)。

> **一个键被解析、被类型检查、被文档记录,而读它的那处代码在读之前就返回了。**
> `choose_runner` 在 `os == "none"` 之外一律返回空模板,于是宿主交叉目标
> (x86_64 上构建的 `aarch64-linux-musl`)的 runner 从未被查询过:`mcpp run` 裸执行
> 产物,内核以 `ENOEXEC` 拒绝,而 `run_exec` 把这次拒绝变成一个不打印任何东西的
> 127。同一层的有界启动器在自己的声明里写着「『起不来』与『跑了但失败』不能共用
> 一个退出码」,而它的两个调用方都靠**再 spawn 一次**来回落,把第一次的 errno 丢掉。

### 修复

- **`[target.<triple>].runner` 对每个目标生效。** freestanding 谓词只决定一件事:
没有 runner 时是否在任何 spawn 之前就失败。宿主能不能执行一个外来 ISA 的产物不再
被预测 —— 这台机器上 `binfmt_misc` 注册了 qemu-user 就能跑,报告 #544 的那台不能,
而两者的三元组相同。mcpp 要么执行工程声明的 runner,要么尝试启动并报告内核的回答。

- **runner 的程序由 mcpp 定位,不由 `posix_spawnp` 定位。** 先在 `[xlings] deps`
声明的每个载荷的 `bin/` 里找,再走 `PATH`。裸名在 `PATH` 上解析到的是 xvm 垫片,
而垫片按**当前 SubOS** 回答而不是按包所在的位置回答(e2e 130 在 CI 里记录过这一条:
同一个 job 里 `qemu-system-riscv64 --version` 成功,而 `mcpp run` 执行同一个裸名
得到「未安装」)。「哪儿都找不到」在任何 spawn 之前判定,并且是错误而不是回落到
裸执行:让产物在另一个解释器下带着另一组参数运行,正是这个键存在要防止的失败。

- **启动失败被定型并且只被报告一次。** `DeadlineRun` 与 `BoundedOutcome` 带上
spawn 错误码;`run_exec`、`capture_exec` 与两个 deadline 包装各多一个末位
`int* spawn_error`。调用方要了错误码就由调用方报告,没要就由启动器自己报告。
没有第二次 spawn。

- **`mcpp test` 有了第四种状态。** 产物这台宿主装载不了的测试既没有通过也没有失败。
它被报成 **not run**,原因在确立时打印一次,在汇总行里以与失败同等的分量再出现一次,
退出码是 2 —— freestanding 的 no-runner 路径对同一种处境早就用这个码。1 保持
「跑了并且失败」,0 保持「每个测试都跑了并且通过」。`--message-format json` 带上
`"status":"not_run"` 与每条记录的 `reason`,汇总记录带上 `not_run` /
`not_run_reason`;`--workspace` 另加 `tests_not_run` 与 `unrunnable_members`。

- **`--no-runner`,`mcpp run` 与 `mcpp test` 都接受。** 「这台宿主能直接执行该产物」
是关于宿主的事实,而 manifest 没有宿主轴:`[target.<triple>]` 按目标索引,
`[xlings] deps` 没有任何索引。为 x86_64 开发者写的 runner 在 aarch64 宿主上同样会
被读到,而那里模拟器既无用也装不上。旗标由那台宿主上的操作者给出,因为只有那里
知道这件事。

- **`[xlings]` 的值可以按宿主平台给出。** `deps` 的一项与 `[xlings.workspace]` 的一个
值都可以写成 `{ linux = "...", macos = "...", windows = "...", default = "..." }` ——
xlings 自己的 `.xlings.json` 对 `workspace` 接受的就是这个形式,`macosx` 作为它的
拼法一并接受。在 manifest 加载时对本机解析,因此下游每一个读者看到的仍然是一张平表。
未知的平台键是硬错误。在此之前 `[xlings.workspace]` 会**静默丢掉**一个表值,而
`[xlings] deps` 根本没有条件化形式 —— 这让 `deps = ["qemu-user-aarch64"]`
(索引里只为 x86_64 构建的包)在其余每一类宿主上都是硬构建错误。

- **`[target.<triple>]` 的未知键普查覆盖数组。** `runnerX = ["x"]` 会被报出来,
而支持键的清单里补上了 `runner` —— 此前普查刻意跳过数组,代价是这张表读取的唯一
一个数组键既不在清单里,拼错了也无人报告。

- **诊断名出的是规范拼法。** 六处消息此前打印驱动报出的三元组;其中三处打印的是
一段供粘贴的 `[target.<triple>]`,而在 macOS 上那是 `arm64-apple-darwin24.6.0`,
没有任何 `[target.…]` 查询会命中它。`RunnerChoice::tripleKey` 与查询本身在同一处
求出,两者不可能各说各的。

### 行为变化

- 宿主三元组下已经声明的 `runner` 从此生效。在其程序缺失的宿主上,`mcpp run` /
`mcpp test` 现在带消息失败,而不是静默裸跑产物;`--no-runner` 是出口。
- 此前对跑不起来的产物报 `FAIL (exit 127)` 的 `mcpp test` 现在报 `NOT RUN` 并退 2
(仍然非零,CI 作业不会因此改变颜色)。
- `[xlings] deps` 与 `[xlings.workspace]` 接受表值;带未知平台键的表此前被丢弃,
现在是错误。

## [2026.9.1.1] — 2026-09-01

#540 的七条审计,加上核验它们时挖出的四条没有人报过的。它们几乎全是同一族:
Expand Down
92 changes: 91 additions & 1 deletion docs/05-mcpp-toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -1086,7 +1086,7 @@ The selector `<sel>` has three forms:
|---|---|---|
| **bare OS alias** | a single OS / family — the concise, common form | `[target.windows]`, `[target.unix]` |
| **`cfg(...)` predicate** | a compound condition (arch / env / combinators) | `[target.'cfg(all(linux, not(arch = "aarch64")))']` |
| **exact triple** | one specific target (also carries `toolchain` / `linkage`) | `[target.x86_64-linux-musl]` |
| **exact triple** | one specific target (also carries `toolchain` / `linkage` / `sysroot` / `runner`; see §2.7.3) | `[target.x86_64-linux-musl]` |

A selector may carry platform-conditional **dependencies** and **build flags**:

Expand Down Expand Up @@ -1284,6 +1284,75 @@ different argv (`-bios default` for an OpenSBI boot, `-bios none -semihosting`
for a picolibc image) — and an engine that guesses one is an engine the other
board has to fight. A board-support package normally supplies it.

### 2.7.3 `runner` on a hosted target (2026.9.2.1+)

`[target.<triple>].runner` applies to every exact triple, not only to bare
metal. A hosted cross artifact — `aarch64-linux-musl` built on an x86_64
machine — is executable by some hosts (binfmt_misc with qemu-user registered)
and refused by others with `Exec format error`, and which of the two applies is
a property of the machine, not of the triple. mcpp does not predict it. It
either executes the artifact through the runner the project declared, or it
attempts direct execution and reports what the kernel answered.

```toml
[target.aarch64-linux-musl]
runner = ["qemu-aarch64-static"]
```

The rules, for `mcpp run` and `mcpp test` alike:

- **A declared runner is used.** Its first element is located by mcpp: first in
the `bin/` directory of each payload declared under `[xlings] deps` (§2.13),
then on `PATH`. A bare name on `PATH` resolves to an xvm shim, which answers
for the current SubOS rather than for the package; the payload lookup is what
lets a runner name a program the project declared.
- **A declared runner that cannot be found or started is an error**, with the
program, the directories searched and the errno. There is no fallback to
direct execution: running the artifact under a different interpreter with
different arguments is the failure the key exists to prevent.
- **No runner, and the kernel refuses the artifact:** `mcpp run` reports the
refusal and the key to write, and exits 2. `mcpp test` reports every test as
not run, with the reason once, and exits 2 (§2.7.3.1).
- **`--no-runner`** executes the artifact directly and ignores a declared
runner. It states a fact about this host — the triple is native here — that
the manifest has no axis to carry; a project whose runner was written for
x86_64 developers is still readable on an aarch64 machine.

Provisioning the emulator through `[xlings] deps` is the form for a CI job or
a project built on one host class. `qemu-user-aarch64` in the index is built
for x86_64 Linux only, and `[xlings] deps` provisions on every host that builds
the project, so the entry is written per platform (§2.13):

```toml
[xlings]
deps = [{ linux = "qemu-user-aarch64" }]

[target.aarch64-linux-musl]
runner = ["qemu-aarch64-static"]
```

A package the host cannot install is a hard build error, so an entry without
the platform form would make the project unbuildable on macOS and Windows. The
Linux/aarch64 host, where the package does not exist either, passes
`--no-runner`.

#### 2.7.3.1 `mcpp test` and tests that were not run

A test whose artifact this host cannot execute has neither passed nor failed.
`mcpp test` reports it as **not run**, prints the reason once when it is
established, repeats the first line of the reason in the summary, and exits 2:

```
warning: this host cannot execute aarch64-linux-musl artifacts: Exec format error (error 8); declare [target.aarch64-linux-musl].runner, or pass --no-runner on a host that can
smoke ... not run
error: test result: NOT RUN. 0 passed; 0 failed; 1 not run (this host cannot execute aarch64-linux-musl artifacts: Exec format error (error 8); ...); finished in 0.41s (build 0.39s + run 0.00s)
```

Exit code 1 keeps its meaning — a test ran and failed — and 0 means every test
ran and passed. `--message-format json` carries `"status":"not_run"` and a
`reason` on each record, and `not_run` / `not_run_reason` on the summary
record (see [11 — Machine-Readable Output](11-machine-output.md)).

### 2.8 `[features]` — Features (Cargo-style, additive)

```toml
Expand Down Expand Up @@ -1820,6 +1889,27 @@ build needs (`make`/`cmake`/`protoc`/…), pin tool versions per project, or set
build-time env vars — without hand-editing `.xlings.json`. `[toolchain]` (§2.7) remains
the ergonomic shorthand for the compiler; `[xlings.workspace]` is the general form.

**Values per host platform (2026.9.2.1+).** A `deps` entry and a
`[xlings.workspace]` value may be a table keyed by platform, the form xlings'
own `.xlings.json` accepts for `workspace`:

```toml
[xlings]
deps = ["xim:ninja", { linux = "qemu-user-aarch64" }, { windows = "nasm", default = "yasm" }]

[xlings.workspace]
gcc = { linux = "15.1.0" }
llvm = { macos = "20", default = "22" }
```

The keys are `linux`, `macos`, `windows` and `default`; `macosx` is accepted as
xlings' spelling of `macos`. mcpp resolves the table against the host it runs
on when the manifest is loaded: the host's key wins, `default` is the fallback,
and a table with neither declares nothing on that host — the entry is absent,
not empty. An unknown key is an error rather than a dropped entry. The axis is
the host operating system only; a package that exists for the OS but not for
the architecture is still a provisioning error on that host.

`subos` selects the root project's **local build/run OS environment**. If the
key is absent, mcpp uses its initialized, release-verified `McppDefault` SubOS;
`subos = "default"` is an explicit `NamedSubos("default")` selection. There is
Expand Down
51 changes: 51 additions & 0 deletions docs/11-machine-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,54 @@ from the same resolution a build performs, which may fetch packages, install a
payload and run a dependency's build program. A client gates on that table
*before* running anything, so an omission would be a safety claim that is not
true.

### `mcpp test --message-format json` — the test stream

```
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
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.

Per test:

| field | |
|---|---|
| `member` | the workspace member, or `""` outside a workspace |
| `test` | the path-based test name (`tests/00-a/0.cpp` → `00-a/0`) |
| `status` | `pass`, `compile_fail`, `run_fail`, or `not_run` |
| `exit_code` | the test's exit status; `0` for `not_run` |
| `signal` | the signal number when the status encodes one, else `null` |
| `duration_ms` | build+run wall time of this test |
| `timed_out` | `true` when `--timeout` killed it (`run_fail`) |
| `compile_output`, `run_output` | captured diagnostics |
| `reason` | `not_run` only: why, in one sentence; `""` otherwise |

Summary record, `{"summary": {...}}`:

| field | |
|---|---|
| `member`, `passed`, `failed` | counts |
| `not_run` | tests that were built and not executed |
| `not_run_reason` | the reason shared by all of them, or `""` |
| `elapsed_ms`, `build_ms`, `run_ms` | wall time, split |

⚠️ **`not_run` is neither `pass` nor `run_fail`, and the exit code says so
(2026.9.2.1).** A test is `not_run` when this host cannot load its artifact
(`Exec format error` on a cross target with no runner declared), or when the
declared `[target.<triple>].runner` could not be found or started. The
condition is a fact about the invocation: it is established once, the
remaining tests are reported `not_run` without being started, and the process
exits **2**. Exit 1 keeps meaning "a test ran and failed"; exit 0 means every
test ran and passed. A client that read the exit code alone as pass/fail must
handle 2, and a client that inferred "everything passed" from `failed == 0`
must also read `not_run`.

`workspace_summary` adds `tests_not_run` (the sum over members) and
`unrunnable_members` (members all of whose tests were `not_run`), alongside the
existing `not_run` list, which continues to name members the
`--workspace-timeout` stopped before they started.
8 changes: 8 additions & 0 deletions docs/13-baremetal.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,14 @@ error: no runner is configured for 'riscv64-none-elf' — a freestanding artifac
A board-support package normally supplies this so you do not have to.
```

The key is not specific to bare metal. A hosted cross target — an
`aarch64-linux-musl` artifact on an x86_64 host — takes the same
`[target.<triple>].runner`, with a user-mode emulator such as
`qemu-aarch64-static` in place of the system emulator; on such a target an
absent runner is not an error until the kernel refuses the artifact. The rules
for hosted targets, the `--no-runner` escape and the not-run reporting of
`mcpp test` are in [5 — mcpp.toml](05-mcpp-toml.md), §2.7.3.

## Writing a board-support package

A board-support package is an ordinary mcpp package. It declares the emulator
Expand Down
19 changes: 19 additions & 0 deletions docs/15-openkal-cross.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,25 @@ runner = ["qemu-system-riscv64", "-machine", "virt", "-nographic",
firmware mode to use are board facts, and an engine that guesses one is an
engine a different board has to fight.

A hosted cross target takes the same key with a user-mode emulator
(2026.9.2.1). An `aarch64-linux-musl` artifact built on an x86_64 host is
executed through `qemu-aarch64-static` when the project declares it, and the
package that provides the emulator is declared for the hosts that can install
it:

```toml
[xlings]
deps = [{ linux = "qemu-user-aarch64" }]

[target.aarch64-linux-musl]
runner = ["qemu-aarch64-static"]
```

Without the key, `mcpp run` reports the kernel's refusal (`Exec format error`)
and the key to write, and `mcpp test` reports every test as not run and exits
2. A host that executes the artifact natively passes `--no-runner`. The rules
are in [5 — mcpp.toml](05-mcpp-toml.md), §2.7.3.

### The Source Is The Same, The Program Is Not

"The same source" is a claim about the toolchain and the standard library, and
Expand Down
13 changes: 13 additions & 0 deletions docs/17-the-project-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,19 @@ instead of installing, and names the packages so they can be provisioned
out of band — the same two knobs `[toolchain]` honours, for the same reason: an
unasked-for download is not something a build decides on a project's behalf.

The declaration is provisioned on every host that builds the project, and a
package the host cannot install is an error, not a skipped entry. A tool that
exists for one host platform only is therefore declared for that platform
(2026.9.2.1): `deps = [{ linux = "qemu-user-aarch64" }]` declares the emulator
on Linux and nothing elsewhere. The keys and the resolution rule are in
chapter 5, §2.13.

**The runner.** A program under `[xlings] deps` is also where
`[target.<triple>].runner` looks first for its first element, before `PATH`
(chapter 5, §2.7.3). The two keys together provision a user-mode emulator on a
CI host and execute a cross-built artifact through it, without the manifest
naming the payload's path.

## 6. What belongs somewhere else

| Need | Where it goes |
Expand Down
Loading
Loading