Skip to content

fix(modgraph): a directory name the code page cannot spell must not end the build (2026.8.27.2) - #517

Merged
Sunrisepeak merged 3 commits into
mainfrom
fix/issue516-acp-path-narrowing
Aug 27, 2026
Merged

fix(modgraph): a directory name the code page cannot spell must not end the build (2026.8.27.2)#517
Sunrisepeak merged 3 commits into
mainfrom
fix/issue516-acp-path-narrowing

Conversation

@Sunrisepeak

@Sunrisepeak Sunrisepeak commented Aug 27, 2026

Copy link
Copy Markdown
Member

Closes #516.

一句话

不是解压问题,解压是对的。 抛异常的是 mcpp 自己,在
src/modgraph/scanner.cppm:238dir.filename().string() —— 而这是 #230 的同一处漏网:
#231 加固了三个窄化站点,漏掉了同一个 walk 循环里早一行执行的第四处。

根因

MSVC 的 path::string()WideCharToMultiByte(ACP),遇到当前代码页拼不出的字符就抛
std::system_error,what() 恰好是 issue 里那句
No mapping for the Unicode character exists in the target multi-byte code page.

is_excluded_walk_dir() 是 walk 循环体的第一行,每个目录条目过一次 —— 所以它比
#231 加固过的 path_matches_glob 更早执行。

触发条件比看上去宽:include_dirs = { "*" } 会从解压根无界递归遍历整棵上游源码树
(在 mcpplibs/mcpp-index 891b2f7 上量:130 个 recipe 里 103 个含以 * 开头的 glob;
这个比例随索引增长而变,写下的是那个 commit 的数)。cpp-httplib 带了
test/www/日本語Dir/,于是三个 httplib 测试在 Windows 上一起挂,Linux/macOS 全绿 ——
因为那两个平台上 path::string() 不做任何编码转换。

归属反证

有人会怀疑是 xlings 解压时按 ANSI 写坏了名字。恰恰相反:
ERROR_NO_UNICODE_TRANSLATION 的前提是宽名里存在 ACP 拼不出的字符。若盘上是
mojibake(UTF-8 字节被 CP1252 逐字节打散),那些字符逐个都在 CP1252 里,mcpp 反而不会抛。
mcpp 抛了,正好证明 xlings 写对了。 xlings 自己的同类隐患另立 openxlings/xlings#571

改动

不是第四个 try/catch,而是按用途分三档的一条规则:

用途 写法
与 ASCII 字面量比较 path 比,根本不窄化
稳定身份(hash / digest) u8string() —— 各平台同一串字节,不碰代码页
交给编译器 / ninja / CDB mcpp::modgraph::try_narrow(),处理 nullopt
  • P0 is_excluded_walk_dir 改为静态 path 常量比较。行为与原窄串比较逐字同解
    (大小写敏感、ASCII 字面量无损转换),且比原来"每条目一次 std::string 构造"更便宜
  • P1 try_narrow() 收敛为唯一入口;path_matches_globscan_file 的诊断文案、
    pack/digest.cppmscaffold/template.cppm 全部归并。
  • 跳过不再静默(口径 B):按目录报告一次,走 mcpp.diagdegraded 通道 ——
    它的批次不变式本就要求"因前提不满足而少做事必须给出 impact"。
    src/modgraph / src/manifest 是 leaf 层(全仓没有一条到 mcpp.ui/mcpp.diag
    import 边),所以 glob 层记录cli::run 的 scope guard 排空上报
  • 顺带修掉一个真实的跨平台不一致:interface_set_digest.string() 折入文件名,
    输入是对已发布包的未过滤走查 —— 除了会抛,还让"Linux 打包 / Windows 校验"的同一棵树
    对非 ASCII 名字给出不同摘要,表现为 does not match what was packaged
    改用 u8string();纯 ASCII 名字字节不变,已发布包摘要不变。
  • CI 硬门 .github/tools/check_narrow_conversions.sh

Test plan

在 Linux/macOS 上写的任何测试都无法证伪这个缺陷 —— 那两个平台 path::string() 不做转换。
所以:

  • Scanner.GlobWalkSurvivesNamesTheCodePageCannotSpell —— Windows 专属,自己探测
    GetACP(),拼得出就 GTEST_SKIP 并说明原因(runner 镜像哪天默认 UTF-8 ACP,
    它不会变成永远绿的装饰品)。用天城文而非日文:日文在 CP932/936/950 上是可拼写的,
    在日文/中文开发机上单用它等于零覆盖。名字由显式码位构造,不依赖源文件编码。
    断言三件事:不抛、walk 没被截断、跳过被记录
  • Scanner.GlobWalkHandlesNonAsciiNames / Glob.UnnarrowablePathsDedupToTheirSpellableAncestor
    —— 跨平台可跑。
  • 本地:mcpp build 46s 通过,mcpp test 93 passed / 0 failed

