diff --git a/docs/20-toolchains.md b/docs/20-toolchains.md index f4db148ee..1e47ba58c 100644 --- a/docs/20-toolchains.md +++ b/docs/20-toolchains.md @@ -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: diff --git a/docs/zh/20-toolchains.md b/docs/zh/20-toolchains.md index c52afa4fc..78a42d29b 100644 --- a/docs/zh/20-toolchains.md +++ b/docs/zh/20-toolchains.md @@ -769,8 +769,10 @@ runner 时,`mcpp run` 与 `mcpp test` 使用这个程序,因此不涉及 PAT 与 `mcpp toolchain install `。仅仅重装会再次按同一份配方安装,因为 索引只在解析未命中时刷新,而不因配方变化而刷新;这一点在 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 前缀,而那个会话属于 一个**包**,不属于引擎: diff --git a/src/build/execute.cppm b/src/build/execute.cppm index ad4cb0065..214a4ca79 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -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>& 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 @@ -3451,7 +3466,6 @@ int run_artifact_via_runner(mcpp::build::BuildContext& ctx, } runClosure = *closure; } - run_separator(); std::vector> childEnv; auto [runEnvKey, runEnvValue] = compute_run_env(ctx.plan); @@ -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 @@ -4464,6 +4481,9 @@ struct TestBuild { // and each such result is NotRun, not RunFail. The reason is printed once. std::atomic 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 @@ -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 diff --git a/src/build/runner_lookup.cppm b/src/build/runner_lookup.cppm index 84e66e055..a575351cc 100644 --- a/src/build/runner_lookup.cppm +++ b/src/build/runner_lookup.cppm @@ -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 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]... []...` + 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 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(head.size())); + head.resize(static_cast(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 ` and\n" + " `mcpp toolchain install ` for the toolchain that built it.", + artifact.string(), script->line, missing); +} + } // namespace mcpp::build::runner_lookup diff --git a/tests/unit/test_runner_lookup.cpp b/tests/unit/test_runner_lookup.cpp index 144aca228..d8a9b12f8 100644 --- a/tests/unit/test_runner_lookup.cpp +++ b/tests/unit/test_runner_lookup.cpp @@ -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 "), std::string::npos)) << note; + expect(ne(note.find("mcpp toolchain install "), 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 */; }};