Skip to content

Commit 7501e50

Browse files
committed
build: runner lookup through declared payloads then PATH, with typed messages (#544)
1 parent 42fcf96 commit 7501e50

2 files changed

Lines changed: 266 additions & 0 deletions

File tree

‎src/build/runner_lookup.cppm‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// mcpp.build.runner_lookup — where the runner's program is, and what to say
2+
// when it is not.
3+
//
4+
// The lookup is mcpp's own rather than posix_spawnp's for one measured reason:
5+
// a bare name on PATH resolves to an xvm shim, and the shim answers for the
6+
// current subos rather than for the package (e2e 130 in CI: `mcpp run` exec'ing
7+
// the bare `qemu-system-riscv64` answered "not installed" while
8+
// `qemu-system-riscv64 --version` in the same job succeeded; `python3` on the
9+
// development host answers "not installed in this subos (_) — installed
10+
// elsewhere"). The payload's bin/ is the binary itself, so it is searched
11+
// first. That is what lets a runner name a program the project declared under
12+
// `[xlings] deps` without writing the payload's home-and-version path into the
13+
// manifest — the thing BuildConfig::runner's comment says a static manifest
14+
// cannot do.
15+
//
16+
// Doing the lookup here has a second effect: "not found anywhere" is decided
17+
// before any spawn, so a spawn-time ENOENT can only mean the program was found
18+
// and its interpreter or loader was not. The two messages differ, and neither
19+
// guesses.
20+
//
21+
// Design: .agents/docs/2026-09-02-runner-beyond-baremetal-design.md §4.3-4.4.
22+
23+
module;
24+
#include <cerrno>
25+
26+
export module mcpp.build.runner_lookup;
27+
28+
import std;
29+
import mcpp.platform;
30+
31+
export namespace mcpp::build::runner_lookup {
32+
33+
struct Lookup {
34+
std::optional<std::filesystem::path> program; // absolute, executable
35+
std::vector<std::filesystem::path> searched; // in order, for the message
36+
};
37+
38+
namespace detail {
39+
inline bool executable_file(const std::filesystem::path& p) {
40+
std::error_code ec;
41+
if (!std::filesystem::is_regular_file(p, ec)) return false;
42+
if constexpr (mcpp::platform::is_windows) return true;
43+
auto perms = std::filesystem::status(p, ec).permissions();
44+
using P = std::filesystem::perms;
45+
return (perms & (P::owner_exec | P::group_exec | P::others_exec)) != P::none;
46+
}
47+
} // namespace detail
48+
49+
// `argv0` absolute, or containing a directory separator: taken as-is when it
50+
// is an executable file. Otherwise `<each depBinDir>/argv0`, then each `PATH`
51+
// entry (`pathEnv` split on the platform's list separator); the first
52+
// executable regular file wins. Every directory looked in is recorded so the
53+
// not-found message can list them.
54+
inline Lookup locate(std::string_view argv0,
55+
std::span<const std::filesystem::path> depBinDirs,
56+
std::string_view pathEnv)
57+
{
58+
Lookup out;
59+
std::filesystem::path a0(argv0);
60+
const bool hasDir = a0.is_absolute()
61+
|| argv0.find('/') != std::string_view::npos
62+
|| argv0.find('\\') != std::string_view::npos;
63+
if (hasDir) {
64+
std::error_code ec;
65+
if (detail::executable_file(a0)) out.program = std::filesystem::absolute(a0, ec);
66+
out.searched.push_back(a0.parent_path());
67+
return out;
68+
}
69+
for (auto const& d : depBinDirs) {
70+
out.searched.push_back(d);
71+
if (auto c = d / a0; detail::executable_file(c)) { out.program = c; return out; }
72+
}
73+
constexpr char sep = mcpp::platform::is_windows ? ';' : ':';
74+
for (auto part : std::views::split(pathEnv, sep)) {
75+
std::string_view sv(part.begin(), part.end());
76+
if (sv.empty()) continue;
77+
std::filesystem::path d(sv);
78+
out.searched.push_back(d);
79+
if (auto c = d / a0; detail::executable_file(c)) { out.program = c; return out; }
80+
}
81+
return out;
82+
}
83+
84+
// What the kernel's refusal means. Only ENOEXEC (and EBADARCH where the
85+
// platform defines it) says "this host cannot load the artifact"; everything
86+
// else — EACCES, ENOENT on a found program, E2BIG — is reported verbatim and
87+
// never turned into advice about runners.
88+
enum class SpawnClass { Unloadable, Other };
89+
90+
inline SpawnClass classify(int e) {
91+
if (e == ENOEXEC) return SpawnClass::Unloadable;
92+
#if defined(EBADARCH)
93+
if (e == EBADARCH) return SpawnClass::Unloadable;
94+
#endif
95+
return SpawnClass::Other;
96+
}
97+
98+
inline std::string errno_text(int e) {
99+
return std::generic_category().message(e);
100+
}
101+
102+
inline std::string not_found_message(std::string_view triple, std::string_view argv0,
103+
std::span<const std::filesystem::path> searched) {
104+
std::string dirs;
105+
for (auto const& d : searched) dirs += "\n " + d.string();
106+
return std::format(
107+
"runner '{}' for '{}' was not found. Searched:{}\n"
108+
" Declare the package that provides it under [xlings] deps, or "
109+
"install it on PATH.\n"
110+
" Pass --no-runner to execute the artifact directly on this host.",
111+
argv0, triple, dirs);
112+
}
113+
114+
inline std::string spawn_failed_message(std::string_view program, int e) {
115+
return std::format("'{}' could not be started: {} (error {})",
116+
program, errno_text(e), e);
117+
}
118+
119+
// The hosted sibling of mcpp::freestanding::no_runner_message. It reports what
120+
// the kernel answered rather than asserting why: on a hosted triple mcpp does
121+
// not know whether the refusal is a foreign ISA or a file that is not an
122+
// executable at all, and the example it prints is a user-mode emulator.
123+
inline std::string unrunnable_message(std::string_view triple,
124+
const std::filesystem::path& artifact, int e) {
125+
return std::format(
126+
"this host cannot execute '{}': {} (error {}).\n"
127+
" The artifact was built for '{}'. Declare how to run it here:\n"
128+
"\n"
129+
" [target.{}]\n"
130+
" runner = [\"qemu-aarch64-static\"]\n"
131+
"\n"
132+
" The artifact path is appended, or substituted for `{{}}` if the "
133+
"template contains it.\n"
134+
" A host that can execute it directly may pass --no-runner.",
135+
artifact.string(), errno_text(e), e, triple, triple);
136+
}
137+
138+
} // namespace mcpp::build::runner_lookup

‎tests/unit/test_runner_lookup.cpp‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#include <gtest/gtest.h>
2+
#include <cerrno>
3+
#include <fstream>
4+
5+
import std;
6+
import mcpp.build.runner_lookup;
7+
8+
using namespace mcpp::build::runner_lookup;
9+
10+
// The lookup order is the whole point (#544 §4.4): a declared payload's bin/
11+
// beats PATH, because a bare name on PATH reaches an xvm shim that answers for
12+
// the current subos rather than for the package. The directories are real and
13+
// the files are executable, so what is asserted is the rule, not a mock of it.
14+
#if !defined(_WIN32)
15+
16+
namespace {
17+
std::filesystem::path fresh_root(std::string_view name) {
18+
auto root = std::filesystem::temp_directory_path() / name;
19+
std::filesystem::remove_all(root);
20+
std::filesystem::create_directories(root);
21+
return root;
22+
}
23+
std::filesystem::path make_exe(const std::filesystem::path& dir, std::string_view name) {
24+
std::filesystem::create_directories(dir);
25+
auto p = dir / name;
26+
{ std::ofstream o(p); o << "#!/bin/sh\nexit 0\n"; }
27+
std::filesystem::permissions(p, std::filesystem::perms::owner_all);
28+
return p;
29+
}
30+
} // namespace
31+
32+
TEST(RunnerLookup, PayloadBinBeatsPath) {
33+
auto root = fresh_root("mcpp-runner-lookup-1");
34+
auto inPayload = make_exe(root / "payload" / "bin", "qemu-x");
35+
make_exe(root / "path", "qemu-x");
36+
std::vector<std::filesystem::path> bins{root / "payload" / "bin"};
37+
auto l = locate("qemu-x", bins, (root / "path").string());
38+
ASSERT_TRUE(l.program.has_value());
39+
EXPECT_EQ(*l.program, inPayload);
40+
}
41+
42+
TEST(RunnerLookup, PathIsSearchedAfterPayloads) {
43+
auto root = fresh_root("mcpp-runner-lookup-2");
44+
auto onPath = make_exe(root / "path", "qemu-y");
45+
std::vector<std::filesystem::path> bins{root / "payload" / "bin"}; // absent dir
46+
auto l = locate("qemu-y", bins, (root / "path").string());
47+
ASSERT_TRUE(l.program.has_value());
48+
EXPECT_EQ(*l.program, onPath);
49+
ASSERT_EQ(l.searched.size(), 2u);
50+
EXPECT_EQ(l.searched[0], root / "payload" / "bin");
51+
EXPECT_EQ(l.searched[1], root / "path");
52+
}
53+
54+
TEST(RunnerLookup, NonExecutableFileIsSkipped) {
55+
auto root = fresh_root("mcpp-runner-lookup-3");
56+
std::filesystem::create_directories(root / "p1");
57+
{ std::ofstream o(root / "p1" / "tool"); o << "data"; } // no exec bit
58+
auto real = make_exe(root / "p2", "tool");
59+
auto l = locate("tool", {}, (root / "p1").string() + ":" + (root / "p2").string());
60+
ASSERT_TRUE(l.program.has_value());
61+
EXPECT_EQ(*l.program, real);
62+
}
63+
64+
TEST(RunnerLookup, NotFoundListsEveryDirectorySearched) {
65+
auto root = fresh_root("mcpp-runner-lookup-4");
66+
std::vector<std::filesystem::path> bins{root / "a" / "bin"};
67+
auto l = locate("nope", bins, (root / "p1").string() + ":" + (root / "p2").string());
68+
EXPECT_FALSE(l.program.has_value());
69+
ASSERT_EQ(l.searched.size(), 3u);
70+
auto msg = not_found_message("aarch64-linux-musl", "nope", l.searched);
71+
EXPECT_NE(msg.find("runner 'nope'"), std::string::npos) << msg;
72+
EXPECT_NE(msg.find("aarch64-linux-musl"), std::string::npos) << msg;
73+
EXPECT_NE(msg.find((root / "a" / "bin").string()), std::string::npos) << msg;
74+
EXPECT_NE(msg.find((root / "p2").string()), std::string::npos) << msg;
75+
EXPECT_NE(msg.find("[xlings] deps"), std::string::npos) << msg;
76+
EXPECT_NE(msg.find("--no-runner"), std::string::npos) << msg;
77+
}
78+
79+
TEST(RunnerLookup, AbsoluteArgv0IsTakenAsIs) {
80+
auto root = fresh_root("mcpp-runner-lookup-5");
81+
auto abs = make_exe(root, "runner.sh");
82+
auto l = locate(abs.string(), {}, "");
83+
ASSERT_TRUE(l.program.has_value());
84+
EXPECT_EQ(*l.program, abs);
85+
// ...and an absolute path that is not there is not searched for elsewhere.
86+
auto missing = locate((root / "absent.sh").string(), {}, root.string());
87+
EXPECT_FALSE(missing.program.has_value());
88+
}
89+
90+
TEST(RunnerLookup, EmptyPathEntriesAreIgnored) {
91+
auto root = fresh_root("mcpp-runner-lookup-6");
92+
auto onPath = make_exe(root / "p", "tool");
93+
auto l = locate("tool", {}, ":" + (root / "p").string() + "::");
94+
ASSERT_TRUE(l.program.has_value());
95+
EXPECT_EQ(*l.program, onPath);
96+
EXPECT_EQ(l.searched.size(), 1u);
97+
}
98+
99+
TEST(RunnerLookup, ClassifiesENOEXECAsUnloadable) {
100+
EXPECT_EQ(classify(ENOEXEC), SpawnClass::Unloadable);
101+
EXPECT_EQ(classify(EACCES), SpawnClass::Other);
102+
EXPECT_EQ(classify(ENOENT), SpawnClass::Other);
103+
EXPECT_EQ(classify(0), SpawnClass::Other);
104+
}
105+
106+
TEST(RunnerLookup, UnrunnableMessageNamesKernelAnswerTripleAndKey) {
107+
auto msg = unrunnable_message("aarch64-linux-musl", "/x/bin/app", ENOEXEC);
108+
EXPECT_NE(msg.find("Exec format error"), std::string::npos) << msg;
109+
EXPECT_NE(msg.find("/x/bin/app"), std::string::npos) << msg;
110+
EXPECT_NE(msg.find("built for 'aarch64-linux-musl'"), std::string::npos) << msg;
111+
EXPECT_NE(msg.find("[target.aarch64-linux-musl]"), std::string::npos) << msg;
112+
EXPECT_NE(msg.find("runner = [\"qemu-aarch64-static\"]"), std::string::npos) << msg;
113+
EXPECT_NE(msg.find("--no-runner"), std::string::npos) << msg;
114+
}
115+
116+
TEST(RunnerLookup, SpawnFailedMessageIsVerbatim) {
117+
auto msg = spawn_failed_message("/x/bin/qemu", EACCES);
118+
EXPECT_NE(msg.find("'/x/bin/qemu' could not be started"), std::string::npos) << msg;
119+
EXPECT_NE(msg.find("Permission denied"), std::string::npos) << msg;
120+
EXPECT_NE(msg.find("(error 13)"), std::string::npos) << msg;
121+
EXPECT_EQ(msg.find("runner = ["), std::string::npos) << msg; // no runner advice
122+
}
123+
124+
#else
125+
126+
TEST(RunnerLookup, WindowsCoveredByIntegration) { SUCCEED(); }
127+
128+
#endif

0 commit comments

Comments
 (0)