Skip to content

Commit 93cf907

Browse files
committed
feat(device): runner gains three siblings, --locked asserts, and mcpp sbom
Four axes a project is asked about before it is adopted, and none of them had an answer: how the artefact reaches a device, whether the build is reproducible, what went into it, and whether an emulator and real silicon are one package or two. ## The device slots `run`, `flash`, `monitor` and `debug` are one shape — an argv the BOARD knows and a TOOL performs, addressed absolutely, with the artefact appended or substituted for `{}`. `runner` has carried that shape since 2026.8.19; three special-case commands would have carried it three more times. So the slot is the parameter: one directive-table row each, one reader, one CLI shape. ⚠️ What no argv can express is which of them ENDS. `run` and `flash` finish and hand back a verdict; `monitor` and `debug` have no natural end, so a live process is success for them and a hang for the other two. `Semantics` answers that from the slot, because `openocd -c "program … exit"` and `openocd -c "init"` are spelled alike up to the argument the board chose. `debug` starts a SERVER and stops there. The client is the user's debugger or their IDE, which reaches mcpp through docs/11; driving it would put mcpp in the middle of a session it has nothing to add to. ⚠️ `runner-exclusive` is the first thing a physical board needed that an emulator never did. `mcpp test` runs binaries on a worker pool; one probe on one device is a mutex, and two workers reaching for it do not fail — they interleave, and the verdict is about neither test. The board knows this about itself, so it says so once and no project remembers `-j1`. ## Emulator or hardware is a feature, not a fork A board reached through QEMU and the same board reached through a probe differ in the argv of their device slots and nothing else. Publishing two packages would duplicate a linker script, startup code and a module surface to vary four strings. `mcpp::has_feature()` already existed, so this needed no engine work at all — it is what the layering was for. e2e 333 builds one board package and drives it both ways. ## --locked The lock has always been written after resolution and never read back; its own header said so. This makes it an ASSERTION rather than a pin: the resolution that happens must equal the one recorded, and a difference names the package that moved and both versions. That is the half reproducibility needs first, and it is what Cargo's flag of the same name means. ⚠️⚠️ And it must not meet the fast path. Measured before that guard existed: a deliberately corrupted lock passed `mcpp build --locked` and printed "Finished" — the flag accepted, the build correct, the assertion never run. A criterion that is skipped is worse than one that is absent, because the green reads as a verification. ## mcpp sbom CycloneDX 1.5 over the recorded resolution. Everything a bill of materials names is already in mcpp.lock, so this is an output format rather than a mechanism: it resolves nothing and asks the network for nothing. ⚠️ It reads the lock rather than re-resolving, which is the one property such a document must have — an SBOM describing a different graph from the one that was built is worse than none. Asserted in e2e 333 by editing the lock and checking the output follows it. An unknown licence is emitted as NOASSERTION rather than omitted: an absent key reads as "not examined", and a reviewer cannot filter on silence. ## Two propagation sites, and the one that was missed first Dependency-supplied RunGlobal entries reach the root through a different path from a package's own directives. Wiring only `apply()` left `mcpp flash` reporting "no flash is configured" while `mcpp run` found the runner the same build program emitted three lines away — measured. Both sites now iterate the slot table instead of naming `runner`. Protocol version 6. 97/97 unit tests; freestanding e2e 130-139 and 332 green.
1 parent fd1a539 commit 93cf907

12 files changed

Lines changed: 937 additions & 42 deletions

File tree

