Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/20-toolchains.md
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,10 @@ give such a payload a descriptor, refresh the index first and then reinstall:
again, because the index is refreshed when a resolution misses and not because a
recipe changed; measured in the sandbox verification of 2026.9.12.2. Until then
the failure is the interpreter's own (`/usr/bin/env: 'node': No such file or
directory`), which mcpp does not yet explain (#621).
directory`). Before `mcpp run` and `mcpp test` execute an artefact directly,
they read its `#!` line, and when the program it names is not there (`node` on
no directory of the artefact's PATH) a note names that program and these three
steps (#621).

For a target whose artifact runs elsewhere, the `runner` key is an argv prefix
and the session belongs to a package rather than to the engine:
Expand Down
6 changes: 4 additions & 2 deletions docs/zh/20-toolchains.md
Original file line number Diff line number Diff line change
Expand Up @@ -769,8 +769,10 @@ runner 时,`mcpp run` 与 `mcpp test` 使用这个程序,因此不涉及 PAT
与 `mcpp toolchain install <spec>`。仅仅重装会再次按同一份配方安装,因为
索引只在解析未命中时刷新,而不因配方变化而刷新;这一点在 2026.9.12.2 的
沙箱验证中实测过。在此之前,失败信息来自解释器自身
(`/usr/bin/env: 'node': No such file or directory`),mcpp 尚未对它作出解释
(#621)。
(`/usr/bin/env: 'node': No such file or directory`)。`mcpp run` 与
`mcpp test` 在直接执行产物之前会读取它的 `#!` 行;这一行指名的程序不存在时
(产物所得 PATH 的任何目录中都没有 `node`),一条 note 会指出这个程序以及上述
三步(#621)。

对于产物在别处运行的 target,`runner` 键是一个 argv 前缀,而那个会话属于
一个**包**,不属于引擎:
Expand Down
32 changes: 31 additions & 1 deletion src/build/execute.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -2995,6 +2995,21 @@ void yield_terminal() {
mcpp::ui::print_closing_notices();
}

// A SCRIPT WHOSE `#!` INTERPRETER IS NOT THERE (#621), said before the
// artefact is executed directly: the refusal is the interpreter's own and
// comes after mcpp has handed the process over, so nothing of mcpp follows it.
// The PATH searched is the one the artefact receives -- the last value
// `childEnv` gives it, else this invocation's -- since that is the one `env`
// searches.
void note_missing_interpreter(const std::filesystem::path& exe,
const std::vector<std::pair<std::string, std::string>>& childEnv) {
std::string path = mcpp::invocation::get("PATH").value_or("");
for (auto const& [key, value] : childEnv)
if (key == "PATH") path = value;
if (auto note = mcpp::build::runner_lookup::missing_interpreter_note(exe, path); !note.empty())
mcpp::ui::note(note);
}

// mcpp#225 (E2): `mcpp run`'s fast path. Mirrors try_fast_build's
// fingerprint/freshness gate against the SAME cache entry `mcpp build`
// wrote (targetTriple == "" — a HOST build; see the precondition below), then
Expand Down Expand Up @@ -3451,7 +3466,6 @@ int run_artifact_via_runner(mcpp::build::BuildContext& ctx,
}
runClosure = *closure;
}
run_separator();

std::vector<std::pair<std::string, std::string>> childEnv;
auto [runEnvKey, runEnvValue] = compute_run_env(ctx.plan);
Expand All @@ -3464,6 +3478,9 @@ int run_artifact_via_runner(mcpp::build::BuildContext& ctx,
if (runClosure)
for (auto& kv : run_contract_env(ctx, exe, "run", choice.tripleKey, *runClosure))
childEnv.push_back(std::move(kv));
if (choice.tmpl.empty() && host.prefix.empty() && !print_launch_flag())
note_missing_interpreter(exe, childEnv);
run_separator();

// Direct exec (no /bin/sh): the loader env reaches ONLY the target child,
// never mcpp or a host shell. Fixes the bundled-glibc-vs-host-libtinfo
Expand Down Expand Up @@ -4464,6 +4481,9 @@ struct TestBuild {
// and each such result is NotRun, not RunFail. The reason is printed once.
std::atomic<bool> hostCannotRun{false};
std::string hostCannotRunReason;
// The first test executed directly was read for a missing `#!`
// interpreter (#621); the note, if any, is printed once per build.
bool interpreterNoted = false;
};

// Phase A goal set: every shared prerequisite — all package/dep compile
Expand Down Expand Up @@ -5320,6 +5340,16 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts,
}
}

// Said once for the build, before any test runs: every test of one
// build is executed the same way, so one script's missing interpreter
// is every script's.
if (runnerTmpl.empty() && tb.runUnderPrefix.empty() && !tb.interpreterNoted && !json
&& !print_launch_flag() && !testOpts.noRun && !tb.builtNotRunHere
&& invocationNotRunReason.empty()) {
tb.interpreterNoted = true;
note_missing_interpreter(exe, childEnv);
}

runnable.push_back({lu.targetName, std::move(argv), std::move(childEnv),
buildMsOfTest, exe});
// The protocol it implements: what its entry states, else the marker
Expand Down
93 changes: 93 additions & 0 deletions src/build/runner_lookup.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,97 @@ inline std::string unrunnable_message(std::string_view triple,
artifact.string(), errno_text(e), e, triple, triple);
}

// THE PROGRAM A SCRIPT'S `#!` LINE HANDS IT TO (#621).
//
// An artefact the run slot executes directly can be a script: emsdk's
// JavaScript launcher begins `#!/usr/bin/env node`. When that program is not
// there, the refusal comes after mcpp has handed the process over -- from the
// kernel, or from `env` once it has started (`/usr/bin/env: 'node': No such
// file or directory`) -- so none of the messages above applies. The line is
// read as the kernel reads it: the interpreter is the first word after `#!`
// and the rest is its argument; through `env`, that argument names the program
// `env` looks up on PATH. A form this does not read with certainty (an
// interpreter that is not an absolute path, an `env` option other than `-S`,
// `env` with no program) names no program, so a note built on it never names
// the wrong one.
struct ScriptInterpreter {
std::string line; // the `#!` line, without its line end
std::filesystem::path program; // what the kernel executes
std::string viaEnv; // what `program`, an `env`, looks up on PATH; empty otherwise
};

inline std::optional<ScriptInterpreter> parse_script_interpreter(std::string_view head) {
if (!head.starts_with("#!")) return std::nullopt;
head = head.substr(0, head.find_first_of("\r\n"));
ScriptInterpreter out;
out.line = std::string(head);
constexpr std::string_view blanks = " \t";
auto next_word = [&](std::string_view& rest) {
const auto from = std::min(rest.find_first_not_of(blanks), rest.size());
rest.remove_prefix(from);
const auto to = std::min(rest.find_first_of(blanks), rest.size());
auto word = rest.substr(0, to);
rest.remove_prefix(to);
return word;
};
std::string_view rest = head.substr(2);
const auto program = next_word(rest);
if (!program.starts_with('/')) return std::nullopt;
out.program = std::filesystem::path(program);
if (out.program.filename() != "env") return out;
// `env [-S] [NAME=value]... <program> [<argument>]...`
for (auto word = next_word(rest); !word.empty(); word = next_word(rest)) {
if (word == "-S" || word == "--split-string") continue;
if (word.starts_with('-')) return std::nullopt;
if (word.find('=') != std::string_view::npos) continue;
out.viaEnv = std::string(word);
return out;
}
return std::nullopt;
}

// The artefact's own `#!` line; none on Windows, which does not read one.
inline std::optional<ScriptInterpreter> script_interpreter(const std::filesystem::path& artifact) {
if constexpr (mcpplibs::os::is_windows) {
return std::nullopt;
} else {
std::ifstream in(artifact, std::ios::binary);
if (!in) return std::nullopt;
// The kernel reads no more of the line than this (BINPRM_BUF_SIZE).
std::string head(256, '\0');
in.read(head.data(), static_cast<std::streamsize>(head.size()));
head.resize(static_cast<std::size_t>(in.gcount()));
return parse_script_interpreter(head);
}
}

// What `mcpp run` and `mcpp test` say before they execute `artifact` directly
// when the program its `#!` line names is not there; empty when it is, and
// when the artefact is no script this reads. `pathEnv` is the PATH the
// artefact receives, the one `env` searches.
//
// The remedy is the one an emsdk payload installed before its recipe wrote
// `.mcpp-toolchain.json` needs (docs/20, "Running what they produce"). The
// note names no toolchain: it reads a file's first line, and which payload
// built the file is not a fact that line holds.
inline std::string missing_interpreter_note(const std::filesystem::path& artifact,
std::string_view pathEnv) {
auto script = script_interpreter(artifact);
if (!script) return {};
std::string missing;
if (!detail::executable_file(script->program))
missing = std::format("'{}' is not an executable file", script->program.string());
else if (!script->viaEnv.empty() && !locate(script->viaEnv, {}, pathEnv).program)
missing = std::format("'{}' is not on PATH", script->viaEnv);
if (missing.empty()) return {};
return std::format(
"'{}' runs through its first line, `{}`, and {}.\n"
" A toolchain payload names the program that runs its artefacts in\n"
" `.mcpp-toolchain.json`; one installed before its recipe wrote that file\n"
" names none, and its artefacts run through their `#!` line.\n"
" fix: `mcpp index update`, then `mcpp toolchain remove <spec>` and\n"
" `mcpp toolchain install <spec>` for the toolchain that built it.",
artifact.string(), script->line, missing);
}

} // namespace mcpp::build::runner_lookup
70 changes: 70 additions & 0 deletions tests/unit/test_runner_lookup.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,76 @@ UTest spawn_failed_message_is_verbatim{"RunnerLookup/SpawnFailedMessageIsVerbati
expect(eq(msg.find("runner = ["), std::string::npos)) << msg; // no runner advice
}};

