Skip to content

Commit 940d9f2

Browse files
fix(build): a dlopen-surface pass that does not apply publishes a reason, and never over an answer (2026.9.10.2) (#600)
* fix(build): a dlopen-surface pass that does not apply publishes a reason, and never over an answer (2026.9.10.2) `check_dlopen_surface` returned without writing in four cases: not Linux, a non-hermetic binding or `allow_host_libs`, a plan producing no program, a plan producing no linked artifact. Omitting the record makes "the check did not apply" and "the check was never run" the same reading. It does more than omit a sentence, because 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. 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. Every early return now publishes a record carrying its `reason`. 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. Publishing the reason alone would have turned a silent absence into a loud wrong answer. WHAT WAS MEASURED, AND WHAT WAS NOT. Against the released 2026.9.10.1 in a sandbox, `.agents/docs/2026-09-10-596-verify.sh` passed sections A to C and section D reported `resolution.json has no runtime.dlopen_surface`; that run was also the run that installed the dependency. Run again against the same binary in the same sandbox with the registry warm, section D passes with `dlopen_surface examined 15 of 15 members`. The ordering of drives on the cold run was not captured and has not been reproduced, so what is repaired here is the code path that can produce that reading, recorded as such in section 7 and section 8.6 of the design record rather than as a diagnosis of that run. The invariant is a unit test rather than an end-to-end case: reaching it needs two drives over one output directory where the second is the one that does not apply, and no project shape names that. Both halves were refuted with the repair removed -- without the republish branch the reading becomes 0 members, without the publish the sidecar is not written at all. The verification script gained the matching criterion: a published non-answer counts as a failure. * docs(33): the record's `reason` key, in both languages A reader who finds `{"members": 0, "walked": 0, "findings": [], "reason": ...}` has to be told what it means, and told to treat it as "not measured" rather than as a clean result. Four reasons are published; 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 is its own confusion. * test(596-verify): section D names the record it read, and counts them The object was whatever `find` reached first. A run that also builds a dependency can leave more than one resolution.json under the work tree, and traversal order is not a property of the record being tested. The probe builds one project, so the count is the assertion and the file that was read is printed beside the result. --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
1 parent c18fa61 commit 940d9f2

9 files changed

Lines changed: 287 additions & 8 deletions

File tree

.agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,13 @@ already filed as mcpp-index#376, reached this time through
574574
and not here, from the same pinned payload. R7 now serves the adapter through
575575
`compat:opencl`, so the practical consequence is gone; what is still
576576
unexplained is why their machine supplied `libOpenCL.so.1` without it.
577+
* **The one cold-registry run in which section D found no record (§8.6).** The
578+
released 2026.9.10.1 published nothing for a project it answers for on every
579+
warm run of the same script in the same sandbox. The code path that can
580+
produce that reading is repaired, and the repair is stated by a unit test;
581+
the ordering of backend drives on that particular run was not captured and
582+
has not been reproduced. A second cold sandbox -- a fresh registry, the
583+
dependency built in the same invocation -- is what would close it.
577584
* **What R6 does to an executable that is itself a plugin host.** Hiding
578585
`libc++.a` and `libc++abi.a` means a library `dlopen`ed later cannot resolve
579586
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
646653
sitting on top of them. A check whose noise is repaired shows what the noise
647654
was covering, which is the third time this issue has produced that shape.
648655

656+
## 8.6 What the sandbox raised, and what the second run of it settled
657+
658+
The verification script was run against the released 2026.9.10.1 in a sandbox
659+
and section D failed -- `resolution.json has no runtime.dlopen_surface` -- on a
660+
build whose sections A to C had just passed. That run was also the run that
661+
installed `compat:sycl-runtime` into the sandbox registry.
662+
663+
Run again against the same binary in the same sandbox, with the registry now
664+
warm, section D passes: `dlopen_surface examined 15 of 15 members`. So the
665+
absence is not a property of the released check on this project; it appeared
666+
once, on the cold run, and no second observation of it exists. THAT IS RECORDED
667+
AS AN OPEN OBSERVATION RATHER THAN A DIAGNOSIS, because the difference between
668+
the two runs -- which drives the backend performed, and in what order -- was
669+
not captured while the failing run was in front of us.
670+
671+
What the failure did expose, by sending us to read the code, is a path that can
672+
produce exactly that reading and should not exist regardless. `check_dlopen_surface`
673+
returned without writing in four cases: a non-hermetic binding, `allow_host_libs`,
674+
a plan producing no program, a plan producing no artifact. (A target that is not
675+
Linux still publishes nothing, and should: the record is ELF-shaped.) Omitting a
676+
record makes "did not apply" and "was never run" the same reading,
677+
which is the failure this repository names most often. And it does more than
678+
omit a sentence, because the two copies of the record have opposite lifetimes:
679+
the sidecar survives an invocation, while `resolution.json` is regenerated from
680+
an empty object at the start of one. The backend runs once per drive and a
681+
single invocation can drive it more than once -- `mcpp test` builds the library
682+
and then links the test binary -- so a drive that links only a dependency's
683+
shared library has no program, has nothing to answer, and yet decides what the
684+
documented place to look finally contains.
685+
686+
THE REPAIR HAS TWO HALVES, and only one of them was obvious. Every early return
687+
now publishes a record carrying its `reason`, so a non-answer is legible. And a
688+
non-answer republishes a reading already on file under the same key rather than
689+
replacing it with a blank: the key covers the contract hash, the SubOS stamp
690+
and the host-libs policy, not the link units, so a reading taken under it is
691+
still about this farm and this policy, and a key that moved has already cleared
692+
the record before this runs. Publishing the reason alone would have converted a
693+
silent absence into a loud wrong answer.
694+
695+
The invariant is stated in a unit test rather than end to end
696+
(`DlopenSurfaceRecord.ANonAnswerRepublishesTheAnswerAlreadyOnFile`): reaching it
697+
requires two drives over one output directory where the SECOND is the one that
698+
does not apply, and no project shape names that. The verification script gained
699+
the matching criterion -- a published non-answer counts as a failure -- because
700+
without it the next such overwrite would read as a pass.
701+
649702
## 9. What this touches, across the three repositories
650703

651704
The review that asks the other question: not "is each repair right" but "what

.agents/docs/2026-09-10-596-verify.sh

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,15 +136,38 @@ section "D. mcpp reports a dlopen surface it cannot satisfy"
136136
# fails the next time the wording improves. Both denominators are asserted for
137137
# the reason they exist -- "no findings" and "nothing was examined" must not
138138
# read the same.
139-
res=$(find "$work" -name resolution.json 2>/dev/null | head -1)
139+
#
140+
# THE OBJECT IS SELECTED BY IDENTITY, NOT BY WHERE `find` ARRIVES FIRST. The
141+
# probe project's own build tree is `$work/target/<triple>/<fingerprint>`, and
142+
# a run that also builds a dependency can leave more than one resolution.json
143+
# under the work tree. "The first one" is a property of directory traversal
144+
# order, not of the record being tested, so the count is asserted and named.
145+
res=""
146+
res_n=0
147+
for candidate in "$work"/target/*/*/resolution.json; do
148+
[ -f "$candidate" ] || continue
149+
res_n=$((res_n + 1))
150+
res="$candidate"
151+
done
152+
if [ "$res_n" -gt 1 ]; then
153+
fail "D: $res_n resolution.json files under $work/target; the probe builds one project"
154+
res=""
155+
fi
140156
if [ -n "$res" ] && command -v python3 >/dev/null 2>&1; then
157+
ok "reading ${res#"$work"/}"
141158
python3 - "$res" <<'PY'
142159
import json, sys
143160
doc = json.load(open(sys.argv[1]))
144161
rec = doc.get("runtime", {}).get("dlopen_surface")
145162
if rec is None:
146163
print("ASSERT-FAIL: resolution.json has no runtime.dlopen_surface")
147164
sys.exit(1)
165+
# A published non-answer carries its reason. Distinguishing it from a real
166+
# reading is the whole point of publishing one: this assertion is what caught
167+
# the check erasing its own record on a second pass.
168+
if rec.get("reason"):
169+
print(f"ASSERT-FAIL: the check did not apply: {rec['reason']}")
170+
sys.exit(1)
148171
members, walked = rec.get("members", 0), rec.get("walked", 0)
149172
if members <= 0:
150173
print(f"ASSERT-FAIL: dlopen_surface examined {members} members")
@@ -160,8 +183,8 @@ if bad:
160183
print("ok: no driver soname is missing from the farm")
161184
PY
162185
[ $? -eq 0 ] || fails=$((fails + 1))
163-
else
164-
skip "D: no resolution.json (section C did not build) or no python3"
186+
elif [ "$res_n" -le 1 ]; then
187+
skip "D: no resolution.json under $work/target (section C did not build) or no python3"
165188
fi
166189

167190
# ---------------------------------------------------------------------------

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,39 @@
55
66
## [Unreleased]
77

8+
## [2026.9.10.2] - 2026-09-10
9+
10+
### dlopen 面检查:不适用的那一趟也会发布记录,并且不会盖掉已经量出来的答案
11+
12+
`check_dlopen_surface` 有多条提前返回:绑定非 hermetic、设置了 `allow_host_libs`
13+
本次构建不产出程序、本次构建没有链接产物。它们都直接返回、什么都不写,于是「没适用」
14+
与「没检查过」读成同一个——这正是本仓库记下次数最多的那种失败,而它出现在一条为消除
15+
这种失败而写的检查里。(非 Linux 目标仍然不发布记录:这条记录是 ELF 形状的,对一个
16+
本次构建根本不产出的格式给出空答案是另一种混淆。)
17+
18+
它不止是少一句话。记录有两份副本,寿命相反:sidecar 跨调用存活,而
19+
`resolution.json``prepare_build` 在每次调用开头从空对象重写。后端按「趟」运行,
20+
一次调用可以驱动它不止一次(`mcpp test` 先构建库、再链接测试程序),只链接依赖的
21+
共享库的那一趟没有程序,这个问题本就不归它答——而它同样决定了**文档指定的查看
22+
位置**里最后剩下什么。
23+
24+
现在这四条提前返回都发布一条带 `reason` 的记录;并且当同一 key 下已经有一次真实读数
25+
时,不适用的那一趟把它**重新发布**,而不是用空白覆盖。key 由契约哈希、SubOS 戳记
26+
与 host-libs 策略构成,不含链接单元,所以同 key 的读数仍然是关于这个 farm 和这个
27+
策略的;key 变了本来就会先清空记录。
28+
29+
**测量到什么,以及什么没有被复现。** 沙箱里用发布的 2026.9.10.1 跑
30+
`.agents/docs/2026-09-10-596-verify.sh`:A/B/C 三节通过,D 节报
31+
`resolution.json has no runtime.dlopen_surface`,那一次注册表是冷的——依赖是在同一次
32+
运行里装上的。注册表转热之后,同一份脚本、同一个发布二进制、同一个沙箱,D 节通过并
33+
打印 `dlopen_surface examined 15 of 15 members`。因此「哪一趟的顺序产生了那次缺席」
34+
并没有被复现;这里修的是**能够产生它的那条代码路径**,以及它此后不再能产生它。
35+
36+
不变量由单测陈述(`DlopenSurfaceRecord.ANonAnswerRepublishesTheAnswerAlreadyOnFile`):
37+
没有端到端形状够得着它——它需要同一个输出目录上的两趟,且第二趟必须是不适用的那一趟。
38+
验证脚本同时补上对应判据:发布出来的非答案带 `reason` 时按失败计,否则同样的覆盖下次
39+
仍会被读成通过。
40+
841
## [2026.9.10.1] - 2026-09-10
942

1043
三条改动来自同一次排查(#596):一个 SYCL 工程构建全绿、运行时以退出码 134 终止且

docs/33-authoring-an-adapter.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,24 @@ The full result, including both denominators, is published as
120120
failed to build enumerates nothing, and "no findings" would otherwise be
121121
indistinguishable from "nothing was examined".
122122

123+
A build the check does not apply to publishes the same record with a `reason`
124+
and no reading:
125+
126+
```json
127+
{ "members": 0, "walked": 0, "findings": [],
128+
"reason": "this build produces no program; the surface is reached from a process and belongs to whatever runs" }
129+
```
130+
131+
The four reasons are: the runtime binding is not hermetic, `allow_host_libs` is
132+
set, the build produces no program, and the build produced no linked artifact.
133+
A target that is not Linux publishes no record at all, because the record is
134+
ELF-shaped and an empty answer about a format the build never produces would be
135+
its own confusion.
136+
137+
A test that reads this record should treat a `reason` as "not measured" rather
138+
than as a clean result -- "did not apply" and "was never run" are the pair this
139+
record exists to keep apart.
140+
123141
## Current limitations
124142

125143
- **Linux only, by construction.** macOS's dyld and the Windows PE loader have

docs/zh/33-authoring-an-adapter.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,20 @@ on this artifact's search path:
101101
即使没有任何发现,`members``walked` 也会被发布。一个构建失败的农场枚举出零个成员,
102102
否则「没有发现」与「什么都没检查」就读起来一模一样。
103103

104+
这条检查不适用的构建,发布同一条记录,但只带 `reason`、不带读数:
105+
106+
```json
107+
{ "members": 0, "walked": 0, "findings": [],
108+
"reason": "this build produces no program; the surface is reached from a process and belongs to whatever runs" }
109+
```
110+
111+
四种理由是:运行时绑定非 hermetic;设置了 `allow_host_libs`;本次构建不产出程序;本次构建
112+
没有链接产物。目标不是 Linux 时不发布任何记录 —— 这条记录是 ELF 形状的,对一个本次构建
113+
根本不产出的格式给出「空答案」本身就是另一种混淆。
114+
115+
读这条记录的测试应当把 `reason` 当作「没有测到」而不是「测了,结果干净」—— 「没适用」与
116+
「没检查过」正是这条记录存在的意义所在。
117+
104118
## 当前边界
105119

106120
- **按构造只适用于 Linux。** macOS 的 dyld 与 Windows 的 PE 加载器没有对应的这一层,

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "2026.9.10.1"
3+
version = "2026.9.10.2"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

modules/versioning/src/version.cppm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ import std;
3131

3232
export namespace mcpp {
3333

34-
inline constexpr std::string_view MCPP_VERSION = "2026.9.10.1";
34+
inline constexpr std::string_view MCPP_VERSION = "2026.9.10.2";
3535

3636
} // namespace mcpp

src/build/runtime_validation.cppm

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,15 +1151,59 @@ latest_stored_verdict(const std::filesystem::path& targetRoot) {
11511151
mcpp::platform::elf::DlopenSurfaceReport
11521152
check_dlopen_surface(const mcpp::build::BuildPlan& plan) {
11531153
mcpp::platform::elf::DlopenSurfaceReport report;
1154+
1155+
// The record below is ELF-shaped, so a host that links no ELF publishes
1156+
// nothing rather than an empty answer about a format it never produces.
11541157
if constexpr (!mcpp::platform::is_linux) return report;
11551158

1159+
// ON LINUX, A NON-ANSWER IS PUBLISHED, NOT OMITTED -- AND NEVER OVER AN
1160+
// ANSWER.
1161+
//
1162+
// Returning without writing made "the check did not apply" and "the check
1163+
// was never run" the same reading, which is the confusion this repository
1164+
// has recorded most often. It is worse than a missing sentence here,
1165+
// because the two copies of the record have opposite lifetimes:
1166+
// `prepare_build` regenerates `resolution.json` from an empty object at
1167+
// the start of an invocation while the sidecar survives it, so a pass that
1168+
// returns without publishing leaves the DOCUMENTED place to look empty for
1169+
// an answer that was measured.
1170+
//
1171+
// The backend runs once per pass and one invocation can drive it more than
1172+
// once -- `mcpp test` builds the library and then links the test binary --
1173+
// so a pass that links only a dependency's shared library has no program
1174+
// and nothing to say. Such a pass republishes what is on file rather than
1175+
// replacing it: the key covers the contract, the subos stamp and the
1176+
// host-libs policy, not the link units, so a reading taken under the same
1177+
// key is still about this farm and this policy. A key that moved has
1178+
// already cleared the record before this runs.
1179+
auto publish_reason = [&](std::string_view why) {
1180+
const auto key = post_link_key(plan);
1181+
auto doc = read_cache(plan.outputDir);
1182+
if (doc.is_object() && doc.value("post_link_key", "") == key) {
1183+
auto it = doc.find(std::string(kDlopenSurfaceRecord));
1184+
if (it != doc.end() && it->is_object() && !it->contains("reason")) {
1185+
persist_post_link(plan, kDlopenSurfaceRecord, key, *it);
1186+
return;
1187+
}
1188+
}
1189+
persist_post_link(plan, kDlopenSurfaceRecord, key,
1190+
nlohmann::json{{"members", 0}, {"walked", 0},
1191+
{"findings", nlohmann::json::array()},
1192+
{"reason", std::string(why)}});
1193+
};
1194+
11561195
// THE SAME APPLICABILITY THE ARTIFACT VERDICT HAS, and for the same
11571196
// reason. Under a non-hermetic binding the host loader also consults
11581197
// `ld.so.cache`, which mcpp deliberately does not parse, so "not on the
11591198
// path mcpp computed" is not evidence of anything. `allow_host_libs` is
11601199
// the user's statement that resolution is theirs to arrange.
1161-
if (!plan.runtimeBinding.hermetic() || host_libs_allowed(plan))
1200+
if (!plan.runtimeBinding.hermetic() || host_libs_allowed(plan)) {
1201+
publish_reason(host_libs_allowed(plan)
1202+
? "allow_host_libs: resolution at run time is the project's to arrange"
1203+
: "the runtime binding is not hermetic; the host loader also "
1204+
"consults ld.so.cache, which mcpp does not parse");
11621205
return report;
1206+
}
11631207

11641208
auto searchDirs = runtime_search_dirs(plan);
11651209

@@ -1193,9 +1237,16 @@ check_dlopen_surface(const mcpp::build::BuildPlan& plan) {
11931237
return unit.kind == mcpp::build::LinkUnit::Binary
11941238
|| unit.kind == mcpp::build::LinkUnit::TestBinary;
11951239
});
1196-
if (!producesAProgram) return report;
1240+
if (!producesAProgram) {
1241+
publish_reason("this build produces no program; the surface is reached "
1242+
"from a process and belongs to whatever runs");
1243+
return report;
1244+
}
11971245
const auto artifacts = snapshot_link_artifacts(plan);
1198-
if (artifacts.empty()) return report;
1246+
if (artifacts.empty()) {
1247+
publish_reason("this build produced no linked artifact");
1248+
return report;
1249+
}
11991250

12001251
for (auto const& [artifact, stamp] : artifacts) {
12011252
auto dir = artifact.parent_path();

0 commit comments

Comments
 (0)