modules/buildmcpp/src/directives.cppm

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,24 @@ enum class Slot : std::size_t {
7676
// is neither a compile input nor a link input, and putting it in LdFlags
7777
// would put an emulator's argv on the linker command line.
7878
Runner,
79+
// ⭐⭐ THE THREE SIBLINGS OF `Runner`, AND THE COLUMN THAT SEPARATES THEM.
80+
//
81+
// Writing an artefact to a device, watching what it prints and attaching a
82+
// debugger have `Runner`'s shape exactly: an argv the BOARD knows and a
83+
// TOOL performs. They are slots for the same reason `Runner` is one — an
84+
// emulator's argv is neither a compile input nor a link input.
85+
//
86+
// ⚠️ WHAT NO ARGV CAN SAY IS WHICH ONE ENDS. `Runner` and `Flash` finish
87+
// and hand back an exit code; `Monitor` and `Debug` do not terminate on
88+
// their own, so for them a live process IS the success condition and for
89+
// the other two it is a hang. `semantics_of` below answers that from the
90+
// SLOT, because the tokens cannot.
91+
Flash,
92+
Monitor,
93+
Debug,
94+
// Not an argv at all: a board stating that it is a mutex. See
95+
// `BuildConfig::runnerExclusive`.
96+
RunnerExclusive,
7997
CxxFlags,
8098
CFlags,
8199
LdFlags,
@@ -104,6 +122,51 @@ enum class Slot : std::size_t {
104122
};
105123
inline constexpr std::size_t kSlotCount = static_cast<std::size_t>(Slot::Count);
106124

125+
// ⭐⭐ HOW A DEVICE ACTION'S PROCESS ENDS, WHICH IS A PROPERTY OF THE SLOT.
126+
//
127+
// The four device slots share an argv shape and differ in exactly one way that
128+
// the engine has to act on: whether the process is expected to terminate.
129+
//
130+
// OneShot `run`, `flash` — runs to completion; the exit code is the verdict
131+
// LongLived `monitor`, `debug` — has no natural end; the operator ends it,
132+
// and a non-zero status after Ctrl-C is that, not a failure
133+
//
134+
// ⚠️ NO TOKEN IN THE TEMPLATE CARRIES THIS. `openocd -c "program {} verify
135+
// reset exit"` terminates and `openocd -c "init"` does not, and both are
136+
// spelled the same way up to the argument the board chose. So it is read from
137+
// the slot, and a board cannot get it wrong by writing its argv differently.
138+
//
139+
// ⚠️ AND `debug` IS `LongLived` RATHER THAN A THIRD VALUE. It starts a GDB
140+
// SERVER; the client that attaches to it is the user's debugger or their IDE,
141+
// which reaches mcpp through the machine-output protocol (docs/11) and not
142+
// through this table. Driving the client would put mcpp in the middle of a
143+
// session it has nothing to add to.
144+
enum class Semantics { OneShot, LongLived };
145+
146+
inline constexpr Semantics semantics_of(Slot s) {
147+
return (s == Slot::Monitor || s == Slot::Debug) ? Semantics::LongLived
148+
: Semantics::OneShot;
149+
}
150+
151+
// The device slots, in the order a user meets them. Iterated rather than
152+
// hand-listed wherever all four must be handled, so a fifth cannot be added to
153+
// one site and missed at another.
154+
inline constexpr Slot kDeviceSlots[] = { Slot::Runner, Slot::Flash,
155+
Slot::Monitor, Slot::Debug };
156+
157+
// The user-facing name of a device slot: the `mcpp <name>` subcommand, the
158+
// `[target.<triple>].<name>` key and the `mcpp:<name>=` directive are all this
159+
// one string, which is why it has a single read point.
160+
inline constexpr std::string_view device_slot_name(Slot s) {
161+
switch (s) {
162+
case Slot::Runner: return "runner";
163+
case Slot::Flash: return "flash";
164+
case Slot::Monitor: return "monitor";
165+
case Slot::Debug: return "debug";
166+
default: return {};
167+
}
168+
}
169+
107170
// Who sees the value. The field that must be answered for every new directive.
108171
enum class Scope {
109172
PackagePrivate, // only this package's own TUs — never propagated to consumers
@@ -166,7 +229,7 @@ struct Def {
166229
int sinceProtocol;
167230
};
168231

169-
inline constexpr std::array<Def, 16> kTable{{
232+
inline constexpr std::array<Def, 20> kTable{{
170233
// wire tag slot scope transform must missingPrefix missingSuffix since
171234
{"cxxflag", "cxxflag", Slot::CxxFlags, Scope::PackagePrivate, Transform::Verbatim, false, "", "", 1},
172235
{"cflag", "cflag", Slot::CFlags, Scope::PackagePrivate, Transform::Verbatim, false, "", "", 1},
@@ -212,6 +275,10 @@ inline constexpr std::array<Def, 16> kTable{{
212275
// OWNER home — measured in CI as `xlings: '…' is not installed` from a job
213276
// where the same name had answered `--version` two steps earlier.
214277
{"runner", "runner", Slot::Runner, Scope::RunGlobal, Transform::Verbatim, false, "", "", 4},
278+
{"flash", "flash", Slot::Flash, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6},
279+
{"monitor", "monitor", Slot::Monitor, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6},
280+
{"debug", "debug", Slot::Debug, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6},
281+
{"runner-exclusive", "runner-exclusive", Slot::RunnerExclusive, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6},
215282
{"link-script", "ldflag", Slot::LdFlags, Scope::LinkGlobal, Transform::LinkerScript, false, "", "", 3},
216283
{"include-dir", "include-dir", Slot::IncludeDirs, Scope::PackagePrivate, Transform::AbsPath, false, "", "", 1},
217284
{"include-dir-after", "include-dir-after", Slot::IncludeDirsAfter, Scope::PackagePrivate, Transform::AbsPath, false, "", "", 1},
@@ -668,13 +735,24 @@ void apply(mcpp::manifest::Manifest& m, const Directives& d) {
668735
auto const& c = d.at(Slot::CFlags);
669736
auto const& ld = d.at(Slot::LdFlags);
670737
auto const& runner = d.at(Slot::Runner);
738+
auto const& flash = d.at(Slot::Flash);
739+
auto const& monitor = d.at(Slot::Monitor);
740+
auto const& debugTpl = d.at(Slot::Debug);
671741
auto const& defines = d.at(Slot::Defines);
672742

673743
bc.cxxflags.insert(bc.cxxflags.end(), cxx.begin(), cxx.end());
674744
bc.cflags.insert(bc.cflags.end(), c.begin(), c.end());
675745
bc.ldflags.insert(bc.ldflags.end(), ld.begin(), ld.end());
676746
// Appended in emission order — the tokens ARE the argv.
677747
bc.runner.insert(bc.runner.end(), runner.begin(), runner.end());
748+
bc.flash.insert(bc.flash.end(), flash.begin(), flash.end());
749+
bc.monitor.insert(bc.monitor.end(), monitor.begin(), monitor.end());
750+
bc.debugger.insert(bc.debugger.end(), debugTpl.begin(), debugTpl.end());
751+
// ⚠️ ANY non-empty value sets it, and there is deliberately no way to unset
752+
// it from a second package. Exclusivity is a claim about the DEVICE: if one
753+
// package in the graph knows the target is a mutex, it is one, and a later
754+
// package saying nothing must not relax that.
755+
if (!d.at(Slot::RunnerExclusive).empty()) bc.runnerExclusive = true;
678756
// cfg defines colour BOTH language channels — the one slot that fans out.
679757
bc.cflags.insert(bc.cflags.end(), defines.begin(), defines.end());
680758
bc.cxxflags.insert(bc.cxxflags.end(), defines.begin(), defines.end());

modules/buildmcpp/src/program_protocol.cppm

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,12 @@ export namespace mcpp::build::program_protocol {
5151
// COMPILE, because the bundled module that engine ships has no such function.
5252
// That is the same cost `link-script` carried into v3 and is stated here so
5353
// the next reader does not look for a protocol path that never runs.
54-
inline constexpr int kProtocolVersion = 5;
54+
// v6: adds `flash`, `monitor`, `debug` and `runner-exclusive` — `runner`'s
55+
// three siblings and the claim that a device admits one user at a time. Same
56+
// cost as v5's: a package calling `mcpp::flash()` fails on an older engine at
57+
// the build.mcpp COMPILE, because that engine's bundled module has no such
58+
// function, not through a protocol refusal.
59+
inline constexpr int kProtocolVersion = 6;
5560

5661
// ── Cache-format epoch ─────────────────────────────────────────────────────
5762
//

modules/manifest/src/toml.cppm

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2142,24 +2142,43 @@ std::expected<Manifest, ManifestError> parse_string(std::string_view content,
21422142
// artifact cannot execute here. An ARRAY, so it is neither a
21432143
// scalar (the unknown-key sweep below skips it by type) nor part
21442144
// of the conditional sub-table channel.
2145-
if (auto it = body.find("runner"); it != body.end()) {
2145+
//
2146+
// ⭐ FOUR KEYS, ONE LOOP. `runner` was alone until `flash`,
2147+
// `monitor` and `debug` joined it, and they are the same key in
2148+
// every respect a parser can see: an array of strings, empty is an
2149+
// error, non-strings are an error. Writing the second one out by
2150+
// hand is how the third and fourth acquire slightly different
2151+
// diagnostics.
2152+
struct DeviceKey { std::string_view name; std::vector<std::string> TargetEntry::*into; };
2153+
static constexpr std::string_view kExample =
2154+
"[\"qemu-system-riscv64\", \"-kernel\"]";
2155+
const DeviceKey kDeviceKeys[] = {
2156+
{ "runner", &TargetEntry::runner },
2157+
{ "flash", &TargetEntry::flash },
2158+
{ "monitor", &TargetEntry::monitor },
2159+
{ "debug", &TargetEntry::debugger },
2160+
};
2161+
for (auto const& dk : kDeviceKeys) {
2162+
auto it = body.find(std::string(dk.name));
2163+
if (it == body.end()) continue;
21462164
if (!it->second.is_array()) {
21472165
return std::unexpected(error(origin, std::format(
2148-
"[target.{}].runner must be an array of strings, "
2149-
"e.g. runner = [\"qemu-system-riscv64\", \"-kernel\"]",
2150-
triple)));
2166+
"[target.{}].{} must be an array of strings, e.g. {} = {}",
2167+
triple, dk.name, dk.name, kExample)));
21512168
}
2169+
auto& dest = e.*(dk.into);
21522170
for (auto& el : it->second.as_array()) {
21532171
if (!el.is_string()) {
21542172
return std::unexpected(error(origin, std::format(
2155-
"[target.{}].runner must contain only strings", triple)));
2173+
"[target.{}].{} must contain only strings",
2174+
triple, dk.name)));
21562175
}
2157-
e.runner.push_back(el.as_string());
2176+
dest.push_back(el.as_string());
21582177
}
2159-
if (e.runner.empty()) {
2178+
if (dest.empty()) {
21602179
return std::unexpected(error(origin, std::format(
2161-
"[target.{}].runner is empty — an empty template would "
2162-
"run nothing and report success", triple)));
2180+
"[target.{}].{} is empty — an empty template would do "
2181+
"nothing and report success", triple, dk.name)));
21632182
}
21642183
}
21652184

@@ -2195,7 +2214,9 @@ std::expected<Manifest, ManifestError> parse_string(std::string_view content,
21952214
static constexpr std::string_view kKnownTargetScalars[] = {
21962215
"cxx_runtime", "linkage", "sysroot", "toolchain",
21972216
};
2198-
static constexpr std::string_view kKnownTargetArrays[] = { "runner" };
2217+
static constexpr std::string_view kKnownTargetArrays[] = {
2218+
"debug", "flash", "monitor", "runner",
2219+
};
21992220
for (auto& [key, value] : body) {
22002221
if (value.is_table()) continue; // the conditional channel
22012222
const std::span<const std::string_view> known = value.is_array()
@@ -2204,7 +2225,8 @@ std::expected<Manifest, ManifestError> parse_string(std::string_view content,
22042225
if (std::ranges::find(known, key) != known.end()) continue;
22052226
m.schemaWarnings.push_back(std::format(
22062227
"[target.{}] has unsupported key '{}' (ignored). Supported keys: "
2207-
"cxx_runtime, linkage, runner, sysroot, toolchain. "
2228+
"cxx_runtime, debug, flash, linkage, monitor, runner, sysroot, "
2229+
"toolchain. "
22082230
"Per-role contracts go in [build].cxx_runtime's table form.",
22092231
triple, key));
22102232
}

modules/manifest/src/types.cppm

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,31 @@ struct BuildConfig : BuildInputs {
456456
// argv that is neither one's, and it would fail at exec time with no
457457
// indication of which package contributed which token.
458458
std::vector<std::string> runner;
459+
// ⭐⭐ THE THREE SIBLINGS OF `runner`, AND WHY THEY ARE SLOTS RATHER THAN
460+
// COMMANDS.
461+
//
462+
// Executing an artifact, writing it to a device, watching what it prints
463+
// and attaching a debugger are one shape: an argv that the BOARD knows and
464+
// a TOOL performs, addressed by absolute path, with the artifact appended
465+
// or substituted for `{}`. `runner` has carried that shape since 2026.8.19;
466+
// three special-case commands would have carried it three more times.
467+
//
468+
// ⚠️ THEY ARE NOT INTERCHANGEABLE, AND THE DIFFERENCE IS THE PROCESS AND
469+
// NOT THE ARGV. `run` and `flash` finish and report an exit code; `monitor`
470+
// and `debug` do not end on their own, so "the process is still alive" is
471+
// success for them and a hang for the other two. That is `Semantics`, which
472+
// the engine reads from the slot rather than from the tokens — no argv can
473+
// say which of the two it is.
474+
std::vector<std::string> flash;
475+
std::vector<std::string> monitor;
476+
std::vector<std::string> debugger;
477+
// ⚠️ ONE BOARD IS A MUTEX, AND NOTHING ELSE IN THE BUILD IS.
478+
//
479+
// `mcpp test` runs test binaries on a pool of workers. An emulator takes
480+
// N instances happily; a physical board takes one, and two probes reaching
481+
// for the same device do not fail — they interleave. The board knows this
482+
// about itself, so it says so, and a project never has to remember `-j1`.
483+
bool runnerExclusive = false;
459484

460485
// Was `sources` WRITTEN, as opposed to merely being empty?
461486
//
@@ -819,6 +844,13 @@ struct TargetEntry {
819844
// engine a different board has to fight. The artifact path is appended, or
820845
// substituted for `{}` when the template contains it.
821846
std::vector<std::string> runner;
847+
// The project's override for each of `runner`'s siblings, on the same axis
848+
// and with the same precedence: what the author of THIS project wrote beats
849+
// what a dependency supplied, and the override is reported rather than
850+
// applied in silence.
851+
std::vector<std::string> flash;
852+
std::vector<std::string> monitor;
853+
std::vector<std::string> debugger;
822854
// #336 — per-target C++ runtime contract, same vocabulary as
823855
// [build].cxx_runtime and overriding it for this triple. It lives HERE,
824856
// beside `linkage`, rather than in the `cfg(...)` conditional channel:

0 commit comments

Comments
 (0)