⚠️ 第一个 commit 是故意红的

ce86b41 不含 P0 修复。它存在的目的是让下一个 commit 有东西可证明:

  • ci-windows 应在 Scanner.GlobWalkSurvivesNamesTheCodePageCannotSpell 失败
    → 证明这个测试失败;
  • ci-linux 应在 check_narrow_conversions.sh 失败并点名 scanner.cppm:238
    → 证明这道门抓到真实缺陷,而不只是抓到我构造的负例。

一个从来没红过的测试不证明任何事,只见过合成负例的门也一样。

范围之外(按 review 决定)

  • 不做 httplib 端到端;mcpp-index PR 留言版 | 使用mcpp工具构建的项目(库/工具/应用) #260 在本版发布后自然转绿。
  • P2(嵌 activeCodePage=UTF-8 清单)与"项目根目录含非 ACP → 先以『没有源文件』失败"
    另立 issue。P2 不危险(ASCII 路径下字节不变),但可能不彻底 ——
    build.ninja 是另一个进程按它自己的 ACP 读的。

完整分析:.agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md

This commit deliberately does NOT contain the one-line fix. It exists so the
next one has something to prove.

mcpp#516: on Windows `path::string()` converts the native (wide) name through
the process ANSI code page and throws std::system_error for a character that
code page cannot spell. `is_excluded_walk_dir` narrows a directory name on the
FIRST line of the glob walk loop, so it fires before the guard #231 added to
`path_matches_glob` one line later — which is why hardening that one could
never cover a directory name.

Two things here are supposed to be RED on this commit, and green on the next:

  * ci-windows  Scanner.GlobWalkSurvivesNamesTheCodePageCannotSpell
                — proves the test can fail, rather than passing by accident.
  * ci-linux    check_narrow_conversions.sh, naming scanner.cppm
                — proves the gate catches the real defect, not just the
                  negative control I fed it while writing it.

A test that has never been red does not prove anything, and neither does a gate
that has only ever seen a synthetic violation.

What this commit does contain:

  * mcpp::modgraph::try_narrow() — the ONE place a walk-derived path becomes a
    narrow string. Leaf-safe: modgraph imports only std, and nothing under
    src/modgraph or src/manifest imports mcpp.ui or mcpp.diag.
  * note_unnarrowable_path() / take_unnarrowable_paths() — records a skip,
    deduplicated to the nearest ancestor that CAN be named (never the offending
    name, which cannot enter a message without throwing the same exception).
  * cli::run scope guard draining those into mcpp.diag as `degraded` — silently
    dropping a file is the half-fix this whole change exists to avoid, and diag
    already requires an `impact` sentence for exactly this situation.
  * pack/digest.cppm: interface_set_digest folds in u8string(), not string().
    Its input is an UNFILTERED recursive_directory_iterator over a published
    package, and the old spelling also made the digest depend on the host code
    page — a tree packed on Linux and verified on Windows could disagree about
    a non-ASCII name while every byte on disk was identical. ASCII names hash
    identically, so no published digest changes.
  * scaffold/template.cppm: same, for a third-party template provider's tree.
  * docs (EN + zh), the contributing invariant, and the CI gate.

Refs #516, #230, #231
…2026.8.27.4

The fix the previous commit exists to prove.

`is_excluded_walk_dir` compared a directory name against three ASCII literals
by first converting it to a narrow string. On Windows that conversion goes
through the process ANSI code page and throws for anything the code page cannot
spell. The comparison never needed a narrow string: `path::operator==` compares
native representations, the three literals are ASCII and convert losslessly,
and the decision is byte-for-byte the one the narrow comparison made. Static
path constants keep the per-entry cost below what it was (#225 bounded this
walk on purpose, and this is its hot path).