// A SCRIPT'S `#!` LINE, READ AS THE KERNEL READS IT (#621). The interpreter is
// the first word; through `env`, the program is the first word that is neither
// `-S` nor an assignment. A form read without certainty names no program.
UTest a_shebang_names_its_interpreter{"RunnerLookup/AShebangNamesItsInterpreter", [] {
auto direct = parse_script_interpreter("#!/bin/sh -e\nexit 0\n");
expect(fatal(direct.has_value()));
expect(eq(direct->program, std::filesystem::path("/bin/sh")));
expect(eq(direct->viaEnv, std::string{}));
expect(eq(direct->line, std::string("#!/bin/sh -e")));

auto viaEnv = parse_script_interpreter("#!/usr/bin/env node\r\n// emsdk\n");
expect(fatal(viaEnv.has_value()));
expect(eq(viaEnv->program, std::filesystem::path("/usr/bin/env")));
expect(eq(viaEnv->viaEnv, std::string("node")));
expect(eq(viaEnv->line, std::string("#!/usr/bin/env node")));

auto split = parse_script_interpreter("#! /usr/bin/env -S NODE_OPTIONS=x node --flag\n");
expect(fatal(split.has_value()));
expect(eq(split->viaEnv, std::string("node")));
}};

UTest an_uncertain_shebang_names_no_program{"RunnerLookup/AnUncertainShebangNamesNoProgram", [] {
expect(!parse_script_interpreter("\x7f" "ELF\x02\x01\x01").has_value());
expect(!parse_script_interpreter("#!\n").has_value());
expect(!parse_script_interpreter("#!node\n").has_value()); // not absolute
expect(!parse_script_interpreter("#!/usr/bin/env\n").has_value()); // no program
expect(!parse_script_interpreter("#!/usr/bin/env -i node\n").has_value());
expect(!parse_script_interpreter("#!/usr/bin/env -u X node\n").has_value());
}};

