Skip to content

Commit be0beee

Browse files
committed
run/test: honour the declared runner on every target; --no-runner; not-run reporting with exit 2; e2e 330 (#544)
1 parent 7501e50 commit be0beee

6 files changed

Lines changed: 534 additions & 98 deletions

File tree

‎src/build/execute.cppm‎

Lines changed: 301 additions & 84 deletions
Large diffs are not rendered by default.

‎src/build/prepare.cppm‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,15 @@ export struct BuildContext {
663663
// derivation would drift from the first exactly when a resolution rule
664664
// changes. Written into `.build_cache`; see BuildCacheEntry::depSourceRoots.
665665
std::vector<std::filesystem::path> depSourceRoots;
666+
// `<payload>/bin` of every installed `[xlings] deps` payload of the
667+
// runtime-owner manifest, in declaration order (#544). Read by
668+
// choose_runner's lookup (mcpp.build.runner_lookup) so a runner may name
669+
// a program the project declared without writing the payload's
670+
// home-and-version path into the manifest. Computed by the same
671+
// resolution `fillXpkgDirs` uses for build programs; a payload that is
672+
// declared but not installed contributes nothing, and the lookup then
673+
// continues to PATH.
674+
std::vector<std::filesystem::path> xlingsDepBinDirs;
666675
std::filesystem::path outputDir;
667676
std::filesystem::path stdBmi;
668677
std::filesystem::path stdObject;
@@ -8698,6 +8707,20 @@ prepare_build(bool print_fingerprint,
86988707
}
86998708
ctx.depSourceRoots = std::move(roots);
87008709
}
8710+
// Where a runner may find the programs this project declared (#544). The
8711+
// same resolution `fillXpkgDirs` hands to build programs, kept as
8712+
// directories rather than env vars because the reader is mcpp's own
8713+
// lookup, not a child process. See BuildContext::xlingsDepBinDirs.
8714+
if (!runtimeOwnerManifest.xlings.deps.empty()) {
8715+
if (auto cfg = get_cfg()) {
8716+
auto xlEnv = mcpp::config::make_xlings_env(**cfg);
8717+
for (auto const& spec : runtimeOwnerManifest.xlings.deps) {
8718+
auto ref = mcpp::xlings::paths::parse_xpkg_ref(spec);
8719+
if (auto dir = mcpp::xlings::paths::xpkg_payload(xlEnv, ref))
8720+
ctx.xlingsDepBinDirs.push_back(*dir / "bin");
8721+
}
8722+
}
8723+
}
87018724
// ─── Prebuilt dependencies: check before planning to link them ─────
87028725
//
87038726
// Here rather than at each place a dependency manifest is loaded, because

‎src/build/runner_lookup.cppm‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,12 @@ inline std::string errno_text(int e) {
101101

102102
inline std::string not_found_message(std::string_view triple, std::string_view argv0,
103103
std::span<const std::filesystem::path> searched) {
104+
// The first line stands on its own: `mcpp test` repeats it in its summary.
104105
std::string dirs;
105106
for (auto const& d : searched) dirs += "\n " + d.string();
106107
return std::format(
107-
"runner '{}' for '{}' was not found. Searched:{}\n"
108+
"runner '{}' for '{}' was not found on any search path.\n"
109+
" Searched:{}\n"
108110
" Declare the package that provides it under [xlings] deps, or "
109111
"install it on PATH.\n"
110112
" Pass --no-runner to execute the artifact directly on this host.",

‎src/cli.cppm‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ void print_usage() {
5959
std::println(" mcpp new <name> Create a new package skeleton");
6060
std::println(" mcpp build [options] Build the current package");
6161
std::println(" mcpp run [target] [-- args...] Build + run a binary target");
62-
std::println(" mcpp test [pattern] [-- args...] Build + run tests/**/*.cpp (--list, --timeout, --build-timeout, --message-format json)");
62+
std::println(" mcpp test [pattern] [-- args...] Build + run tests/**/*.cpp (--list, --timeout, --build-timeout, --message-format json, --no-runner)");
6363
std::println(" mcpp clean [--bmi-cache] Remove target/ (and optionally the build cache)");
6464
std::println(" mcpp add [ns.]pkg@ver Add an exact dependency to mcpp.toml");
6565
std::println(" mcpp remove [ns.]pkg Remove an exact dependency from mcpp.toml");
@@ -391,6 +391,8 @@ int run(int argc, char** argv) {
391391
.help("Global dependency cache: global (default) | local | off"))
392392
.option(cl::Option("no-cache")
393393
.help("Deprecated alias for --cache=off (also clears the build dir)"))
394+
.option(cl::Option("no-runner")
395+
.help("Execute the artifact directly, ignoring any [target.<triple>].runner (a host that runs it natively)"))
394396
.action(wrap_rc([&passthrough](const cl::ParsedArgs& p) {
395397
return cmd_run(p, std::span<const std::string>(passthrough));
396398
})))
@@ -404,6 +406,8 @@ int run(int argc, char** argv) {
404406
.help("Output format: human (default) | json (NDJSON, one record per test)"))
405407
.option(cl::Option("list")
406408
.help("List (filtered) tests without building or running them"))
409+
.option(cl::Option("no-runner")
410+
.help("Run test binaries directly, ignoring any [target.<triple>].runner (a host that runs them natively)"))
407411
.option(cl::Option("timeout").takes_value().value_name("SECS")
408412
.help("Kill a test still RUNNING after SECS seconds (default 300; 0 = no limit)"))
409413
.option(cl::Option("build-timeout").takes_value().value_name("SECS")

‎src/cli/cmd_build.cppm‎

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,12 @@ export int cmd_run(const mcpplibs::cmdline::ParsedArgs& parsed,
213213
std::string target_triple;
214214
if (auto tt = parsed.value("target")) target_triple = *tt;
215215
if (auto tt = parsed.value("target-triple")) target_triple = *tt;
216+
// --no-runner: "this host can execute the artifact" is a fact about the
217+
// host, and the manifest has no host axis to state it on (#544, D3).
218+
const bool no_runner = parsed.is_flag_set("no-runner");
216219
return mcpp::build::build_run_target(targetName, passthrough, package_filter,
217-
cache_mode, no_cache, target_triple);
220+
cache_mode, no_cache, target_triple,
221+
no_runner);
218222
}
219223

220224
export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed,
@@ -238,6 +242,7 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed,
238242
mcpp::build::TestOptions to;
239243
if (parsed.positional_count() > 0) to.filter = parsed.positional(0);
240244
to.list = parsed.is_flag_set("list");
245+
to.noRunner = parsed.is_flag_set("no-runner"); // see cmd_run
241246
// The three deadlines share one parser: they differ only in what they
242247
// bound, not in how they are spelled. 0 always means "no limit" — for
243248
// --timeout that now has to be asked for rather than being the default.
@@ -280,9 +285,10 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed,
280285

281286
int rc = 0;
282287
std::vector<std::string> failed;
283-
std::vector<std::string> notRun;
288+
std::vector<std::string> notRun; // --workspace-timeout reached
289+
std::vector<std::string> unrunnable; // tests built, none executed (#544)
284290
std::vector<std::pair<std::string, long long>> memberTimes;
285-
int totalPassed = 0, totalFailed = 0;
291+
int totalPassed = 0, totalFailed = 0, totalNotRun = 0;
286292
auto tWs = std::chrono::steady_clock::now();
287293
auto ws_ms = [&tWs] {
288294
return std::chrono::duration_cast<std::chrono::milliseconds>(
@@ -309,9 +315,19 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed,
309315
int r = mcpp::build::run_tests(passthrough, mo, to, &sum);
310316
totalPassed += sum.passed;
311317
totalFailed += sum.failed;
318+
totalNotRun += sum.notRun;
312319
memberTimes.emplace_back(mp, sum.elapsedMs);
313320
auto secs = static_cast<double>(sum.elapsedMs) / 1000.0;
314-
if (r != 0) {
321+
if (r == 2 && sum.failed == 0 && sum.notRun > 0) {
322+
// Built and not executed (#544): the member did not fail, and
323+
// it did not pass. 2 outranks 0 and yields to 1, as it does
324+
// for a single member.
325+
if (rc == 0) rc = 2;
326+
unrunnable.push_back(mp);
327+
mcpp::ui::status("Workspace",
328+
std::format("member '{}' ({}/{}) NOT RUN — {} passed, {} not run in {:.2f}s",
329+
mp, idx, members->size(), sum.passed, sum.notRun, secs));
330+
} else if (r != 0) {
315331
rc = r;
316332
failed.push_back(mp);
317333
mcpp::ui::status("Workspace",
@@ -344,10 +360,16 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed,
344360
}
345361
return s;
346362
};
363+
// `not_run` keeps its meaning (members the --workspace-timeout
364+
// stopped before they started); `tests_not_run` and
365+
// `unrunnable_members` are #544's — tests that were built and not
366+
// executed, and the members all of whose tests were.
347367
std::println("{{\"workspace_summary\":{{\"members\":{},\"passed\":{},\"failed\":{},"
348-
"\"failed_members\":[{}],\"not_run\":[{}],\"elapsed_ms\":{}}}}}",
349-
members->size(), totalPassed, totalFailed,
350-
join(failed), join(notRun), wsElapsed);
368+
"\"tests_not_run\":{},"
369+
"\"failed_members\":[{}],\"unrunnable_members\":[{}],"
370+
"\"not_run\":[{}],\"elapsed_ms\":{}}}}}",
371+
members->size(), totalPassed, totalFailed, totalNotRun,
372+
join(failed), join(unrunnable), join(notRun), wsElapsed);
351373
std::fflush(stdout);
352374
return rc;
353375
}
@@ -370,19 +392,27 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed,
370392
for (auto& f : v) { if (!s.empty()) s += ", "; s += f; }
371393
return s;
372394
};
373-
if (failed.empty() && notRun.empty())
395+
// The not-run count is in the line whenever it is non-zero, at the
396+
// same weight as the failure count (#544): a member whose tests were
397+
// built and not executed must not read as a passing member.
398+
std::string notRunCounts = totalNotRun
399+
? std::format("; {} not run", totalNotRun) : std::string{};
400+
if (failed.empty() && notRun.empty() && unrunnable.empty())
374401
mcpp::ui::status("workspace result",
375-
std::format("ok. {} member(s); {} passed; 0 failed; finished in {:.2f}s",
376-
members->size(), totalPassed,
402+
std::format("ok. {} member(s); {} passed; 0 failed{}; finished in {:.2f}s",
403+
members->size(), totalPassed, notRunCounts,
377404
static_cast<double>(wsElapsed) / 1000.0));
378405
else
379406
mcpp::ui::error(std::format(
380-
"workspace test: {}/{} member(s) failed; {} passed; {} failed; "
407+
"workspace test: {}/{} member(s) failed; {} passed; {} failed{}; "
381408
"finished in {:.2f}s",
382-
failed.size(), members->size(), totalPassed, totalFailed,
409+
failed.size(), members->size(), totalPassed, totalFailed, notRunCounts,
383410
static_cast<double>(wsElapsed) / 1000.0));
384411
if (!failed.empty())
385412
mcpp::ui::plain(std::format(" failed members: {}", join_names(failed)));
413+
if (!unrunnable.empty())
414+
mcpp::ui::plain(std::format(" not run (no runner on this host): {}",
415+
join_names(unrunnable)));
386416
if (!notRun.empty())
387417
mcpp::ui::plain(std::format(
388418
" not run (--workspace-timeout {}s reached): {}",
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
#!/usr/bin/env bash
2+
# requires: unix-shell
3+
# 330_runner_hosted_targets.sh — `[target.<triple>].runner` on a hosted target,
4+
# and what `mcpp run` / `mcpp test` say when this host cannot execute an
5+
# artifact (#544).
6+
#
7+
# Design: .agents/docs/2026-09-02-runner-beyond-baremetal-design.md, §11 lists
8+
# the criteria this file implements. Two properties of the setup carry the
9+
# whole test:
10+
#
11+
# 1. No emulator is needed and none is used. The runner is a shell script
12+
# that records its argv and then executes it, so "the artifact went
13+
# through the runner" is asserted on the exact argv, not on a message.
14+
# 2. The "artifact this host cannot execute" is a real host binary whose ELF
15+
# e_machine is patched to 0xffff AFTER the build. No binfmt_misc entry
16+
# and no native loader accepts it, so posix_spawnp answers ENOEXEC on
17+
# every Linux host — one with qemu-user registered included — and the
18+
# criterion does not depend on which machine runs it.
19+
#
20+
# `requires: unix-shell` and nothing else, on purpose: a `requires: gcc` or
21+
# `requires: llvm` guard skips on both CI shards, and a skip exits 0.
22+
# The ELF half is Linux-only (macOS artifacts are Mach-O); the runner half
23+
# runs on every POSIX host.
24+
set -uo pipefail
25+
26+
TMP=$(mktemp -d)
27+
trap "rm -rf $TMP" EXIT
28+
cd "$TMP"
29+
30+
fail() { echo "FAIL: $*"; exit 1; }
31+
MCPP="${MCPP:?set MCPP to the mcpp binary under test}"
32+
33+
"$MCPP" new app >/dev/null 2>&1 || fail "mcpp new"
34+
cd app
35+
mkdir -p tests
36+
rm -f tests/*.cpp # the scaffold's own smoke test; the counts below are exact
37+
cat > src/main.cpp <<'EOF'
38+
#include <cstdio>
39+
int main() { std::puts("ARTIFACT-RAN"); return 0; }
40+
EOF
41+
cat > tests/one.cpp <<'EOF'
42+
int main() { return 0; }
43+
EOF
44+
45+
# The host triple as the manifest spells it. Read from the engine rather than
46+
# guessed: `[target.<triple>]` is matched against the resolved target.
47+
out=$("$MCPP" build 2>&1) || fail "initial build: $out"
48+
HOST=$(sed -n 's/.*Target \([^ ]*\) → .*/\1/p' <<<"$out" | head -1)
49+
[[ -n "$HOST" ]] || HOST=$(ls target | head -1)
50+
[[ -n "$HOST" ]] || fail "could not determine the host triple from: $out"
51+
52+
cat > "$TMP/runner.sh" <<'EOF'
53+
#!/bin/sh
54+
printf '%s\n' "$@" >> "$RUNNER_LOG"
55+
exec "$@"
56+
EOF
57+
chmod +x "$TMP/runner.sh"
58+
export RUNNER_LOG="$TMP/runner.log"
59+
60+
# ── 1. a declared runner is used on a hosted target, on both `run` doors ────
61+
printf '\n[target.%s]\nrunner = ["%s"]\n' "$HOST" "$TMP/runner.sh" >> mcpp.toml
62+
out=$("$MCPP" run 2>&1) || fail "run through runner: $out"
63+
grep -q "ARTIFACT-RAN" <<<"$out" || fail "artifact output missing: $out"
64+
[[ -s "$RUNNER_LOG" ]] || fail "runner was not invoked (declared under [target.$HOST]): $out"
65+
exe=$(head -1 "$RUNNER_LOG")
66+
[[ "$exe" == */bin/app ]] || fail "runner argv[0] is not the artifact: $exe"
67+
: > "$RUNNER_LOG"
68+
out=$("$MCPP" run 2>&1) || fail "second run (fast path): $out"
69+
[[ -s "$RUNNER_LOG" ]] || fail "the run fast path bypassed the declared runner: $out"
70+
71+
# ── 2. --no-runner executes directly and says so ───────────────────────────
72+
: > "$RUNNER_LOG"
73+
out=$("$MCPP" run --no-runner 2>&1) || fail "--no-runner: $out"
74+
grep -q "ARTIFACT-RAN" <<<"$out" || fail "--no-runner lost the program output: $out"
75+
grep -q "no-runner" <<<"$out" || fail "--no-runner printed no note: $out"
76+
[[ ! -s "$RUNNER_LOG" ]] || fail "--no-runner still invoked the runner"
77+
78+
# ── 3. runner not found: names the program and the search list, exit 2 ─────
79+
sed -i.bak "s|^runner = .*|runner = [\"mcpp-e2e-no-such-runner\"]|" mcpp.toml
80+
out=$("$MCPP" run 2>&1); rc=$?
81+
[[ $rc -eq 2 ]] || fail "missing runner: exit $rc, want 2: $out"
82+
grep -q "runner 'mcpp-e2e-no-such-runner' for '$HOST' was not found" <<<"$out" \
83+
|| fail "missing runner not named: $out"
84+
grep -q "Searched:" <<<"$out" || fail "search list missing: $out"
85+
grep -q "ARTIFACT-RAN" <<<"$out" && fail "artifact ran without its runner: $out"
86+
# `mcpp test`, one test: every test is not-run with that reason, exit 2.
87+
out=$("$MCPP" test 2>&1); rc=$?
88+
[[ $rc -eq 2 ]] || fail "test with missing runner: exit $rc, want 2: $out"
89+
grep -qE "NOT RUN\. 0 passed; 0 failed; 1 not run \(runner 'mcpp-e2e-no-such-runner'" <<<"$out" \
90+
|| fail "test summary with missing runner: $out"
91+
grep -q "one ... not run" <<<"$out" || fail "per-test not-run line missing: $out"
92+
# --no-runner on `test` runs it directly.
93+
out=$("$MCPP" test --no-runner 2>&1) || fail "test --no-runner: $out"
94+
grep -qE "ok\. 1 passed; 0 failed; finished" <<<"$out" || fail "test --no-runner summary: $out"
95+
96+
# ── 4. no runner declared, artifact this host cannot load (ELF only) ───────
97+
if [[ "$(uname -s)" == Linux ]]; then
98+
sed -i.bak "/^\[target\.$HOST\]/,/^runner = /d" mcpp.toml
99+
grep -q "runner" mcpp.toml && fail "runner key still present after removal"
100+
"$MCPP" build >/dev/null 2>&1 || fail "rebuild without runner"
101+
bin=$(ls target/*/*/bin/app | head -1)
102+
[[ -f "$bin" ]] || fail "no artifact at target/*/*/bin/app"
103+
printf '\xff\xff' | dd of="$bin" bs=1 seek=18 conv=notrunc status=none
104+
out=$("$MCPP" run 2>&1); rc=$?
105+
[[ $rc -eq 2 ]] || fail "unrunnable artifact: exit $rc, want 2: $out"
106+
grep -q "this host cannot execute" <<<"$out" || fail "unrunnable message missing: $out"
107+
grep -q "Exec format error" <<<"$out" || fail "the kernel's answer is missing: $out"
108+
grep -q "\[target.$HOST\]" <<<"$out" || fail "paste-able key missing: $out"
109+
grep -q 'runner = \["qemu-aarch64-static"\]' <<<"$out" || fail "runner example missing: $out"
110+
grep -q -- "--no-runner" <<<"$out" || fail "escape hatch not mentioned: $out"
111+
# The criterion measured the patched artifact, not a rebuilt one.
112+
[[ "$(od -An -tx1 -j18 -N2 "$bin" | tr -d ' ')" == "ffff" ]] \
113+
|| fail "the artifact was rebuilt under the run; the criterion measured nothing"
114+
115+
# `mcpp test`: one test (streaming path) and two tests (capturing path).
116+
# Today they report a spawn failure differently; the assertion is the same.
117+
for n in 1 2; do
118+
if [[ $n -eq 2 ]]; then
119+
cat > tests/two.cpp <<'EOF'
120+
int main() { return 0; }
121+
EOF
122+
fi
123+
# A patched binary is newer than its source, so ninja keeps it; the
124+
# sources are touched so this iteration measures freshly built binaries.
125+
touch tests/*.cpp
126+
out=$("$MCPP" test 2>&1) || fail "test build+run before patching ($n): $out"
127+
grep -qE "ok\. $n passed; 0 failed" <<<"$out" || fail "sanity: tests should pass unpatched ($n): $out"
128+
tbins=$(find target -type f -perm -u+x \( -name one -o -name two \))
129+
[[ $(wc -l <<<"$tbins") -eq $n ]] || fail "expected $n test binaries, found: $tbins"
130+
for t in $tbins; do
131+
printf '\xff\xff' | dd of="$t" bs=1 seek=18 conv=notrunc status=none
132+
done
133+
out=$("$MCPP" test 2>&1); rc=$?
134+
[[ $rc -eq 2 ]] || fail "test unrunnable ($n): exit $rc, want 2: $out"
135+
grep -qE "NOT RUN\. 0 passed; 0 failed; $n not run \(this host cannot execute $HOST artifacts: Exec format error" <<<"$out" \
136+
|| fail "test summary ($n): $out"
137+
[[ $(grep -c " \.\.\. not run" <<<"$out") -eq $n ]] || fail "per-test not-run lines ($n): $out"
138+
# The reason is printed once when established, and once in the summary.
139+
[[ $(grep -c "cannot execute $HOST artifacts" <<<"$out") -eq 2 ]] \
140+
|| fail "reason printed other than once + summary ($n): $out"
141+
for t in $tbins; do
142+
[[ "$(od -An -tx1 -j18 -N2 "$t" | tr -d ' ')" == "ffff" ]] \
143+
|| fail "test binary was rebuilt under the run ($n)"
144+
done
145+
jout=$("$MCPP" test --message-format json 2>/dev/null); jrc=$?
146+
[[ $jrc -eq 2 ]] || fail "json exit ($n): $jrc, want 2"
147+
[[ $(grep -c '"status":"not_run"' <<<"$jout") -eq $n ]] || fail "json not_run records ($n): $jout"
148+
grep -q "\"not_run\":$n," <<<"$jout" || fail "json summary not_run ($n): $jout"
149+
grep -q '"not_run_reason":"this host cannot execute' <<<"$jout" || fail "json summary reason ($n): $jout"
150+
done
151+
fi
152+
153+
# ── 5. an array-valued typo is reported, and `runner` is in the list ───────
154+
printf '\n[target.%s]\nrunnerX = ["x"]\n' "$HOST" >> mcpp.toml
155+
out=$("$MCPP" build 2>&1)
156+
grep -q "unsupported key 'runnerX'" <<<"$out" || fail "array typo not reported: $out"
157+
grep -q "Supported keys: cxx_runtime, linkage, runner, sysroot, toolchain" <<<"$out" \
158+
|| fail "runner missing from the supported-keys list: $out"
159+
160+
echo "PASS: 330_runner_hosted_targets"

0 commit comments

Comments
 (0)