Evidence from the previous commit, both on real runners:

  ci-linux   failed at `Check no walk-derived path is narrowed directly`,
             naming src/modgraph/scanner.cppm:238.
  ci-windows Scanner.GlobWalkSurvivesNamesTheCodePageCannotSpell failed with
               Actual: it throws std::system_error with description
               "No mapping for the Unicode character exists in the target
                multi-byte code page."
             — word for word what #516 reported, reproduced from
             `expand_dir_glob(dir, "*")`, which is the exact call the
             `include_dirs = { "*" }` convention makes. The test did NOT skip,
             so the runner's ACP is not UTF-8 and the guard is live. 92 other
             tests passed, so nothing else was disturbed.

Also here:

  * The report drain in cli.cppm is a function-try-block. A destructor is
    implicitly noexcept, and run() can be left by an exception — main() catches
    one. A change whose whole subject is "an uncaught exception must not end
    the build" does not get to introduce a second one in its own reporting path.
  * xlings pin 2026.8.17.2 → 2026.8.27.4 across all 17 live pin sites
    (check_version_pins.sh enforces they equal `pinned::kXlingsVersion`).
    The three remaining 2026.8.17.2 strings in bootstrap-mcpp/action.yml are
    NOT pins — they are recorded measurements ("Measured on the 2026.8.17.2
    assets", "Cache hit for: …xl2026.8.17.2…") and rewriting them would
    falsify a record of what was actually observed. All four platform assets
    for v2026.8.27.4 were verified to exist before bumping.
  * Two comments pointed at files that do not exist (`tools/check-narrow-
    conversions.sh`, `AGENTS.md` — mcpp keeps its conventions in
    .agents/skills/). Found in self-review; a reference that cannot be followed
    is the same defect class as a printed command that cannot be run.

Closes #516
@Sunrisepeak

Copy link
Copy Markdown
Member Author

「打补丁前必须红」的证据(来自 ce86b41,两条腿都在真实 runner 上)

ci-windows —— 测试失败

[ RUN      ] Scanner.GlobWalkSurvivesNamesTheCodePageCannotSpell
D:/a/mcpp/mcpp/tests/unit/test_modgraph.cpp(1003): error:
  Expected: { dirs = expand_dir_glob(dir, "*"); } doesn't throw an exception.
  Actual: it throws std::system_error with description
          "No mapping for the Unicode character exists in the target multi-byte code page."
[  FAILED  ] Scanner.GlobWalkSurvivesNamesTheCodePageCannotSpell (7 ms)
 test result: FAILED. 92 passed; 1 failed

三件事同时成立:

  1. 报的是 Windows 解压包含 UTF-8 路径的归档时因多字节代码页转换失败 #516 逐字相同的那句话;
  2. 抛出点是 expand_dir_glob(dir, "*") —— 正是 include_dirs = { "*" } 走的那个调用;
  3. 测试没有 skip,说明 runner 的 ACP 确实不是 65001,那道 GetACP() 自检是活的
    (否则它会是一个永远绿的装饰品)。另外 92 个测试全过,说明没有捎带破坏别的东西。

ci-linux —— 门抓到真实缺陷

"Check version / xlings pin consistency"          success
"Check no walk-derived path is narrowed directly" failure   ← 新门

失败信息点名 src/modgraph/scanner.cppm:238,也就是真实缺陷那一行 —— 而不只是我写门
的时候喂给它的合成负例。


fade10f 打上修复,两条腿应转绿。一个从来没红过的测试不证明任何事,只见过合成负例的门
也一样,所以这一轮红是有意的,不是失误。

CI on fade10f was 36/36 green, including the Windows leg where the new test
ran (`[ OK ] … (40 ms)`, not SKIPPED). Reading the whole diff back afterwards
turned up six defects anyway — none of them compile errors, all of them the
kind that mislead the next reader:

1. A comment claimed "nothing calls .string() on such a path directly". That is
   false — p1689.cppm does, with a NARROW-OK rationale. An absolute claim that
   is not true is worse than the convention it describes; reworded to say it is
   a convention with a gate, not a guarantee.

2. Two comments pointed at files that do not exist:
   `tools/check-narrow-conversions.sh` (it is .github/tools/, underscores) and
   `AGENTS.md` (mcpp keeps its conventions in .agents/skills/ — AGENTS.md is
   an xlings habit). A reference nobody can follow is the same defect class as
   a printed command nobody can run.

3. "run() has five exits" — a number I could not cheaply verify (most of the
   `return`s in that function body belong to lambdas). Replaced with the
   enumeration that is actually checkable.

4. The docs showed the warning as `'C:\...\test\www'`. The anchor is stored via
   try_narrow, which returns generic_string(), so the real output uses forward
   slashes. The documentation was showing output the code does not produce.
   Fixed both the docs and the code comment that should have said so.

5. The blast-radius statistic was stale AND loosely defined. "128 recipes, 101
   with a `*`-leading glob" was measured before mcpp-index moved under me
   (aa8df60 → 891b2f7); it is 130/103 now. A hard-coded count in a CHANGELOG is
   wrong the next day, so it now names the commit it was measured on and states
   the counting rule (and records the false-positive check: every `"*…` string
   in those recipes really is a glob — `*/include`, `*`, `*/src`,
   `*/mcpp.toml`). Corrected in the CHANGELOG, the design doc, and the PR body.

6. (in fade10f, listed for completeness) the report drain was an ordinary
   destructor, i.e. implicitly noexcept, on a path that runs during unwinding.

No behaviour change: comments, docs and one CHANGELOG paragraph.
@Sunrisepeak

Copy link
Copy Markdown
Member Author

红 → 绿闭环(同一个 runner、同一个测试、同一个 ACP)

commit ci-windows 上那个测试
ce86b41(不含修复) FAILEDit throws std::system_error with description "No mapping for the Unicode character exists in the target multi-byte code page."
fade10f(含修复) [ OK ] Scanner.GlobWalkSurvivesNamesTheCodePageCannotSpell (40 ms)

OK 而不是 SKIPPED —— 说明它真的跑了,GetACP() 自检没有把它悄悄跳过。
fade10f 上 windows 93 passed / 0 failed,全 CI 36 checks / 0 failing

自我 review 又抓到 6 处(0059033)

CI 全绿之后把整个 diff 重读一遍,仍然找出 6 个 —— 一个编译错误都没有,全是会误导下一个读者的那类:

  1. 一句绝对化的假话:注释写"nothing calls .string() on such a path directly",
    而 p1689.cppm 就有一处(带 NARROW-OK)。改成"这是有门把守的约定,不是编译期保证"。
  2. 两处指向不存在的文件:tools/check-narrow-conversions.sh(实际在 .github/tools/、下划线)、
    AGENTS.md(mcpp 的规范在 .agents/skills/,AGENTS.md 是 xlings 的习惯)。
    一个跟不过去的引用,和一条抄下来跑不了的命令是同一类缺陷。
  3. 一个我没法廉价核实的数字:"run() has five exits" —— 那个函数体里的 return 大半属于 lambda。
    换成可核对的枚举。
  4. 文档展示了代码不会产生的输出:告警路径写成 'C:\...\test\www',
    而锚点走 try_narrowgeneric_string(),实际是正斜杠。文档和代码注释一起改。
  5. 发布出去的统计是陈旧的、而且口径没写:"128 个 recipe 里 101 个"是在 mcpp-index
    动过之前量的(aa8df60891b2f7),现在是 130/103。写死在 CHANGELOG 里的计数
    明天就是错的,所以现在点名 commit + 写明口径,并记下假阳性核查
    (那些 "*… 字符串确实全是 glob:*/include**/src*/mcpp.toml)。
    CHANGELOG、设计文档、本 PR 正文三处已同步。
  6. (在 fade10f 里,一并列出)排空上报原本是普通析构函数 —— 隐式 noexcept,而它恰好在
    栈展开时运行
    。一个主题是"未捕获异常不该终结构建"的改动,不该在自己的上报路径上再造一个。

已知的、没有自动化覆盖的一环

cli.cppm 那个 scope guard 的接线只由阅读保证:记录逻辑有单测,Windows 用例断言了
"确实记录到了",但"CLI 真的把它打出来了"需要一个 Windows e2e —— 按 review 决定本次范围
限定在单元测试。写在这里,而不是假装它被覆盖了。

@Sunrisepeak
Sunrisepeak merged commit 8442434 into main Aug 27, 2026
36 checks passed
@Sunrisepeak
Sunrisepeak deleted the fix/issue516-acp-path-narrowing branch August 27, 2026 12:08
Sunrisepeak added a commit to mcpplibs/mcpp-index that referenced this pull request Aug 27, 2026
…266)

MCPP_VERSION 2026.8.27.1 -> 2026.8.27.2.

#263 moved this pin seventeen versions forward, to 2026.8.27.1. That is the
version PR #260 is failing on, and the failure is not #260's:

  error: internal: unhandled exception: No mapping for the Unicode character
  exists in the target multi-byte code page.

three times, in `workspace (windows default 0/2)` and `1/2` — one per httplib
example. It is mcpp-community/mcpp#516: on Windows `path::string()` converts
through the process ANSI code page and throws for a name that code page cannot
spell, and `is_excluded_walk_dir` narrowed a directory name on the first line
of the glob walk. cpp-httplib ships `test/www/<CJK>Dir/`, and the
`include_dirs = { "*" }` convention walks the whole extracted tarball.

Fixed in mcpp 2026.8.27.2 (mcpp-community/mcpp#517), released and mirrored on
both hosts, with xim-pkgindex's `latest` pointing at it.

⚠️ RE-RUNNING #260 WITHOUT THIS BUMP PROVES NOTHING. Its CI would fetch
2026.8.27.1 — the version that still carries the defect — and reproduce the
same three errors. That is a re-confirmation of the bug, not a test of the fix.
Attribution first: the failing job's log was read and it is #516's message
verbatim, not some other Windows problem wearing the same red.

Keeping #263's caution in force: a red at this pin can come from the engine
change or from what accumulated across the versions this repository never ran
against, and a one-sided reading cannot tell them apart.
Sunrisepeak added a commit to mcpplibs/mcpp-index that referenced this pull request Aug 27, 2026
* Validate against the engine that fixes what this index tripped over

MCPP_VERSION 2026.8.27.1 -> 2026.8.27.2.

#263 moved this pin seventeen versions forward, to 2026.8.27.1. That is the
version PR #260 is failing on, and the failure is not #260's:

  error: internal: unhandled exception: No mapping for the Unicode character
  exists in the target multi-byte code page.

three times, in `workspace (windows default 0/2)` and `1/2` — one per httplib
example. It is mcpp-community/mcpp#516: on Windows `path::string()` converts
through the process ANSI code page and throws for a name that code page cannot
spell, and `is_excluded_walk_dir` narrowed a directory name on the first line
of the glob walk. cpp-httplib ships `test/www/<CJK>Dir/`, and the
`include_dirs = { "*" }` convention walks the whole extracted tarball.

Fixed in mcpp 2026.8.27.2 (mcpp-community/mcpp#517), released and mirrored on
both hosts, with xim-pkgindex's `latest` pointing at it.

⚠️ RE-RUNNING #260 WITHOUT THIS BUMP PROVES NOTHING. Its CI would fetch
2026.8.27.1 — the version that still carries the defect — and reproduce the
same three errors. That is a re-confirmation of the bug, not a test of the fix.
Attribution first: the failing job's log was read and it is #516's message
verbatim, not some other Windows problem wearing the same red.

Keeping #263's caution in force: a red at this pin can come from the engine
change or from what accumulated across the versions this repository never ran
against, and a one-sided reading cannot tell them apart.

* Five packages: the routes openkal 0.8's atoms made possible

    openkal-musl          0.4.0 -> 0.5.0
    openkal-macos         0.4.0 -> 0.5.0
    openkal-windows       0.2.0 -> 0.3.0
    openkal-llvm-runtime  0.2.0 -> 0.3.0
    sbase                 0.1.1 -> 0.1.2

The specification does not move. openkal 0.8 already carried the atoms;
what changed is that the layers above and below now use them --- the
socket family upon `openkal.net', datagrams upon `openkal.datagram',
`poll' and `select' upon `openkal.timeout', and `fork' composed above
`openkal.space'. Reported as mcpplibs/openkal-linux#13 and
mcpplibs/openkal-musl#13.

`openkal-windows' provides four of the five and declines `openkal.space'
in terms: this system starts a NAMED PROGRAM and has no primitive that
copies an address space, so a program calling `kal_space_start' fails at
the link naming the operation, which is clause 6.1's report.

`sbase' had been pinned below 0.4.0 --- a caret on a 0.x version bounds
it under the next minor --- so ninety-seven utilities were testing a C
library two minors behind the one everything else builds against.

⚠️ EVERY HASH WAS FETCHED BACK BEFORE IT WAS WRITTEN HERE. The publisher
tags, uploads, and then RETRIEVES the CN asset and compares its bytes;
this index records one sha256 for two URLs, and a wrong digit produces a
package that resolves, downloads and fails verification on every machine
but the one that published it. Both mirrors were then read a second time,
independently of the publisher, and all ten fetches matched.

---------

Co-authored-by: speak-agent <x.d2learn.org@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows 解压包含 UTF-8 路径的归档时因多字节代码页转换失败

2 participants