UTest a_script_whose_interpreter_is_missing_is_noted{"RunnerLookup/AScriptWhoseInterpreterIsMissingIsNoted", [] {
auto root = fresh_root("mcpp-runner-lookup-shebang-1");
auto env = make_exe(root / "sys", "env");
auto script = root / "app.js";
{ std::ofstream o(script); o << "#!" << env.string() << " node-absent\nconsole.log(1)\n"; }
auto note = missing_interpreter_note(script, (root / "path").string());
expect(ne(note.find(script.string()), std::string::npos)) << note;
expect(ne(note.find("`#!" + env.string() + " node-absent`"), std::string::npos)) << note;
expect(ne(note.find("'node-absent' is not on PATH"), std::string::npos)) << note;
expect(ne(note.find(".mcpp-toolchain.json"), std::string::npos)) << note;
expect(ne(note.find("mcpp index update"), std::string::npos)) << note;
expect(ne(note.find("mcpp toolchain remove <spec>"), std::string::npos)) << note;
expect(ne(note.find("mcpp toolchain install <spec>"), std::string::npos)) << note;

// ...and once the program is on the PATH the artefact receives, nothing.
make_exe(root / "path", "node-absent");
expect(eq(missing_interpreter_note(script, (root / "path").string()), std::string{}));
}};

UTest a_missing_interpreter_path_is_noted{"RunnerLookup/AMissingInterpreterPathIsNoted", [] {
auto root = fresh_root("mcpp-runner-lookup-shebang-2");
auto script = root / "tool.sh";
{ std::ofstream o(script); o << "#!" << (root / "absent" / "sh").string() << "\n"; }
auto note = missing_interpreter_note(script, "");
expect(ne(note.find("'" + (root / "absent" / "sh").string() + "' is not an executable file"),
std::string::npos)) << note;
}};

UTest a_program_or_a_found_interpreter_is_not_noted{"RunnerLookup/AProgramOrAFoundInterpreterIsNotNoted", [] {
auto root = fresh_root("mcpp-runner-lookup-shebang-3");
auto sh = make_exe(root / "sys", "sh");
auto found = root / "found.sh";
{ std::ofstream o(found); o << "#!" << sh.string() << "\nexit 0\n"; }
expect(eq(missing_interpreter_note(found, ""), std::string{}));
auto binary = root / "app";
{ std::ofstream o(binary, std::ios::binary); o << "\x7f" "ELF"; }
expect(eq(missing_interpreter_note(binary, ""), std::string{}));
expect(eq(missing_interpreter_note(root / "absent", ""), std::string{}));
}};

#else

UTest runner_lookup_windows_covered_by_integration{"RunnerLookup/WindowsCoveredByIntegration", [] { /* passes */; }};
Expand Down
Loading