|
| 1 | +# Four gaps left by the ecosystem batch, and what to do about each |
| 2 | + |
| 3 | +2026-09-04. The batch described in |
| 4 | +`2026-09-04-named-runners-and-the-universal-command-surface.md` delivered four of |
| 5 | +its six numbered batches in full. This note covers what is left: two items that |
| 6 | +were in scope and were not finished, and two defects the batch surfaced and did |
| 7 | +not close. Section 1 is the root-cause work for the fourth, because the plan for |
| 8 | +it depends on the analysis. |
| 9 | + |
| 10 | +Each section states what is ESTABLISHED by measurement separately from what is |
| 11 | +INFERRED, because one of these four has a signature and no stack trace. |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## 1. Gap 4 analysed: the ninja that spins with nothing to do |
| 16 | + |
| 17 | +### 1.1 What is established |
| 18 | + |
| 19 | +Observed three times, in three separate fresh sandboxes, always on the `mcpp run` |
| 20 | +that follows a `mcpp build` in the same project, on `riscv64-none-elf` and |
| 21 | +`aarch64-none-elf`: |
| 22 | + |
| 23 | +| observation | reading | |
| 24 | +|---|---| |
| 25 | +| CPU | 99.9% of one core, sustained (utime 165168 ticks over 1629s elapsed) | |
| 26 | +| child processes | zero | |
| 27 | +| system time | zero | |
| 28 | +| `/proc/<pid>/syscall` | empty — the process is not in a syscall | |
| 29 | +| the graph | 8 edges, every declared output present, no missing input, no future mtime | |
| 30 | +| the same project on the host | builds and runs in about two seconds | |
| 31 | + |
| 32 | +A ninja that is neither running commands nor making system calls, on a graph |
| 33 | +whose outputs all exist, is not working. It is looping in its own scheduler. |
| 34 | + |
| 35 | +### 1.2 What that signature narrows it to |
| 36 | + |
| 37 | +ninja's build loop is, in shape: |
| 38 | + |
| 39 | +``` |
| 40 | +while (plan.more_to_do()) { |
| 41 | + edge = plan.FindWork(); |
| 42 | + if (edge) { StartEdge(edge); continue; } |
| 43 | + if (commands_running) WaitForCommand(); // a syscall |
| 44 | + else break; |
| 45 | +} |
| 46 | +``` |
| 47 | + |
| 48 | +Zero children and zero system time exclude both branches that leave user space. |
| 49 | +What remains is a loop in which `more_to_do()` stays true and `FindWork()` keeps |
| 50 | +returning an edge whose completion never decrements the outstanding count. For |
| 51 | +that to burn CPU without spawning anything, the edge must be one ninja finishes |
| 52 | +WITHOUT running a command. |
| 53 | + |
| 54 | +**In this graph exactly one edge finishes without a command: the phony.** |
| 55 | + |
| 56 | +``` |
| 57 | +build _mcpp_staged_cache : phony obj/…/riscv_virt_rt.m.o pcm.cache/….pcm |
| 58 | +build obj/main.cpp.ddi : cxx_scan …/src/main.cpp || _mcpp_staged_cache |
| 59 | +build obj/main.o : cxx_object …/src/main.cpp | obj/main.cpp.ddi.dd \ |
| 60 | + || _mcpp_staged_cache |
| 61 | +``` |
| 62 | + |
| 63 | +Its two inputs are `stage_file` edges, and `stage_file` is the one rule in mcpp's |
| 64 | +generated manifest whose command is REGULARLY SKIPPED while succeeding: |
| 65 | +`mcpp stage` does not write when the destination is already equivalent, which is |
| 66 | +the case the rule's `restat = 1` exists to exploit — a skipped stage must not |
| 67 | +dirty every importer of the staged BMI. |
| 68 | + |
| 69 | +So the shape present here is: **restat edges whose outputs do not change, feeding |
| 70 | +a phony, consumed as an order-only dependency.** That is the narrowest |
| 71 | +description of the suspect, and every part of it is mcpp's own construction |
| 72 | +rather than something a user wrote. |
| 73 | + |
| 74 | +### 1.3 What is inferred and not proven |
| 75 | + |
| 76 | +That this shape CAUSES the loop is inference, not measurement. Three attempts to |
| 77 | +obtain a stack all failed, and the failure of each is itself informative: |
| 78 | + |
| 79 | +* `gdb -p` is refused: `ptrace_scope = 1`, and the spinning ninja is not a |
| 80 | + descendant of any shell that can attach to it. |
| 81 | +* `perf record -p` is refused: `perf_event_paranoid = 4`. |
| 82 | +* Wrapping ninja so that gdb is its PARENT — which is legal under yama level 1 — |
| 83 | + makes the spin stop happening. Under gdb the same invocation ran seven |
| 84 | + subprocesses and exited normally. |
| 85 | + |
| 86 | +The last of those is the important one. It says the trigger is timing- or |
| 87 | +environment-sensitive rather than a pure function of the graph, which is |
| 88 | +consistent with a scheduler bookkeeping race and inconsistent with "this manifest |
| 89 | +always loops". |
| 90 | + |
| 91 | +Two further measurements bound the suspect from the other side: |
| 92 | + |
| 93 | +* Both ninja binaries, run 60 times each over the completed graph by hand, never |
| 94 | + hung and never took longer than 500ms. A no-op run of this manifest is not |
| 95 | + sufficient to trigger it. |
| 96 | +* The same project, built and then run on the host, completes in two seconds, |
| 97 | + and its `build.ninja` carries the same two `stage_file` edges and the same |
| 98 | + phony. The graph shape alone is not sufficient either. |
| 99 | + |
| 100 | +### 1.4 A separate defect found while looking, which is fully established |
| 101 | + |
| 102 | +**mcpp does not kill the ninja it spawned.** Every `timeout`-terminated `mcpp |
| 103 | +run` left an orphan spinning at 100% of a core. One of them outlived the removal |
| 104 | +of the entire sandbox it belonged to; its working directory read `(deleted)` and |
| 105 | +it was still burning a core half an hour later. |
| 106 | + |
| 107 | +This is deterministic, has nothing to do with the spin's cause, and makes the |
| 108 | +spin far more expensive than it would otherwise be: any CI that wraps mcpp in |
| 109 | +`timeout` leaks a busy core per timeout. |
| 110 | + |
| 111 | +### 1.5 A third finding, independent of both |
| 112 | + |
| 113 | +`xim:ninja@1.12.1` does not name one artefact. The descriptor promises |
| 114 | +xlings-res's glibc-static build (`ninja-1.12.1-linux-x86_64.tar.gz`); this host |
| 115 | +has that one, at 2202320 bytes, statically linked. Several sandboxes have a |
| 116 | +273768-byte dynamically linked binary built with GCC 4.8.5 on Red Hat — the |
| 117 | +shape and build date (2024-05-11, ninja 1.12.1's release day) of upstream's |
| 118 | +official `ninja-linux.zip`. |
| 119 | + |
| 120 | +Both answer `--version` with `1.12.1`, and their SHA-256 differ. Neither hung in |
| 121 | +the 120-run comparison above, so this is not offered as the cause. It is offered |
| 122 | +as a fact that has to be false before any conclusion about "ninja 1.12.1 |
| 123 | +behaves like X" can be trusted. |
| 124 | + |
| 125 | +--- |
| 126 | + |
| 127 | +## 2. Gap 1: openarch has no 32-bit machine with an address space |
| 128 | + |
| 129 | +### 2.1 Why the discovery is still owed |
| 130 | + |
| 131 | +The plan expected the first 32-bit backend to surface a width assumption: |
| 132 | +`arch_pte_make_leaf` returns `arch_u64`, and nothing had ever asked whether that |
| 133 | +is right on a machine whose page-table entry is 32 bits wide. |
| 134 | + |
| 135 | +Cortex-M arrived and did not settle it. M-profile has an MPU and no MMU, so the |
| 136 | +backend's `pte_impl.cpp` implements the group as functions that exist and refuse, |
| 137 | +and the package withholds the `openarch:address-space` capability so that a |
| 138 | +consumer needing an address space is refused at resolution rather than at run |
| 139 | +time. That is the correct design for that machine, and it means the width |
| 140 | +assumption was never executed. |
| 141 | + |
| 142 | +The machine that settles it is 32-bit AND has paging: ARMv7-A. |
| 143 | + |
| 144 | +### 2.2 Proposal |
| 145 | + |
| 146 | +Add `backends/armv7a` implementing all four groups, with the pte group backed by |
| 147 | +the ARMv7-A **short-descriptor** format, whose second-level entries are 32 bits. |
| 148 | + |
| 149 | +The deliverable is not the backend. **The deliverable is the answer to one |
| 150 | +question**: can a 32-bit page-table entry be expressed through an interface that |
| 151 | +types it as `arch_u64`? |
| 152 | + |
| 153 | +* If yes — the value fits, the extra bits are ignored, and no caller stores the |
| 154 | + return into a machine-width slot — then record that the interface is |
| 155 | + width-independent, with the ARMv7-A implementation as the evidence, and the |
| 156 | + question is closed rather than open. |
| 157 | +* If no — some caller or some table needs the machine's own width — then the |
| 158 | + interface changes now, while openarch is at 0.x and four backends exist to |
| 159 | + change together, rather than after a fifth consumer has depended on it. |
| 160 | + |
| 161 | +Either answer is worth the work; only the second changes the interface. |
| 162 | + |
| 163 | +### 2.3 Shape of the work |
| 164 | + |
| 165 | +* `backends/armv7a/` with `cpu_impl`, `context`, `trap_impl`, `pte_impl`. |
| 166 | +* mcpp already has the target rows (`armv7a-none-eabi`, `armv7a-none-eabihf`), |
| 167 | + the matrix cells and e2e `336_armv7a_builds_and_boots.sh`, so the toolchain |
| 168 | + side needs nothing. |
| 169 | +* qemu `-M virt -cpu cortex-a15` runs it; the existing e2e already boots an |
| 170 | + ARMv7-A image. |
| 171 | +* Release openarch 0.9.0. The `arch_trap_switch` signature is already frozen by |
| 172 | + four implementations, so this backend implements a settled interface. |
| 173 | + |
| 174 | +The one assertion that must exist: a test that builds a leaf entry, reads it |
| 175 | +back, and compares against a hand-written 32-bit descriptor. Without it the |
| 176 | +backend can be written to whatever the interface says and the width question |
| 177 | +stays unasked a second time. |
| 178 | + |
| 179 | +Independent of everything else here; blocks nothing. |
| 180 | + |
| 181 | +--- |
| 182 | + |
| 183 | +## 3. Gap 2: two board packages provision an emulator they may not run |
| 184 | + |
| 185 | +### 3.1 What is wrong |
| 186 | + |
| 187 | +`riscv-virt-rt` and `aarch64-virt-rt` declare |
| 188 | + |
| 189 | +```toml |
| 190 | +[xlings.workspace] |
| 191 | +"xim:qemu-riscv" = "9.2.4-1" |
| 192 | +``` |
| 193 | + |
| 194 | +which is the untiered form, meaning `ToolWhen::Always`: provisioned by every verb |
| 195 | +that builds. A CI job that compiles firmware and never runs it downloads a |
| 196 | +33 MB emulator. `cortex-m-rt` declares the tiered form and does not. |
| 197 | + |
| 198 | +The engine side of batch 2 shipped all four values (`build`, `run`, `dev`, and |
| 199 | +the implicit `always`); only the adoption is incomplete, and it is incomplete in |
| 200 | +the two packages that most obviously want `run`. |
| 201 | + |
| 202 | +### 3.2 Proposal |
| 203 | + |
| 204 | +```toml |
| 205 | +[xlings.workspace] |
| 206 | +"xim:qemu-riscv" = { version = "9.2.4-1", when = "run" } |
| 207 | +``` |
| 208 | + |
| 209 | +Release `riscv-virt-rt 0.7.1` and `aarch64-virt-rt 0.2.1`, one index entry each. |
| 210 | + |
| 211 | +The assertion goes in each package's CI and follows the criterion batch 2 already |
| 212 | +settled (§14.4 of the plan): **test what would be installed, not what was |
| 213 | +installed.** With `MCPP_NO_AUTO_INSTALL` set, a build must report that it needs |
| 214 | +no emulator and a run must report that it needs exactly one. That runs in seconds |
| 215 | +and needs no download, so it can sit in the ordinary build job rather than in a |
| 216 | +job that has an emulator. |
| 217 | + |
| 218 | +Both halves are load-bearing. "The build does not install qemu" is also true when |
| 219 | +the package is broken and installs nothing at all; the pair distinguishes them. |
| 220 | + |
| 221 | +Low risk, small, and it removes an inconsistency inside one batch. |
| 222 | + |
| 223 | +--- |
| 224 | + |
| 225 | +## 4. Gap 3: `mcpp run` folds every non-zero exit status to 1 |
| 226 | + |
| 227 | +### 4.1 The current contract |
| 228 | + |
| 229 | +Both spawn sites in `execute.cppm` end in `return rc == 0 ? 0 : 1`, and the |
| 230 | +comment states the intent: 2 means "could not start", 1 means "ran and failed", |
| 231 | +distinct from each other on purpose. |
| 232 | + |
| 233 | +Measured consequences: |
| 234 | + |
| 235 | +* Hosted: a program whose `main` returns 3 makes `mcpp run` exit 1. |
| 236 | +* Freestanding: the same image under qemu directly exits 3; through `mcpp run` |
| 237 | + it exits 1. |
| 238 | + |
| 239 | +So a program's own status is not observable through the command this ecosystem |
| 240 | +tells people to type. That matters more after this batch than before it, because |
| 241 | +the batch's claim is that running on a device is like running hosted, and a |
| 242 | +hosted `run` that cannot report a status is not that. |
| 243 | + |
| 244 | +### 4.2 Three options |
| 245 | + |
| 246 | +**A. Pass the child's status through; move mcpp's own failures to 125–127.** |
| 247 | +Borrows the convention `env`, `timeout` and `nice` already use and that shells |
| 248 | +document: 125 = the tool itself failed, 126 = found but not executable, 127 = not |
| 249 | +found. "Could not start" then lands on 126/127 by meaning rather than by |
| 250 | +allocation, and every value below 125 belongs to the program. |
| 251 | + |
| 252 | +Cost: the current 2 stops appearing from these two sites, which is a |
| 253 | +compatibility change for anything that tests for it. Scripts testing `!= 0` are |
| 254 | +unaffected. |
| 255 | + |
| 256 | +**B. Pass the child's status through; keep 2 for mcpp's own failures.** |
| 257 | +Smaller change, but a program that exits 2 becomes indistinguishable from a |
| 258 | +launcher failure — reintroducing exactly the ambiguity the current code was |
| 259 | +written to avoid. |
| 260 | + |
| 261 | +**C. Keep the current contract; expose the real status in the machine |
| 262 | +interface.** `mcpp run --format json` (or the existing NDJSON path) carries |
| 263 | +`exit_code`, and the human exit stays 0/1/2. |
| 264 | + |
| 265 | +Cost: the common case still cannot be scripted with `$?`, which is what people |
| 266 | +actually do. |
| 267 | + |
| 268 | +**Recommendation: A.** It is the only one of the three in which the obvious |
| 269 | +thing a user types produces the obvious thing they expect, and the convention it |
| 270 | +borrows is old enough that 126/127 will read correctly to anyone who meets them. |
| 271 | + |
| 272 | +### 4.3 What A costs to implement |
| 273 | + |
| 274 | +* Both sites in `execute.cppm`, plus `mcpp test`'s aggregation, which reports |
| 275 | + per-test status already and would report the true code. |
| 276 | +* `docs/11-machine-output.md` and its Chinese counterpart: the exit-status table |
| 277 | + is part of the machine interface contract and must state the new allocation. |
| 278 | + A published contract page that describes the old one is worse than none. |
| 279 | +* One e2e asserting a hosted program returning 3 gives 3, and one asserting a |
| 280 | + missing runner still gives 127 rather than 3. |
| 281 | + |
| 282 | +**This is the one item in this note that needs a decision before work starts.** |
| 283 | +The other three have a right answer; this one has a trade-off. |
| 284 | + |
| 285 | +--- |
| 286 | + |
| 287 | +## 5. Gap 4: what to do about the spin |
| 288 | + |
| 289 | +Split into two, because one half is established and the other is not. |
| 290 | + |
| 291 | +### 5.1 Stage one — fix what is proven, now |
| 292 | + |
| 293 | +**mcpp must terminate the ninja it spawned.** On POSIX, put the child in its own |
| 294 | +process group and signal the group; on Windows, a job object with |
| 295 | +`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` (the console-control-event route is already |
| 296 | +known to be wrong here — it reaches the whole console, which once took down an |
| 297 | +entire e2e runner with no `FAIL:` line to explain it). |
| 298 | + |
| 299 | +The criterion is not "the code calls kill". It is: **after mcpp is terminated |
| 300 | +mid-build, no ninja remains.** An e2e that starts a slow build, kills mcpp, waits, |
| 301 | +and then asserts that no ninja process survives — asserting on process state, not |
| 302 | +on a log line. |
| 303 | + |
| 304 | +This is worth doing whatever causes the spin, and it converts the spin from |
| 305 | +"leaks a core forever" into "one build hangs until its timeout". |
| 306 | + |
| 307 | +### 5.2 Stage two — instrument, then decide |
| 308 | + |
| 309 | +The one instrument that works on a process nobody can attach to is ninja's own |
| 310 | +`-d explain`, which prints its dirtiness reasoning as it goes. A spinning ninja |
| 311 | +that is re-deciding the same edge will emit a repeating line naming that edge, |
| 312 | +which either confirms §1.2's suspect or names a different one. |
| 313 | + |
| 314 | +Concretely: an `MCPP_NINJA_DEBUG` escape hatch that appends `-d explain` and |
| 315 | +captures the output, used to re-run the verification until it hangs again. The |
| 316 | +sandbox reproduces it about once per run, so a handful of runs suffices. |
| 317 | + |
| 318 | +Second instrument, if the machine's owner is willing: `ptrace_scope = 0` on the |
| 319 | +development box makes `gdb -p` work on the live spinner, which answers it in one |
| 320 | +attempt. |
| 321 | + |
| 322 | +### 5.3 The fix, if §1.2's suspect is confirmed |
| 323 | + |
| 324 | +The phony exists to give consumers one order-only token to depend on instead of |
| 325 | +listing every staged output. If it is implicated, the direct alternative is to |
| 326 | +drop it and attach the staged outputs to each consumer's order-only list |
| 327 | +directly. That costs a longer edge line per consumer and removes from the plan |
| 328 | +the only edge that completes without a command. |
| 329 | + |
| 330 | +Removing `restat = 1` from `stage_file` is the other obvious candidate and is |
| 331 | +the WORSE one: the rule's comment states what restat buys — a skipped stage must |
| 332 | +not dirty every importer of the staged BMI — and that benefit is real and |
| 333 | +measured. Do not trade it away for a defect whose cause is not yet established. |
| 334 | + |
| 335 | +### 5.4 Independent, and cheap |
| 336 | + |
| 337 | +Make `xim:ninja@1.12.1` name one artefact. Whichever build is intended, the |
| 338 | +descriptor and the payload should agree, and a machine that already has the other |
| 339 | +one should be able to tell. A single-line assertion — the installed binary's |
| 340 | +SHA-256 against the descriptor's — would have made §1.5 a report rather than a |
| 341 | +discovery. |
| 342 | + |
| 343 | +--- |
| 344 | + |
| 345 | +## 6. Order, and what needs an answer |
| 346 | + |
| 347 | +| # | item | depends on | needs a decision | |
| 348 | +|---|---|---|---| |
| 349 | +| 5.1 | mcpp kills its ninja | nothing | no | |
| 350 | +| 3 | two boards adopt `when = "run"` | nothing | no | |
| 351 | +| 5.4 | one artefact per ninja version | nothing | no | |
| 352 | +| 2 | openarch ARMv7-A backend | nothing | no | |
| 353 | +| 4 | `mcpp run` exit status | — | **yes, before work starts** | |
| 354 | +| 5.2 | instrument the spin | 5.1 landing first is convenient, not required | no | |
| 355 | +| 5.3 | change the staging graph | 5.2 | not until 5.2 answers | |
| 356 | + |
| 357 | +Everything except item 4 can start without further input. Item 4 is a |
| 358 | +compatibility decision about a published contract, and the recommendation in |
| 359 | +§4.2 is a recommendation rather than a conclusion. |
0 commit comments