Skip to content
Merged
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
53 changes: 53 additions & 0 deletions .agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 26 additions & 3 deletions .agents/docs/2026-09-10-596-verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,38 @@ 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/<triple>/<fingerprint>`, 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]))
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")
Expand All @@ -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

# ---------------------------------------------------------------------------
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 终止且
Expand Down
18 changes: 18 additions & 0 deletions docs/33-authoring-an-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/zh/33-authoring-an-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 加载器没有对应的这一层,
Expand Down
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
2 changes: 1 addition & 1 deletion modules/versioning/src/version.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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
57 changes: 54 additions & 3 deletions src/build/runtime_validation.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading