Skip to content

Commit 59bb1a9

Browse files
committed
docs(plan): 生态批次之后剩下的四个缺口与各自的方案
Gap 4 is analysed first because the plan for it depends on the analysis. The signature — 99.9% of one core, zero children, zero system time, on a graph whose outputs all exist — excludes both branches of ninja's build loop that leave user space, which narrows it to an edge that finishes WITHOUT running a command. This graph has exactly one: the phony fed by the two `restat` staging edges, whose command is regularly skipped while succeeding. That the shape causes the loop remains inference. Three attempts at a stack all failed, and the third is informative: making gdb ninja's parent, which is legal under yama level 1, stops the spin happening at all. Two findings from the same investigation are fully established and independent: mcpp does not kill the ninja it spawned (one orphan outlived the removal of its whole sandbox), and `xim:ninja@1.12.1` names two different binaries. The other three gaps get proposals. Only `mcpp run`'s exit status needs a decision before work starts — it is a compatibility change to a published contract, and the note recommends rather than concludes.
1 parent 1c2603c commit 59bb1a9

1 file changed

Lines changed: 359 additions & 0 deletions

File tree

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
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

Comments
 (0)