fix(index): an index-side change must not decide whether mcpp works - #349
Merged
Conversation
A published index can declare a client-version floor (index.toml min_mcpp).
`xlings update` rewrites the local tree in place, so when mcpp-index raised its
floor, every older mcpp went from working to broken — mid-session, on a machine
that had been fine sixty seconds earlier:
error: index requires mcpp >= 2026.8.3.3 but this is mcpp 0.0.109 [E0006]
error: ... package 'compat:compat.libarchive@3.8.7' not found
and there was no way back, because the readable tree had already been
overwritten. The refresh was the thing that broke the machine.
An index is DATA and mcpp is a PROGRAM. Publishing data must not be able to
disable the program. The invariant this installs:
an index-side change must never take mcpp from "works" to "does not work"
REFRESH IS NOW MONOTONE. `mcpp::xlings::update_index` — the one function every
refresh in the process passes through — archives each readable index tree,
lets the sync happen, re-judges, and rolls back anything that got worse. The
test is "usable before ⇒ usable after", not "usable after": a tree that was
already unreadable has nothing to roll back to. The 2026-07-08 index design
specified exactly this ("staged refresh keeps the last compatible snapshot")
and it was never implemented — the floor was checked in one place, the
descriptor reader, and the refresh path knew nothing about it.
Shape is archive/judge/restore rather than the stage-and-swap that design
assumed, because mcpp does not fetch indexes: it shells out to xlings, which
rewrites in place and takes no destination. Same guarantee, no cross-repo
dependency. A short local history (5 per tree) means "the newest snapshot this
binary can read" is answerable offline — the local, dependency-free version of
what openxlings/xlings#476 would provide for snapshots this machine never saw.
"UNREADABLE" IS NO LONGER SPELLED "NOT FOUND". A floor violation made every
descriptor read return nothing, which is the same answer as "this package is
not in this index". Two things went wrong downstream: the message that STOPPED
the build said `dependency '...': not found`, blaming the package and pointing
at publication or naming while the real answer (upgrade mcpp) had scrolled
away; and that same indistinguishable miss fed the refresh policy as "go
fetch", so the unusable state drove repeated refreshes of itself. It is now a
queryable fact — the refresh policy stops retrying, and all three terminal
not-found sites in prepare.cppm carry the cause via one helper.
MCPP_VERSION MOVED TO A LEAF MODULE (src/version.cppm). Not cosmetic: the guard
would not compile where it belongs. index_contract needs the version for one
comparison and was reaching it through toolchain.fingerprint → toolchain.detect
→ xlings, so "what version is this binary" transitively depended on the entire
toolchain-detection subsystem — which made it impossible for xlings to depend
on index_contract. The cycle the compiler reported was the layering saying the
constant was in the wrong place. check_version_pins.sh and the release docs
follow it.
TESTS THAT DID NOT EXIST. The floor had unit coverage of the pure predicate and
zero e2e, which is why both defects above survived: the predicate was right and
the behaviour around it was never run end to end. Adds 9 unit tests (including
"a refresh that breaks the index is rolled back") and e2e 185.
Two of those tests earned their keep immediately:
* hard links looked like the obvious way to make snapshots free, and the
round-trip test caught that a hard-linked snapshot ALIASES the live tree —
an in-place rewrite writes through the link and destroys the backup, which
is precisely the event the module exists to survive. Real copies; ~15 MB
for five, bounded by prune.
* the first e2e asserted "E0006 appears in the output" and passed on the
pre-fix binary (verified). It always appeared. Retargeted at the LAST
error, where the defect actually lived — now red before, green after.
Only helps users who upgrade to this version; anyone already stuck on an older
mcpp still needs either an upgrade or an index-side floor rollback.
Verifying the guard on a real refresh showed it working — three index trees archived, 3.2 MB, snapshot ids being the upstream commit shas — and also showed where I had put the store: `<home>/registry/data/.index-snapshots`, i.e. inside the directory that IS the index-repo namespace. Everything that enumerates index repos does it by listing subdirectories of `data/`. Fetcher::sorted_index_dirs returns every one of them with no filter, and xlings walks the same tree. The store was being handed to those scanners as if it were an index. It happens to be harmless today: the store has no `pkgs/`, so a descriptor lookup stats a path that does not exist and moves on. That is a property of someone else's loop, not of this design, and it would stop holding the moment the store's internal layout changed or a scanner started matching on something other than `pkgs/`. The store is now a sibling of the data root — `<home>/registry/index-snapshots` — where no index scanner can reach it. The defensive skip in `index_dirs` stays: that loop decides what gets archived, and an archive of the archive is the one mistake that would grow without bound. Verified on a real refresh: `data/` clean, store in the new location, same 3.2 MB. unit 55/55, e2e 176 passed / 0 failed.
Sunrisepeak
pushed a commit
that referenced
this pull request
Aug 6, 2026
`reexport` 要写进**已发布包**的 manifest(grpc 的 [feature-deps.codegen]), 于是冒出一个此前没人问过的问题:比它旧的 mcpp 读到会怎样? 答案曾经是整份加载失败,而且报错误导 —— `reexport = true` 被告知 「must be a string, inline dep table, or nested table」。 根因是**一个谓词兼任两职**:`looks_like_inline_dep_spec` 既判定「内联 spec 还是 嵌套命名空间表」,又枚举「哪些键有意义」。不认识的键因此走不到「未知选项」那条 路上 —— 表直接判不出是 spec,被当成命名空间。 后果不是提示不友好,而是**任何已发布包都永远无法采用新键**。这与 #349 确立的 是同一条性质:数据不得决定程序是否可用。 判据换轴:内联 spec 的判定是「它是否指名了一个来源」(path/version/git/workspace)。 嵌套命名空间表的键是**包名**,不会有包叫 `version`,所以不会误判。识别为 spec 之后,不认识的键记为降级(--strict 仍拒绝),与 xpkg 读取器的 xpkgUnknownKeys 「record rather than swallow」同一条纪律。 救不了已经发布出去的旧客户端,但从这一版起这类问题不再复发。
Sunrisepeak
added a commit
that referenced
this pull request
Aug 6, 2026
* feat(build): 库可以替用户拉起工具链,构建程序可以声明「一组文件」(#359) (2026.8.6.2) #355 让依赖产出的 host 工具可用之后,grpc-m 成了第一个真实使用者,并暴露出 两个缺口:工具**被构建了**却传不到消费者,以及 build.mcpp 无法安全地 glob 自己的输入。两者形状相同 —— **新增一种提供物/输入时,没有任何地方逼你回答 「它怎么传播」「它的指纹怎么取」**。这是本仓库反复付学费的「同一决策在 N 处 推导」的镜像:一个必答问题在**零处**被表达。 ## 提供物进表,传播由 `reexport` 决定 新增 `src/build/provisions.cppm`:ProvisionKind 表(tool / host-module / dep-dir),每行必须回答「裸名可寻址吗」「跨边要不要 reexport」;传播是一条 不动点,与 computeUsageRequirements 同形。 依赖 spec 新增 `reexport = true`(默认 false):把这条边的构建期提供物交给 **本包自己的消费者**。于是 grpc 可以在描述符里声明 protoc + 插件 + 规则模块, 它的用户只写一条依赖。 刻意不复用边上的 `visibility`:它默认就是 "public",搭车等于默认传播,而 「把工具交给消费者」是供应链主张,必须显式写下来。(设计初稿以「include_dirs 默认 private」类比,那条对本仓库不成立 —— prepare.cppm 无条件把 privateBuild.includeDirs 拷进 publicUsage;真正 private-only 的是 build.mcpp 注入的那些。) 裸名不再靠追加顺序决定。全限定 `MCPP_DEP_<NS>_<NAME>_BIN_<TOOL>` 总是发布; 裸名按与包身份同一套阶梯绑定(mcpplibs → compat → 无命名空间 → 唯一候选), 争用时给出诊断而不是默默选一个 —— 否则「版本错配不可表达」这条性质会从另一 扇门跑回来。 `dep_dir()` 与 host 模块一并沿同一条规则传播(此前分别只覆盖直接依赖、只认 root 的 dependencies)。 ## `rerun-if-changed-glob`:输入可以是一个集合 `build.mcpp` 的重跑键此前只有「文件内容」和「环境变量」两种形态,于是 glob **结构性不安全**:新增一个 .proto 不改变任何已声明文件的哈希,程序不重跑, 新文件静默不生成。 新指令的指纹是**排序后的匹配路径集合**,不含内容、size、mtime —— 内容已由 File 条目覆盖,而 mtime 在 checkout/容器/rsync 下不稳定(本仓库在 file_time_type 的 epoch 上摔过)。构建输出目录与 .git 永不进入集合,否则宽 模式会对着自己的产物无限重跑。 两层必须一起改:工程级 fast path 会整个跳过 prepare,而新增文件不移动任何 mtime —— 那正是它此前静默无效的地方。缓存记录因此新增 `root` 行,fast path 才能按每份缓存自己的根去求值。protocol → 2,cache epoch → 2。 ## 让库能按平台裁剪,让子构建说出真话 `ConditionalConfig` 带着三张依赖表却独缺 `feature-deps`,正是该类型注释为 #258 记录过的失败形状。补上 `[target.<sel>.feature-deps.<f>]`:否则一旦由 库决定请求什么,不支持的平台就变成用户改不掉的硬错(Windows 的工具子构建 缺陷至今未定位)。feature 本身无条件注册,只有它拉进来的东西是条件性的。 顺带把条件依赖表收进同一条 funnel:此前三个调用点合并构建输入,只有 root 那个还额外合并依赖表,而注释把它说成「out of scope」。 工具子构建失败现在带上 chain、scratch 目录和可直接重跑的命令, `MCPP_TOOL_BUILD_VERBOSE=1` 关掉内层输出过滤 —— Windows 那个缺陷至今未定位 的直接原因就是真正的报错没进日志。 ## 其他 - `path_matches_glob` 从 scanner 的匿名命名空间搬进 `mcpp.modgraph.glob`: glob 输入的指纹必须与 `sources = [...]` 选中同一批文件,两个「应该一致」的 匹配器就是那个病。 - 内置 xlings 升到 2026.8.6.2。 ## 验证 - 新单测:传播规则(未 reexport 不外泄 / 逐跳传递 / 环终止)、裸名阶梯四级、 glob 指纹(增删变、改内容不变、输出目录不参与、跨平台排序一致)。 - 新 e2e 193/194/195。193 已用「让 propagate 忽略 reexport」验证会变红。 - 新增 `EveryDependencySpecKeyIsAccepted`:dep-spec 的键必须同时写进两处, 漏掉第一处时诊断是**误导性**的(bool 被告知「必须是字符串或嵌套表」), #359 加 `reexport` 时就撞上了。 - 本机 e2e 179 passed / 7 failed / 8 skipped;7 条在 main 上逐条复现,均为环境性。 * fix(ci): 自举 pin 指向索引里已经不存在的版本 `ci-aarch64-fresh-install` 的 self-host 步报 `xlings: version '2026.8.5.3' not found for 'mcpp' — available: 2026.8.6.1`。 自举 pin 是「从哪个已发布版本开始自举」,按设计不随每次发布走;但它是**上界 之下的选择**,不是一个可以无限期不动的常量 —— xim-pkgindex 对 mcpp 只暴露最 新条目,pin 落在它之外就成了「装不上」。判据因此是「该版本此刻仍可解析」, 而不是「它是不是最新」。 * feat(build): feature-dep 与已声明依赖同键时按加法合并 `mergeActiveFeatureDeps` 用的是 `try_emplace`,键已存在就丢弃 feature 的 spec。这条规则对 version/path/git 是对的(条件段不该静默覆盖无条件段),对 `tools` / `reexport` 却丢掉了这个 feature 存在的全部理由。 gRPC 就是反例,而且是本 issue 的头号用例:它**无条件**依赖 compat.protobuf, 而 `codegen` feature 需要往**同一条边**加 `tools = ["protoc"], reexport = true`。 挪到无条件条目上不可行 —— 那会让每个 grpc 消费者都构建 protoc(~157 个额外 TU),而这正是 tools 默认关闭要避免的。 于是:tools / features 取并集,host-module / reexport 取或,身份字段不合并。 与逐边 feature 请求本来遵循的规则一致。 e2e 193 补第 3 段覆盖它,并已用「不合并 tools」验证会变红 (`rulepkg: no codegen tool`)。 * test(e2e): 195 用诊断而不是 --strict 判定 `--strict` 会把**所有**降级提升为错误,包括与本测试无关的那些 —— Windows 上 clang 报「this toolchain and platform combination emits no GNU depfile」,于是 195 在 Windows 上因为一件它并不关心的事而红。 本测试要断言的是「没有任何谓词匹配的平台上,请求该 feature 不是未知 feature 错误」,那就直接断言那条诊断不出现。 * fix(manifest): 不认识的依赖键降级而不是让整份 manifest 加载失败 `reexport` 要写进**已发布包**的 manifest(grpc 的 [feature-deps.codegen]), 于是冒出一个此前没人问过的问题:比它旧的 mcpp 读到会怎样? 答案曾经是整份加载失败,而且报错误导 —— `reexport = true` 被告知 「must be a string, inline dep table, or nested table」。 根因是**一个谓词兼任两职**:`looks_like_inline_dep_spec` 既判定「内联 spec 还是 嵌套命名空间表」,又枚举「哪些键有意义」。不认识的键因此走不到「未知选项」那条 路上 —— 表直接判不出是 spec,被当成命名空间。 后果不是提示不友好,而是**任何已发布包都永远无法采用新键**。这与 #349 确立的 是同一条性质:数据不得决定程序是否可用。 判据换轴:内联 spec 的判定是「它是否指名了一个来源」(path/version/git/workspace)。 嵌套命名空间表的键是**包名**,不会有包叫 `version`,所以不会误判。识别为 spec 之后,不认识的键记为降级(--strict 仍拒绝),与 xpkg 读取器的 xpkgUnknownKeys 「record rather than swallow」同一条纪律。 救不了已经发布出去的旧客户端,但从这一版起这类问题不再复发。 * fix(toolchain): 固定的 host 工具链装不上时,回退到已经在本机可用的 target 工具链 `ci-aarch64-fresh-install` 在 aarch64 上构建 xlings 时死在: [error] download failed for xim:gcc@16.1.0: HTTP 404 error: host toolchain for build.mcpp ('gcc@16.1.0'): ... 链条是:图里有包带 build.mcpp ⇒ 需要 HOST 编译器 ⇒ 取 `[toolchain] default` ⇒ 那是 `gcc@16.1.0`,而 xim 的 gcc 声明 `archs = { "x86_64" }` ⇒ 404。 `[toolchain]` 的键是 OS,**没有 arch 这根轴**(#367),所以 aarch64 问的和 x86_64 问的是同一个包。结果是:凡是依赖图里出现 build.mcpp 的工程,在 aarch64 上就是死路。 但这台机器上**已经有**一个能用的编译器:当解析出的 target 与 host 只差 libc 环境(aarch64-linux-musl 跑在 aarch64 Linux 上),target 工具链就是本机原生的 —— 它在这里能跑,它产出的二进制在这里也能跑,拿它编译一个构建程序完全成立。 因此:固定的 host 工具链取不到时,若 arch 与 os 与 host 相同,就用 target 工具 链,并发一条命名双方的告警。成功路径一字未改;此前的死路变成一次可解释的降级。 #367 记录的仍是根因(host 工具链选择缺 arch 轴),这条只是让它不再是绝路。 * test(ci): aarch64 自举步的 xlings 一半用的是**已安装**的 mcpp,不是被审的代码 同一条 job 里,mcpp 用 "$m"(刚构建出来的)验证,紧接着的 xlings 构建却用 裸 `mcpp` —— 即已安装的那个。于是这一半从来没看见过 PR。 这正是上面那段注释为 clone ref 记录过的缺陷,只是位置往下挪了几行;暴露方式 也一样:一个**只在这条构建里出现**的 aarch64 失败,其修复在这里无法验证,因为 跑它的二进制早于该修复。 顺带两处:`m` 改为绝对路径(`cd /tmp/xlings-src` 之后还要用),并显式把 MCPP_HOME 传下去 —— mcpp 从**二进制所在位置**推导 home,放在 /tmp/mcpp-src 下的二进制否则会认领一个空 home,把整套生态重新自举一遍。 * fix(toolchain): aarch64 上原生 GCC 的载荷是 musl-gcc,不是 glibc gcc (#367) `ci-aarch64-fresh-install` 报 `download failed for xim:gcc@16.1.0: HTTP 404`。 **先前的归因是错的**:我判成「`[toolchain]` 缺 arch 轴」,并动手给 manifest 加了 一根选择器轴。生态其实**早就支持 aarch64** —— xlings-res 的 musl-gcc 发行版里 就有 `musl-gcc-16.1.0-linux-aarch64.tar.gz`,而 `xim:gcc` 声明的是 `archs = { "x86_64" }`。所以缺的不是让用户去写 arch,而是 mcpp 问错了包。 同一台机器上 target 一侧是对的: `gcc@16.1.0 → aarch64-linux-musl → xim-x-musl-gcc`。因为 target 一侧把 triple 注入了 spec,`to_xim_package` 的 musl 分支便按「同一 target,两种载荷形态」选中 了原生包。而 build.mcpp 的 **host** 解析刻意不注入 target,于是落到最后那条 glibc 分支,拿到只有 x86_64 资产的 `gcc`。 「这个 spec 在这台机器上对应哪个载荷」正是 `to_xim_package` 存在的意义,上面 那条 musl 分支已经在回答它的另一半。所以修在这里:Linux 上瞄准本机的 GCC spec, 在非 x86_64 架构上解析到 `musl-gcc`。用户不该被要求去编码「某个工具链包是为 哪些架构构建的」。 同时撤回上一版那个「host 工具链装不上就回退到 target 工具链」的兜底 —— 它是 错误归因的产物;正确解析之后它是死代码,而一条失败路径上的隐式回退不该白留。 判定抽成自由函数并接收 host arch,而不是读编译期常量:这样 aarch64 的答案能在 x86_64 机器上被测到 —— 而「测一个手边没有的架构」正是重点。 * docs(release): 自举 pin 的失败又被归因成「索引删了版本」,第三次 09-release.md §5 已经为 3b1cb6b 记过一次这个更正,而本 PR 的 c1a3e45 又写了一遍 「指向索引里已经不存在的版本」。它是错的:openxlings/xim-pkgindex 与 d2learn/xim-pkgindex 都列着 63 个 mcpp 版本,`2026.8.5.3` 在里面。 值得注意的是 `available:` 实际列出的东西 —— 只有一个版本,正是那次 job 刚装上 的那个。这是「已安装版本」的形状,不是索引清单;而那一步解析的是 .xlings.json 的 **workspace** pin,workspace 作用域正是既有的那个陷阱。真实机制未坐实,不写 成结论。 pin 的 bump 本身没问题(§4 明确说这是合理的做法,而且它确实解开了那条 job)。 教训更窄,而且反复被重学:**在断言「索引删了它」之前,先读索引。** 一条 curl 就能定。 --------- Co-authored-by: speak-agent <x.d2learn.org@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
版本
2026.8.3.5。方案文档:.agents/docs/2026-08-03-index-availability-must-not-decide-mcpp-availability.md背景分析:
.agents/docs/2026-08-03-index-floor-should-degrade-not-brick.md问题
索引可以声明客户端版本下限(
index.tomlmin_mcpp)。xlings update原地覆盖本地树,所以 mcpp-index 抬了一次下限之后,所有旧版 mcpp 当场从「能用」变成「不能用」:而且没有退路 —— 能用的那份树已经被覆盖掉了。刷新本身就是把机器弄坏的那个动作。
索引是数据,mcpp 是程序。发布数据不该能让程序失效。本 PR 立的不变量:
改动
刷新现在是单调的
mcpp::xlings::update_index(全进程唯一的刷新入口)在同步前归档每一棵可读的索引树,同步后复核,变差就回滚。判据是「刷新前能用 ⇒ 刷新后必须仍能用」,不是「刷新后必须能用」—— 本来就不可读的树没有可回滚的东西。2026-07-08 的索引设计明文写过这个行为("staged refresh keeps the last compatible snapshot"),从未实现:下限只在描述符读取处检查过一次,刷新路径对它一无所知。
形状是 archive/judge/restore 而不是设计设想的 stage-and-swap,因为 mcpp 不下载索引 —— 它 shell out 给 xlings,后者原地覆盖且不接受目标目录。效果等价,零跨仓依赖。
外加一条短的本地历史(每棵树 5 份),于是「我这个版本能读的最新快照」离线就能回答 —— 这是 xlings#476 的本地退化版,覆盖这台机器见过的快照(开发机/CI 缓存机的绝大多数情况)。
「读不了」不再拼写成「不存在」
违反下限会让该树每次描述符读取返回空,与「包确实不在」同一个答案。两处下游因此出错:
dependency '...': not found—— 把责任推给包,指向发布/命名,而真正的答案(升级 mcpp)早已滚出屏幕;现在它是一个可查询的事实:刷新策略据此停止徒劳重试,
prepare.cppm里三处终止性 not-found 都通过一个with_index_cause()helper 带上真正的原因。MCPP_VERSION移到叶子模块src/version.cppm不是洁癖 —— 守卫装不进它该在的位置,编译器直接报了循环依赖:
index_contract只为一次比较需要这个常量,却因此传递依赖了整个工具链探测子系统,反过来使得xlings不可能依赖index_contract—— 而刷新守卫恰恰必须装在那里。编译器报的那个环,就是分层在说常量放错了地方。check_version_pins.sh与三处发布文档同步跟随。测试:之前一条都没有
下限行为此前只有纯谓词单测(
floor_violation()),tests/e2e/里零覆盖 —— 这正是上面两个缺陷能一直存在的原因:谓词是对的,谓词周围的行为从没被端到端跑过。新增 9 条单测(含「刷新弄坏索引必须回滚」)+
tests/e2e/185_index_floor_degrades.sh。其中两条测试当场就赚回了成本:
prune封顶)。验证
check_version_pins.sh范围与边界