diff --git a/.agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md b/.agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md index b72bd45a..52a845a9 100644 --- a/.agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md +++ b/.agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md @@ -574,6 +574,13 @@ already filed as mcpp-index#376, reached this time through and not here, from the same pinned payload. R7 now serves the adapter through `compat:opencl`, so the practical consequence is gone; what is still unexplained is why their machine supplied `libOpenCL.so.1` without it. +* **The one cold-registry run in which section D found no record (§8.6).** The + released 2026.9.10.1 published nothing for a project it answers for on every + warm run of the same script in the same sandbox. The code path that can + produce that reading is repaired, and the repair is stated by a unit test; + the ordering of backend drives on that particular run was not captured and + has not been reproduced. A second cold sandbox -- a fresh registry, the + dependency built in the same invocation -- is what would close it. * **What R6 does to an executable that is itself a plugin host.** Hiding `libc++.a` and `libc++abi.a` means a library `dlopen`ed later cannot resolve the C++ standard library from the executable. That is the intended direction @@ -646,6 +653,52 @@ Both were invisible before because the same artifact had 68 real findings sitting on top of them. A check whose noise is repaired shows what the noise was covering, which is the third time this issue has produced that shape. +## 8.6 What the sandbox raised, and what the second run of it settled + +The verification script was run against the released 2026.9.10.1 in a sandbox +and section D failed -- `resolution.json has no runtime.dlopen_surface` -- on a +build whose sections A to C had just passed. That run was also the run that +installed `compat:sycl-runtime` into the sandbox registry. + +Run again against the same binary in the same sandbox, with the registry now +warm, section D passes: `dlopen_surface examined 15 of 15 members`. So the +absence is not a property of the released check on this project; it appeared +once, on the cold run, and no second observation of it exists. THAT IS RECORDED +AS AN OPEN OBSERVATION RATHER THAN A DIAGNOSIS, because the difference between +the two runs -- which drives the backend performed, and in what order -- was +not captured while the failing run was in front of us. + +What the failure did expose, by sending us to read the code, is a path that can +produce exactly that reading and should not exist regardless. `check_dlopen_surface` +returned without writing in four cases: a non-hermetic binding, `allow_host_libs`, +a plan producing no program, a plan producing no artifact. (A target that is not +Linux still publishes nothing, and should: the record is ELF-shaped.) Omitting a +record makes "did not apply" and "was never run" the same reading, +which is the failure this repository names most often. And it does more than +omit a sentence, because the two copies of the record have opposite lifetimes: +the sidecar survives an invocation, while `resolution.json` is regenerated from +an empty object at the start of one. The backend runs once per drive and a +single invocation can drive it more than once -- `mcpp test` builds the library +and then links the test binary -- so a drive that links only a dependency's +shared library has no program, has nothing to answer, and yet decides what the +documented place to look finally contains. + +THE REPAIR HAS TWO HALVES, and only one of them was obvious. Every early return +now publishes a record carrying its `reason`, so a non-answer is legible. And a +non-answer republishes a reading already on file under the same key rather than +replacing it with a blank: the key covers the contract hash, the SubOS stamp +and the host-libs policy, not the link units, so a reading taken under it is +still about this farm and this policy, and a key that moved has already cleared +the record before this runs. Publishing the reason alone would have converted a +silent absence into a loud wrong answer. + +The invariant is stated in a unit test rather than end to end +(`DlopenSurfaceRecord.ANonAnswerRepublishesTheAnswerAlreadyOnFile`): reaching it +requires two drives over one output directory where the SECOND is the one that +does not apply, and no project shape names that. The verification script gained +the matching criterion -- a published non-answer counts as a failure -- because +without it the next such overwrite would read as a pass. + ## 9. What this touches, across the three repositories The review that asks the other question: not "is each repair right" but "what diff --git a/.agents/docs/2026-09-10-596-verify.sh b/.agents/docs/2026-09-10-596-verify.sh index 6b290b61..61c92efb 100755 --- a/.agents/docs/2026-09-10-596-verify.sh +++ b/.agents/docs/2026-09-10-596-verify.sh @@ -136,8 +136,25 @@ section "D. mcpp reports a dlopen surface it cannot satisfy" # fails the next time the wording improves. Both denominators are asserted for # the reason they exist -- "no findings" and "nothing was examined" must not # read the same. -res=$(find "$work" -name resolution.json 2>/dev/null | head -1) +# +# THE OBJECT IS SELECTED BY IDENTITY, NOT BY WHERE `find` ARRIVES FIRST. The +# probe project's own build tree is `$work/target//`, and +# a run that also builds a dependency can leave more than one resolution.json +# under the work tree. "The first one" is a property of directory traversal +# order, not of the record being tested, so the count is asserted and named. +res="" +res_n=0 +for candidate in "$work"/target/*/*/resolution.json; do + [ -f "$candidate" ] || continue + res_n=$((res_n + 1)) + res="$candidate" +done +if [ "$res_n" -gt 1 ]; then + fail "D: $res_n resolution.json files under $work/target; the probe builds one project" + res="" +fi if [ -n "$res" ] && command -v python3 >/dev/null 2>&1; then + ok "reading ${res#"$work"/}" python3 - "$res" <<'PY' import json, sys doc = json.load(open(sys.argv[1])) @@ -145,6 +162,12 @@ rec = doc.get("runtime", {}).get("dlopen_surface") if rec is None: print("ASSERT-FAIL: resolution.json has no runtime.dlopen_surface") sys.exit(1) +# A published non-answer carries its reason. Distinguishing it from a real +# reading is the whole point of publishing one: this assertion is what caught +# the check erasing its own record on a second pass. +if rec.get("reason"): + print(f"ASSERT-FAIL: the check did not apply: {rec['reason']}") + sys.exit(1) members, walked = rec.get("members", 0), rec.get("walked", 0) if members <= 0: print(f"ASSERT-FAIL: dlopen_surface examined {members} members") @@ -160,8 +183,8 @@ if bad: print("ok: no driver soname is missing from the farm") PY [ $? -eq 0 ] || fails=$((fails + 1)) -else - skip "D: no resolution.json (section C did not build) or no python3" +elif [ "$res_n" -le 1 ]; then + skip "D: no resolution.json under $work/target (section C did not build) or no python3" fi # --------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ef10010..cf282c38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ ## [Unreleased] +## [2026.9.10.2] - 2026-09-10 + +### dlopen 面检查:不适用的那一趟也会发布记录,并且不会盖掉已经量出来的答案 + +`check_dlopen_surface` 有多条提前返回:绑定非 hermetic、设置了 `allow_host_libs`、 +本次构建不产出程序、本次构建没有链接产物。它们都直接返回、什么都不写,于是「没适用」 +与「没检查过」读成同一个——这正是本仓库记下次数最多的那种失败,而它出现在一条为消除 +这种失败而写的检查里。(非 Linux 目标仍然不发布记录:这条记录是 ELF 形状的,对一个 +本次构建根本不产出的格式给出空答案是另一种混淆。) + +它不止是少一句话。记录有两份副本,寿命相反:sidecar 跨调用存活,而 +`resolution.json` 由 `prepare_build` 在每次调用开头从空对象重写。后端按「趟」运行, +一次调用可以驱动它不止一次(`mcpp test` 先构建库、再链接测试程序),只链接依赖的 +共享库的那一趟没有程序,这个问题本就不归它答——而它同样决定了**文档指定的查看 +位置**里最后剩下什么。 + +现在这四条提前返回都发布一条带 `reason` 的记录;并且当同一 key 下已经有一次真实读数 +时,不适用的那一趟把它**重新发布**,而不是用空白覆盖。key 由契约哈希、SubOS 戳记 +与 host-libs 策略构成,不含链接单元,所以同 key 的读数仍然是关于这个 farm 和这个 +策略的;key 变了本来就会先清空记录。 + +**测量到什么,以及什么没有被复现。** 沙箱里用发布的 2026.9.10.1 跑 +`.agents/docs/2026-09-10-596-verify.sh`:A/B/C 三节通过,D 节报 +`resolution.json has no runtime.dlopen_surface`,那一次注册表是冷的——依赖是在同一次 +运行里装上的。注册表转热之后,同一份脚本、同一个发布二进制、同一个沙箱,D 节通过并 +打印 `dlopen_surface examined 15 of 15 members`。因此「哪一趟的顺序产生了那次缺席」 +并没有被复现;这里修的是**能够产生它的那条代码路径**,以及它此后不再能产生它。 + +不变量由单测陈述(`DlopenSurfaceRecord.ANonAnswerRepublishesTheAnswerAlreadyOnFile`): +没有端到端形状够得着它——它需要同一个输出目录上的两趟,且第二趟必须是不适用的那一趟。 +验证脚本同时补上对应判据:发布出来的非答案带 `reason` 时按失败计,否则同样的覆盖下次 +仍会被读成通过。 + ## [2026.9.10.1] - 2026-09-10 三条改动来自同一次排查(#596):一个 SYCL 工程构建全绿、运行时以退出码 134 终止且 diff --git a/docs/33-authoring-an-adapter.md b/docs/33-authoring-an-adapter.md index 830b2b89..906758b1 100644 --- a/docs/33-authoring-an-adapter.md +++ b/docs/33-authoring-an-adapter.md @@ -120,6 +120,24 @@ The full result, including both denominators, is published as failed to build enumerates nothing, and "no findings" would otherwise be indistinguishable from "nothing was examined". +A build the check does not apply to publishes the same record with a `reason` +and no reading: + +```json +{ "members": 0, "walked": 0, "findings": [], + "reason": "this build produces no program; the surface is reached from a process and belongs to whatever runs" } +``` + +The four reasons are: the runtime binding is not hermetic, `allow_host_libs` is +set, the build produces no program, and the build produced no linked artifact. +A target that is not Linux publishes no record at all, because the record is +ELF-shaped and an empty answer about a format the build never produces would be +its own confusion. + +A test that reads this record should treat a `reason` as "not measured" rather +than as a clean result -- "did not apply" and "was never run" are the pair this +record exists to keep apart. + ## Current limitations - **Linux only, by construction.** macOS's dyld and the Windows PE loader have diff --git a/docs/zh/33-authoring-an-adapter.md b/docs/zh/33-authoring-an-adapter.md index ea704c92..7e0c8670 100644 --- a/docs/zh/33-authoring-an-adapter.md +++ b/docs/zh/33-authoring-an-adapter.md @@ -101,6 +101,20 @@ on this artifact's search path: 即使没有任何发现,`members` 与 `walked` 也会被发布。一个构建失败的农场枚举出零个成员, 否则「没有发现」与「什么都没检查」就读起来一模一样。 +这条检查不适用的构建,发布同一条记录,但只带 `reason`、不带读数: + +```json +{ "members": 0, "walked": 0, "findings": [], + "reason": "this build produces no program; the surface is reached from a process and belongs to whatever runs" } +``` + +四种理由是:运行时绑定非 hermetic;设置了 `allow_host_libs`;本次构建不产出程序;本次构建 +没有链接产物。目标不是 Linux 时不发布任何记录 —— 这条记录是 ELF 形状的,对一个本次构建 +根本不产出的格式给出「空答案」本身就是另一种混淆。 + +读这条记录的测试应当把 `reason` 当作「没有测到」而不是「测了,结果干净」—— 「没适用」与 +「没检查过」正是这条记录存在的意义所在。 + ## 当前边界 - **按构造只适用于 Linux。** macOS 的 dyld 与 Windows 的 PE 加载器没有对应的这一层, diff --git a/mcpp.toml b/mcpp.toml index 70e5074e..2dfc8efd 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.10.1" +version = "2026.9.10.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index 376ad844..6a73bf86 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.9.10.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.10.2"; } // namespace mcpp diff --git a/src/build/runtime_validation.cppm b/src/build/runtime_validation.cppm index 411e3977..cc2cd1d1 100644 --- a/src/build/runtime_validation.cppm +++ b/src/build/runtime_validation.cppm @@ -1151,15 +1151,59 @@ latest_stored_verdict(const std::filesystem::path& targetRoot) { mcpp::platform::elf::DlopenSurfaceReport check_dlopen_surface(const mcpp::build::BuildPlan& plan) { mcpp::platform::elf::DlopenSurfaceReport report; + + // The record below is ELF-shaped, so a host that links no ELF publishes + // nothing rather than an empty answer about a format it never produces. if constexpr (!mcpp::platform::is_linux) return report; + // ON LINUX, A NON-ANSWER IS PUBLISHED, NOT OMITTED -- AND NEVER OVER AN + // ANSWER. + // + // Returning without writing made "the check did not apply" and "the check + // was never run" the same reading, which is the confusion this repository + // has recorded most often. It is worse than a missing sentence here, + // because the two copies of the record have opposite lifetimes: + // `prepare_build` regenerates `resolution.json` from an empty object at + // the start of an invocation while the sidecar survives it, so a pass that + // returns without publishing leaves the DOCUMENTED place to look empty for + // an answer that was measured. + // + // The backend runs once per pass and one invocation can drive it more than + // once -- `mcpp test` builds the library and then links the test binary -- + // so a pass that links only a dependency's shared library has no program + // and nothing to say. Such a pass republishes what is on file rather than + // replacing it: the key covers the contract, the subos stamp and the + // host-libs policy, not the link units, so a reading taken under the same + // key is still about this farm and this policy. A key that moved has + // already cleared the record before this runs. + auto publish_reason = [&](std::string_view why) { + const auto key = post_link_key(plan); + auto doc = read_cache(plan.outputDir); + if (doc.is_object() && doc.value("post_link_key", "") == key) { + auto it = doc.find(std::string(kDlopenSurfaceRecord)); + if (it != doc.end() && it->is_object() && !it->contains("reason")) { + persist_post_link(plan, kDlopenSurfaceRecord, key, *it); + return; + } + } + persist_post_link(plan, kDlopenSurfaceRecord, key, + nlohmann::json{{"members", 0}, {"walked", 0}, + {"findings", nlohmann::json::array()}, + {"reason", std::string(why)}}); + }; + // THE SAME APPLICABILITY THE ARTIFACT VERDICT HAS, and for the same // reason. Under a non-hermetic binding the host loader also consults // `ld.so.cache`, which mcpp deliberately does not parse, so "not on the // path mcpp computed" is not evidence of anything. `allow_host_libs` is // the user's statement that resolution is theirs to arrange. - if (!plan.runtimeBinding.hermetic() || host_libs_allowed(plan)) + if (!plan.runtimeBinding.hermetic() || host_libs_allowed(plan)) { + publish_reason(host_libs_allowed(plan) + ? "allow_host_libs: resolution at run time is the project's to arrange" + : "the runtime binding is not hermetic; the host loader also " + "consults ld.so.cache, which mcpp does not parse"); return report; + } auto searchDirs = runtime_search_dirs(plan); @@ -1193,9 +1237,16 @@ check_dlopen_surface(const mcpp::build::BuildPlan& plan) { return unit.kind == mcpp::build::LinkUnit::Binary || unit.kind == mcpp::build::LinkUnit::TestBinary; }); - if (!producesAProgram) return report; + if (!producesAProgram) { + publish_reason("this build produces no program; the surface is reached " + "from a process and belongs to whatever runs"); + return report; + } const auto artifacts = snapshot_link_artifacts(plan); - if (artifacts.empty()) return report; + if (artifacts.empty()) { + publish_reason("this build produced no linked artifact"); + return report; + } for (auto const& [artifact, stamp] : artifacts) { auto dir = artifact.parent_path(); diff --git a/tests/unit/test_loader_contract.cpp b/tests/unit/test_loader_contract.cpp index 31a5196f..52643e75 100644 --- a/tests/unit/test_loader_contract.cpp +++ b/tests/unit/test_loader_contract.cpp @@ -7,6 +7,10 @@ import mcpp.pack.host_requirements; import mcpp.manifest; import mcpp.runtime.elf; import mcpp.build.runtime_validation; +import mcpp.build.plan; +import mcpp.runtime.binding; +import mcpp.platform; +import mcpp.libs.json; namespace { @@ -265,4 +269,87 @@ TEST(ArtifactIdentity, VersionMatchIsAPathComponent) { EXPECT_EQ(artifact_identity_verdict(a), ArtifactVerdict::Mismatch); } +// ─── a pass with nothing to say does not erase what a pass measured ───────── +// +// The backend runs once per pass and one invocation can drive it more than +// once: `mcpp test` builds the library and then links the test binary, and a +// pass that links only a dependency's shared library has no program, so the +// dlopen surface is not its question to answer. +// +// That pass still decides what the documented place to look contains. The two +// copies of the record have opposite lifetimes -- the sidecar survives an +// invocation, `resolution.json` is regenerated from an empty object at the +// start of one -- so a pass that publishes nothing leaves `resolution.json` +// empty for an answer that was measured, and a pass that publishes its +// non-answer overwrites that answer with a blank. +// +// Stated here because no end-to-end shape reaches it: it needs two drives over +// one output directory where the SECOND one is the one that does not apply. +TEST(DlopenSurfaceRecord, ANonAnswerRepublishesTheAnswerAlreadyOnFile) { + namespace fs = std::filesystem; + if constexpr (!mcpp::platform::is_linux) { + SUCCEED() << "the record is ELF-shaped and this host links no ELF"; + return; + } + + auto root = fs::temp_directory_path() / "mcpp_dlopen_surface_record"; + std::error_code ec; + fs::remove_all(root, ec); + fs::create_directories(root); + struct Cleanup { fs::path d; + ~Cleanup() { std::error_code e; fs::remove_all(d, e); } } cleanup{root}; + + // A plan with no link units: it produces no program, which is exactly the + // pass whose answer is "not mine to give". + mcpp::build::BuildPlan plan; + plan.outputDir = root; + plan.runtimeBinding.loader = "/nonexistent/xlings-use-rpath-not-default-search"; + + const auto sidecar = root / ".mcpp-runtime-verdicts.json"; + auto regenerate_resolution = [&] { + std::ofstream out(root / "resolution.json"); + out << nlohmann::json{{"runtime", nlohmann::json::object()}}.dump(2) << '\n'; + }; + auto read_json = [](const fs::path& p) { + std::ifstream in(p); + return nlohmann::json::parse(in, nullptr, false); + }; + + // Drive one: nothing on file, so the non-answer is published WITH its + // reason. "did not apply" and "was never run" must not read the same. + regenerate_resolution(); + mcpp::build::runtime_validation::check_dlopen_surface(plan); + auto stored = read_json(sidecar); + ASSERT_TRUE(stored.is_object()) << "the sidecar was not written"; + ASSERT_TRUE(stored.contains("dlopen_surface")); + EXPECT_FALSE(stored["dlopen_surface"].value("reason", "").empty()) + << "a published non-answer carries the reason it did not apply"; + EXPECT_FALSE(read_json(root / "resolution.json")["runtime"]["dlopen_surface"] + .value("reason", "").empty()) + << "the documented place to look carries it too"; + + // A pass that DID apply now records a reading, under the key already on + // file. The key covers the contract, the SubOS stamp and the host-libs + // policy, none of which this test changes. + stored["dlopen_surface"] = nlohmann::json{ + {"members", 7}, {"walked", 7}, {"findings", nlohmann::json::array()}}; + { std::ofstream out(sidecar); out << stored.dump(2) << '\n'; } + + // Drive two, with `resolution.json` regenerated as an invocation would: + // the same plan that does not apply must neither overwrite the reading nor + // leave the published copy empty. + regenerate_resolution(); + mcpp::build::runtime_validation::check_dlopen_surface(plan); + + auto after = read_json(sidecar)["dlopen_surface"]; + EXPECT_EQ(after.value("members", 0), 7) + << "a non-answer replaced a reading taken under the same key"; + EXPECT_FALSE(after.contains("reason")); + + auto republished = read_json(root / "resolution.json")["runtime"]["dlopen_surface"]; + EXPECT_EQ(republished.value("members", 0), 7) + << "the reading survived in the sidecar but not where it is documented"; + EXPECT_EQ(republished.value("walked", 0), 7); +} + } // namespace