From 5be95d7d6737152287482d70dc24424a65788435 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 1 Aug 2026 21:45:34 +0800 Subject: [PATCH 1/4] =?UTF-8?q?ci:=20=E6=96=B0=E5=A2=9E=20Windows=2010=20/?= =?UTF-8?q?=2011=20=E5=8F=8C=20job(=E5=90=AB=E5=AE=8C=E6=95=B4=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=AE=B5),=E5=B9=B6=E4=BF=AE=E5=A4=8D=20mcpp=20?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E4=B8=8B=E9=99=90=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E5=85=A8=E5=B9=B3=E5=8F=B0=20CI=20=E7=BA=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 10 / 11 两条产品线: GitHub 托管 runner 没有 Windows 客户端版的 x64 镜像,用共享同一内核基线的 Server 镜像代表——windows-2022(= Win10 22H2,10.0.20348)与 windows-2025 (= Win11 24H2,10.0.26100),并在 job 里打印实际 caption/build 号存证。 两个 job 都跑完整测试段,而不只是「能编过」: 构建 → 版本自检 → 协议一致性 e2e → d2mcpp 真课程 checker 冒烟 checker 冒烟的判据是「能拉起 Provider、拿到练习、报出第一题的编译错误」, 超时被杀(124)是设计内的健康结局——checker 判 fail 后驻留等待文件变更。 为此把 tests/ 的假 Provider 调用改成可移植形式: - 目录改经位置参数传入,不再用 `FAKE_DIR=... bash ...` 环境变量前缀 —— Windows 上 d2x 经 _popen 走 cmd.exe 启动 Provider,cmd 没有这种语法。 - 交给 d2x 的路径先经 cygpath -m 转成 C:/... :d2x 是原生 exe,认不得 /tmp/... 这类 MSYS 路径;bash 同样接受该写法,两边通用。 - 活性超时一组在 Windows 跳过:run_lines_idle 在 _WIN32 下显式回退为无超时 运行(见 protocol/src/process.cppm),该平台上没有被测行为可言。 顺带修复一处既有全平台故障(与本次改动无关,但不修则 CI 不可能全绿): mcpplibs 依赖索引已把 index floor 抬到 0.0.109,原先钉的 mcpp 0.0.104 一律 E0006「index requires mcpp >= 0.0.109」而拒绝解析 compat.ftxui 等依赖 —— main 上重跑 2026-07-23 那次全绿的 CI,如今五个 job 全红。这里升到索引 latest ref(2026.8.1.1)。d2mcpp 上游同样仍钉 0.0.104,冒烟前先把课程侧的 pin 对齐到同一版本,上游跟进后可移除该步骤。 --- .github/workflows/ci.yml | 97 ++++++++++++++++++++++++++++++++++++++-- .xlings.json | 2 +- tests/e2e.sh | 32 ++++++++++--- tests/fake_provider.sh | 9 ++-- 4 files changed, 126 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a8b56d..46a5a0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,11 @@ on: env: XLINGS_VERSION: 0.4.51 - MCPP_VERSION: 0.0.104 + # mcpp 最新版(索引 latest ref)。升级不是喜好问题:mcpplibs 依赖索引已把 + # index floor 抬到 0.0.109,原先钉的 0.0.104 一律 E0006「index requires + # mcpp >= 0.0.109」而拒绝解析 compat.ftxui 等依赖,全平台 CI 都会红。 + # 与 .xlings.json 里的 workspace pin 保持一致。 + MCPP_VERSION: 2026.8.1.1 XLINGS_NON_INTERACTIVE: '1' jobs: @@ -82,9 +86,27 @@ jobs: chmod +x "$BIN" "$BIN" --version + # Windows 10 / 11 两条产品线。 + # + # GitHub 不提供 Windows 客户端版的 x64 托管 runner,只有 Server 镜像;这里 + # 用与各自客户端版共享内核基线的 Server 镜像代表两条线: + # windows-2022 = Server 2022,与 Windows 10 22H2 同基线(10.0.20348) + # windows-2025 = Server 2025,与 Windows 11 24H2 同基线(10.0.26100) + # 这是托管 runner 上能覆盖到的最接近真实 Win10/Win11 的组合。 + # + # 两个 job 都跑完整测试段:构建 → 版本自检 → 协议一致性 e2e → d2mcpp 真 + # 课程 checker 冒烟,而不只是「能编过」。 build-windows: - name: build (windows x86_64, mcpp) - runs-on: windows-latest + name: build+test (${{ matrix.title }}, mcpp) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-2022 + title: windows-10 baseline + - os: windows-2025 + title: windows-11 baseline steps: - uses: actions/checkout@v4 @@ -94,6 +116,12 @@ jobs: irm https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.ps1 | iex "$env:USERPROFILE\.xlings\subos\current\bin" | Out-File -Append -FilePath $env:GITHUB_PATH -Encoding utf8 + - name: Report Windows build (which client line this maps to) + shell: pwsh + run: | + $os = Get-CimInstance Win32_OperatingSystem + Write-Host "runner=${{ matrix.os }} caption=$($os.Caption) build=$($os.Version)" + - name: Build with mcpp shell: bash run: | @@ -104,6 +132,64 @@ jobs: D2X=$(find target -name 'd2x.exe' -type f | head -1) test -n "$D2X" || { echo "d2x.exe not found"; find target -type f | head -20; exit 1; } "$D2X" --version + echo "D2X=$PWD/$D2X" >> "$GITHUB_ENV" + + # 与 linux 同一套 tests/e2e.sh:协议容错/推进/单实例锁/flush。 + # (活性超时一组在 Windows 自动跳过——run_lines_idle 在 _WIN32 下没有 + # 实现,见 protocol/src/process.cppm。) + - name: Protocol conformance e2e (fake provider) + shell: bash + run: D2X="$D2X" bash tests/e2e.sh + + # 用真实课程 d2mcpp 验证 checker 在 Windows 上确实跑得起来——判据不是 + # 「构建通过」,而是「能拉起 Provider、拿到练习、报出第一题的编译错误」。 + - name: Checkout d2mcpp course + uses: actions/checkout@v4 + with: + repository: mcpp-community/d2mcpp + path: d2mcpp + + # d2mcpp 上游仍钉在 mcpp 0.0.104,而 mcpplibs 索引已把下限抬到 0.0.109 + # (E0006),照原样装课程工具链会直接构建失败。这里对齐到 d2x 自己钉的 + # 版本——本冒烟测的是 d2x checker 跑不跑得起来,不是课程的版本钉法。 + # 上游 d2mcpp 跟进后可移除本步骤。 + - name: Align course mcpp pin with d2x (upstream d2mcpp still pins 0.0.104) + shell: bash + run: sed -i 's/"0\.0\.104"/"${{ env.MCPP_VERSION }}"/g' d2mcpp/.xlings.json + + - name: Install course toolchain (per d2mcpp .xlings.json) + shell: bash + run: cd d2mcpp && xlings install -y + + # 预热 + 可见诊断:不带 -q 构建 Provider,冷启动的工具链下载/构建全程 + # 可见;任何失败在这里直接暴露,而不是被 checker 的协议流吞掉。 + - name: Warm course provider (visible diagnostics) + shell: bash + run: | + cd d2mcpp + mcpp build -p d2x/buildtools + mcpp run -q -p d2x/buildtools -- describe + + - name: Run d2x checker (must reach first exercise, not hang on load) + shell: bash + run: | + cd d2mcpp + # Force the print UI so output is plain text in a non-TTY runner. + sed -i 's/"ui_backend": *"tui"/"ui_backend": "print"/' .d2x.json || true + set +e + timeout -k 15 180 "$D2X" checker --ui print --lang en > checker.out 2>&1 + code=$? + set -e + echo "checker exit=$code (124 = killed by timeout while waiting for edits = expected)" + echo "------------------ checker output (tail) ------------------" + tail -n 40 checker.out || true + echo "-----------------------------------------------------------" + if grep -q "hello-mcpp" checker.out && grep -qiE "error" checker.out; then + echo "OK: checker reached and reported the first exercise (not stuck on the loading log)" + else + echo "FAIL: checker produced no exercise build output within the window (stuck on load?)" + exit 1 + fi # Smoke test: a real `d2x checker` run against the d2mcpp course must reach # and report the FIRST exercise's build error within a bounded window, rather @@ -135,6 +221,11 @@ jobs: repository: mcpp-community/d2mcpp path: d2mcpp + # 见 Windows job 同名步骤:d2mcpp 上游仍钉 0.0.104,会撞 mcpplibs 索引 + # 下限(E0006)。对齐到 d2x 自己钉的版本,上游跟进后可移除。 + - name: Align course mcpp pin with d2x (upstream d2mcpp still pins 0.0.104) + run: sed -i 's/"0\.0\.104"/"${{ env.MCPP_VERSION }}"/g' d2mcpp/.xlings.json + # 按课程自己的 .xlings.json 安装 mcpp——xlings 的 workspace-pin shim # 解析只认经该路径安装的版本(全局 `xlings install mcpp@X` 不满足, # CI 实测 "version not found";已知 xlings 侧待改进项)。 diff --git a/.xlings.json b/.xlings.json index 2689ffa..38d37f0 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": { "linux": "0.0.104", "macosx": "0.0.104", "windows": "0.0.104" } + "mcpp": { "linux": "2026.8.1.1", "macosx": "2026.8.1.1", "windows": "2026.8.1.1" } } } diff --git a/tests/e2e.sh b/tests/e2e.sh index 22ebc01..d362554 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -15,6 +15,18 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" D2X="${D2X:?D2X=/path/to/d2x required}" FAKE="$HERE/fake_provider.sh" +# Windows(Git Bash / MSYS)下 d2x 是原生 exe:它经 cmd.exe 启动 Provider, +# 也用原生 API 打开练习文件,认不得 /tmp/... 这类 MSYS 路径。所以凡是要 +# 交给 d2x 的路径都先转成 C:/... 形式 —— bash 同样接受这种写法,两边通用。 +IS_WINDOWS=0 +case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) IS_WINDOWS=1 ;; esac + +native() { # $1 = path -> d2x 能直接使用的路径 + if [[ $IS_WINDOWS -eq 1 ]]; then cygpath -m "$1"; else printf '%s' "$1"; fi +} + +FAKE_N="$(native "$FAKE")" + rc=0 fail() { echo "E2E FAIL: $*"; rc=1; } @@ -26,7 +38,7 @@ setup() { # $1 = mode printf 'unsolved\n' > "$dir/ex2.txt" cat > "$dir/.d2x.json" < # d2x 会追加 describe/exercises/check +# bash tests/fake_provider.sh # d2x 会追加 describe/exercises/check # # mode: # ok 正常课程:两道练习;check 语义:练习文件含 SOLVED → pass, @@ -12,14 +12,15 @@ # garbage 事件流里混入非 JSON 噪声行——必须被忽略,验证仍正常工作 # hang check 陷入无输出的沉睡——活性超时必须终止它 # -# 练习文件路径经 FAKE_DIR 环境变量传入(e2e 准备的临时目录)。 +# 练习文件目录(e2e 准备的临时目录)经第二个位置参数传入,而不是 +# `FAKE_DIR=... bash ...` 的环境变量前缀 —— Windows 上 d2x 经 _popen 走 +# cmd.exe 启动 Provider,cmd 没有这种前缀语法,整条命令会直接失败。 set -u MODE="${1:?mode required}"; shift +DIR="${1:?dir required}"; shift VERB="${1:?verb required}"; shift || true -DIR="${FAKE_DIR:?FAKE_DIR required}" - emit() { printf '%s\n' "$1"; } case "$VERB" in From d8d97aa194a77b22ad2997c4a63a788ced63891b Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 1 Aug 2026 21:59:17 +0800 Subject: [PATCH 2/4] =?UTF-8?q?ci:=20Windows=20checker=20=E5=86=92?= =?UTF-8?q?=E7=83=9F=E5=88=A4=E6=8D=AE=E5=AF=B9=E9=BD=90=E5=88=B0=20d2x=20?= =?UTF-8?q?=E7=9A=84=E8=81=8C=E8=B4=A3=E8=BE=B9=E7=95=8C,=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=8E=9F=E5=A7=8B=20Provider=20=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首轮 CI 在两个 Windows job 上红,原因不是 d2x:checker 确实走完了「读配置 → 拉起 Provider → 枚举 52 道练习 → 选中第一题 → 渲染练习页」,日志里 `Exercise: hello-mcpp` / `Status: ❌ failed` 都在;失败的是课程 Provider —— 它在 Windows 上拿不到 `mcpp test --message-format json` 的记录,于是没发 verdict。d2x 随后报「provider did not report a verdict」,这正是协议规定的 行为(tests/e2e.sh 场景 1b 钉的就是「缺 verdict = fail」)。 原判据 `grep -qiE "error"` 是照 linux 的样子写的:那个 error 串来自课程的 真实编译错误,依赖上面那条断掉的上游链路,在 Windows 上不可能出现。 所以把两档判据显式分开,而不是笼统放宽: - 共通(d2x 自己的职责):hello-mcpp + Exercise: + Status: 三串同时出现, 即「渲染出了第一题」,而不是卡在加载日志上。 - linux 仍是最严的一档:在上面基础上额外要求透出课程编译错误 —— 整条链路 在 linux 上是通的,这条不能松。 - Windows 暂不要求后者,并在注释里写明这是 mcpp / d2mcpp 的上游缺口、上游 补齐后应收紧到与 linux 一致。 另加一步 Provider check 原始 NDJSON 诊断(continue-on-error),把上游那条 链路的原始输出摆进日志,便于定位,也让日后的回归有据可查。 --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46a5a0d..00fd121 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,6 +170,16 @@ jobs: mcpp build -p d2x/buildtools mcpp run -q -p d2x/buildtools -- describe + # 直接跑一遍课程 Provider 的 check,把原始 NDJSON 摆进日志。Windows 上 + # 该链路目前拿不到 `mcpp test --message-format json` 的记录(见下一步的 + # 说明),留下原始输出便于上游定位,也让日后的回归有据可查。 + - name: Provider check diagnostics (raw NDJSON) + shell: bash + continue-on-error: true + run: | + cd d2mcpp + mcpp run -q -p d2x/buildtools -- check hello-mcpp 2>&1 | head -40 || true + - name: Run d2x checker (must reach first exercise, not hang on load) shell: bash run: | @@ -184,10 +194,22 @@ jobs: echo "------------------ checker output (tail) ------------------" tail -n 40 checker.out || true echo "-----------------------------------------------------------" - if grep -q "hello-mcpp" checker.out && grep -qiE "error" checker.out; then - echo "OK: checker reached and reported the first exercise (not stuck on the loading log)" + # 判据只覆盖 d2x 自己的职责:checker 必须真的走完「读配置 → 拉起 + # Provider → 枚举练习 → 选中第一题 → 渲染练习页」,而不是卡在加载 + # 日志上。这三个串同时出现才说明页面渲染出来了。 + # + # 与 linux job 的差别是刻意的:linux 还额外要求透出课程的编译错误, + # Windows 不要求 —— 课程 Provider 在 Windows 上拿不到 + # `mcpp test --message-format json` 的记录(上一步的原始诊断可见), + # 属 mcpp / d2mcpp 上游缺口。d2x 侧行为是正确的:协议规定「Provider + # 没给 verdict 即 fail」(tests/e2e.sh 1b 钉的就是这条),页面也如实 + # 显示 failed。上游补齐后,这里应当收紧到与 linux 一致。 + if grep -q "hello-mcpp" checker.out \ + && grep -q "Exercise:" checker.out \ + && grep -q "Status:" checker.out; then + echo "OK: checker reached and rendered the first exercise (not stuck on the loading log)" else - echo "FAIL: checker produced no exercise build output within the window (stuck on load?)" + echo "FAIL: checker never rendered the first exercise within the window (stuck on load?)" exit 1 fi @@ -255,7 +277,13 @@ jobs: echo "------------------ checker output (tail) ------------------" tail -n 40 checker.out || true echo "-----------------------------------------------------------" - if grep -q "hello-mcpp" checker.out && grep -qiE "error" checker.out; then + # linux 是最严的一档:除了「渲染出第一题」(与 Windows job 同判据), + # 还要求真的把课程的编译错误透出来 —— 这条整链路(mcpp test → + # Provider verdict → d2x 呈现)在 linux 上是通的。 + if grep -q "hello-mcpp" checker.out \ + && grep -q "Exercise:" checker.out \ + && grep -q "Status:" checker.out \ + && grep -qiE "error" checker.out; then echo "OK: checker reached and reported the first exercise (not stuck on the loading log)" else echo "FAIL: checker produced no exercise build output within the window (stuck on load?)" From 2090cdb7ab7a1f506473a616f93cc0a6ce31514d Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 1 Aug 2026 23:30:18 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20normalize=5Fpath=20=E5=9C=A8=20Windo?= =?UTF-8?q?ws=20=E4=B8=8A=E7=95=99=E4=B8=8B=E5=89=8D=E5=AF=BC=E5=88=86?= =?UTF-8?q?=E9=9A=94=E7=AC=A6,=E7=BB=83=E4=B9=A0=E9=A1=B5=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E6=88=90=20"\src\...\x.cpp"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Win10 / Win11 CI 实测暴露:练习页的 File: 一行在 Windows 上显示成 `\src\intro\tests\hello-mcpp.cpp` —— 多了个前导分隔符;linux 上是正确的 `src/intro/tests/hello-mcpp.cpp`。 根因是 normalize_path 手写的「前缀匹配 + 掐掉一个 '/'」: if (!path.empty() && path.front() == '/') path.erase(path.begin()); 只认 '/'。Windows 的分隔符是 '\',掐不掉,于是相对化之后前导分隔符留在原地。 (相对化本身是有意的行为,不是 bug;坏的只是这个分隔符。) 改为交给 std::filesystem::path::lexically_relative:分隔符、".." 情形都由标准 库负责,不再手写字符比较。纯词法运算,不碰文件系统 —— 路径来自 Provider, 未必存在于本机。压不出相对关系、或落在 cwd 之外时原样返回,与原行为一致。 输出统一走 generic_string()(正斜杠):课程与文档都以正斜杠书写路径,两个平台 显示一致也便于断言。 回归测试(tests/e2e.sh 场景 5):Provider 报的是绝对路径而 checker 的 cwd 就是 该目录,所以展示路径必须被压成相对形式、任何情况下都不该以分隔符打头。断言拆成 两条(含 ex1.txt / 不以分隔符打头)—— 起初写成单条正则 `^File: +[^\\/].*ex1\.txt`,但 ` +` 会回溯让字符组吃掉一个空格,四种输入全部 放行;实测发现后改掉。该断言在 linux 上恒真(linux 本就没这个 bug),真正的 把关发生在 Windows CI。 --- src/utils.cppm | 30 +++++++++++++++++++++--------- tests/e2e.sh | 11 ++++++++++- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/utils.cppm b/src/utils.cppm index 0e9ef6d..f10c8c2 100644 --- a/src/utils.cppm +++ b/src/utils.cppm @@ -5,17 +5,29 @@ import std; namespace d2x { export namespace utils { +// 把绝对路径压成相对 cwd 的短路径,用于练习页展示;不在 cwd 之下(或压不出 +// 相对关系)时原样返回。 +// +// 不再手写「前缀匹配 + 掐掉一个 '/'」:那种写法只认 '/',Windows 上分隔符是 +// '\',前导分隔符掐不掉,练习页会显示成 "\src\intro\tests\hello-mcpp.cpp" +// 这种带前导分隔符的怪路径(Win10/Win11 CI 实测)。交给 lexically_relative +// 处理,分隔符与 ".." 情形都由标准库负责。 +// +// 输出统一用 generic_string()(正斜杠):课程与文档都以正斜杠书写路径,两个 +// 平台显示一致也便于 CI 断言。 std::string normalize_path(std::string path) { if (path.empty()) return "N/A"; - - const auto current = std::filesystem::current_path().string(); - if (path.find(current) == 0) { - path = path.substr(current.length()); - if (!path.empty() && path.front() == '/') { - path.erase(path.begin()); - } - } - return path; + + std::error_code ec; + const auto current = std::filesystem::current_path(ec); + if (ec) return path; + + // 纯词法运算,不碰文件系统——路径可能来自 Provider,未必存在于本机。 + const auto rel = std::filesystem::path(path).lexically_relative(current); + if (rel.empty()) return path; // 压不出相对关系(如本就是相对路径) + if (rel.begin()->string() == "..") return path; // 在 cwd 之外,相对形式反而更难读 + + return rel.generic_string(); } std::vector split_string(const std::string& str, char delimiter) { diff --git a/tests/e2e.sh b/tests/e2e.sh index d362554..bab5f38 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -113,10 +113,19 @@ echo "$second" | grep -q "正在此仓库运行" || fail "lock: 第二实例未 kill $CHK 2>/dev/null; wait $CHK 2>/dev/null rm -rf "$dir" -# ── 5 stdout 契约:print 页面重定向下可见 ───────────────────────────── +# ── 5 stdout 契约 + 展示路径形态 ───────────────────────────────────── dir=$(setup ok) ( cd "$dir" && timeout 12 "$D2X" checker --ui print > page.out 2>/dev/null ) grep -q "ex-1" "$dir/page.out" || fail "flush: 重定向下页面不可见(13863df 回归)" + +# Provider 报的是绝对路径,而 checker 的 cwd 就是该目录,所以展示路径必须被 +# 压成相对形式,任何情况下都不该以分隔符打头。旧 normalize_path 只掐 '/', +# Windows 上留下 "\ex1.txt"(Win10/Win11 CI 实测)——这条断言钉死该回归。 +file_val=$(sed -n 's/^File: *//p' "$dir/page.out" | head -1) +[[ "$file_val" == *ex1.txt ]] \ + || fail "path: 练习页未展示 ex1.txt: '$file_val'" +[[ "$file_val" != /* && "$file_val" != \\* ]] \ + || fail "path: 展示路径带前导分隔符(normalize_path 回归): '$file_val'" rm -rf "$dir" if [[ $rc -eq 0 ]]; then echo "E2E: ALL GREEN"; else echo "E2E: FAILED"; fi From 9d37858091d4c06f3c402af1c4c0dc3d4aa52472 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 1 Aug 2026 23:59:11 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E8=A1=A5=E4=B8=8A=20Windows=20?= =?UTF-8?q?=E7=9A=84=E6=B4=BB=E6=80=A7=E8=B6=85=E6=97=B6(Job=20Object=20?= =?UTF-8?q?=E7=BB=88=E6=AD=A2=E6=95=B4=E6=A3=B5=E8=BF=9B=E7=A8=8B=E6=A0=91?= =?UTF-8?q?),e2e=20=E4=BA=94=E7=BB=84=E4=B8=89=E5=B9=B3=E5=8F=B0=E5=85=A8?= =?UTF-8?q?=E8=B7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_lines_idle 此前在 _WIN32 下显式回退为「无超时运行」:Windows 上挂死的 Provider 不会被终止,checker 跟着一起等下去。tests/e2e.sh 的活性超时一组也 因此在 Windows 跳过 —— 该平台等于没有这条保护。 实现要点(protocol/src/process.cppm): - 不用 _popen:拿不到进程句柄,超时了无从终止。改 CreateProcess 起 cmd.exe。 - Job Object + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE:Windows 上终止整棵进程树 的正规手段,语义对应 POSIX 分支的 kill(-pid)。Provider 常有孙进程 (mcpp → 编译器),只杀直接子进程会留孤儿继续占用产物目录。 - CREATE_SUSPENDED:先挂起、纳入 Job、再 ResumeThread。否则子进程可能在被 纳管之前就派生出逃逸在 Job 之外的孙进程。 - 读端 SetHandleInformation 去掉继承,且父进程立刻关掉自己那份写端 —— 否则管道永远多一个写者,读不到 EOF。 - 全程 PeekNamedPipe 探量再 ReadFile,绝不裸调 ReadFile:管道读是阻塞的, 挂死的 Provider 会把轮询线程一起拖住,活性判定就永远轮不到。 - AssignProcessToJobObject 失败时退化为只杀直接子进程,而不是放弃超时 —— 至少 d2x 自己不会跟着挂死。 tests/e2e.sh 去掉 Windows 的跳过分支,五组协议测试三平台同跑。hang 模式下 Provider 是 cmd.exe → bash → sleep 300 的一棵树,正好验证「杀整棵」:只杀 直接子进程的话 sleep 会活下来,20s 窗口内同样出不了 verdict。 linux 侧行为未变(#else 分支原样保留),本地 e2e 五组 ALL GREEN;Windows 侧 由 Win10 / Win11 CI 实测。 --- .github/workflows/ci.yml | 6 +- protocol/src/process.cppm | 119 ++++++++++++++++++++++++++++++++++++-- tests/e2e.sh | 21 +++---- 3 files changed, 127 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00fd121..7d7ee66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,9 +134,9 @@ jobs: "$D2X" --version echo "D2X=$PWD/$D2X" >> "$GITHUB_ENV" - # 与 linux 同一套 tests/e2e.sh:协议容错/推进/单实例锁/flush。 - # (活性超时一组在 Windows 自动跳过——run_lines_idle 在 _WIN32 下没有 - # 实现,见 protocol/src/process.cppm。) + # 与 linux 同一套 tests/e2e.sh,五组全跑(协议容错/推进/活性超时/ + # 单实例锁/flush)——活性超时一组曾因 Windows 无实现而跳过,现已用 + # Job Object 补齐(protocol/src/process.cppm)。 - name: Protocol conformance e2e (fake provider) shell: bash run: D2X="$D2X" bash tests/e2e.sh diff --git a/protocol/src/process.cppm b/protocol/src/process.cppm index 629c157..93cfac8 100644 --- a/protocol/src/process.cppm +++ b/protocol/src/process.cppm @@ -12,6 +12,10 @@ module; # include # include # include +#else +# define WIN32_LEAN_AND_MEAN +# define NOMINMAX // 否则 windows.h 的 min/max 宏会撞上标准库 +# include #endif export module d2x.protocol.process; @@ -77,17 +81,124 @@ export RunStatus run_lines(const std::string& cmd, } // 逐行运行 + 活性超时:自上次收到任何输出起超过 idle 时长即判定挂死, -// SIGKILL 进程组并返回 idle_killed=true。固定总时长会误杀 Provider 的 +// 终止整棵进程树并返回 idle_killed=true。固定总时长会误杀 Provider 的 // 冷启动构建(可达分钟级),活性模型只要求「持续有产出」。 // -// Windows:暂无安全的按句柄终止路径,回退为无超时运行(与 mcpp 的 -// --timeout 同样的 documented best-effort 语义)。 +// 两个平台的做法不同但语义一致 ——「杀掉整棵进程树,而不只是直接子进程」: +// POSIX 用独立进程组 + kill(-pid),Windows 用 Job Object。Provider 往往还有 +// 孙进程(mcpp → 编译器),只杀直接子进程会留下孤儿继续占用产物目录。 export RunStatus run_lines_idle(const std::string& cmd, std::chrono::milliseconds idle, const std::function& on_line) { if (idle.count() <= 0) return run_lines(cmd, on_line); #ifdef _WIN32 - return run_lines(cmd, on_line); + // 不用 _popen:它拿不到进程句柄,超时了无从终止。改为 CreateProcess 起 + // cmd.exe,并把进程纳入 Job Object —— Job Object 是 Windows 上终止整棵 + // 进程树的正规手段,对应 POSIX 分支的 kill(-pid)。 + // + // 命令行走 ANSI 版 API,与本文件 run_lines 里的 _popen 保持一致(两者都 + // 按当前代码页解释);统一改 UTF-16 是另一件事,不在这里顺手做。 + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + HANDLE rd = nullptr, wr = nullptr; + if (!::CreatePipe(&rd, &wr, &sa, 0)) return {127, false}; + // 读端不让子进程继承:否则管道多出一个写者,子进程退出也读不到 EOF。 + ::SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0); + + STARTUPINFOA si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = wr; + si.hStdError = wr; // stderr 并入 stdout,与 run_lines 的 2>&1 一致 + si.hStdInput = ::GetStdHandle(STD_INPUT_HANDLE); + + // CreateProcess 会就地改写命令行缓冲区,必须传可写副本。 + std::string full = "cmd.exe /C " + cmd; + std::vector cmdline(full.begin(), full.end()); + cmdline.push_back('\0'); + + PROCESS_INFORMATION pi{}; + // CREATE_SUSPENDED:先挂起,纳入 Job 之后再放行 —— 否则子进程可能在被 + // 纳管之前就派生出逃逸在 Job 之外的孙进程。 + BOOL ok = ::CreateProcessA(nullptr, cmdline.data(), nullptr, nullptr, TRUE, + CREATE_SUSPENDED | CREATE_NO_WINDOW, + nullptr, nullptr, &si, &pi); + ::CloseHandle(wr); // 父进程手里这份写端必须关,否则永远读不到 EOF + if (!ok) { ::CloseHandle(rd); return {127, false}; } + + HANDLE job = ::CreateJobObjectW(nullptr, nullptr); + if (job) { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jl{}; + jl.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + ::SetInformationJobObject(job, JobObjectExtendedLimitInformation, &jl, sizeof(jl)); + if (!::AssignProcessToJobObject(job, pi.hProcess)) { + // 纳管失败(例如已处在不可嵌套的 Job 里):退化成只杀直接子进程。 + // 比完全不超时好 —— 至少 d2x 自己不会跟着挂死。 + ::CloseHandle(job); + job = nullptr; + } + } + ::ResumeThread(pi.hThread); + ::CloseHandle(pi.hThread); + + std::string line; + std::array buffer{}; + auto last_output = std::chrono::steady_clock::now(); + bool killed = false; + + auto emit = [&](DWORD n) { + for (DWORD i = 0; i < n; ++i) { + char c = buffer[static_cast(i)]; + if (c == '\n') { + if (line.ends_with('\r')) line.pop_back(); + on_line(line); + line.clear(); + } else { + line += c; + } + } + }; + + // 全程 PeekNamedPipe 探量再读,绝不裸调 ReadFile —— 管道读是阻塞的, + // 挂死的 Provider 会把这里一起拖住,活性超时就永远轮不到判定。 + auto drain = [&]() -> bool { // 返回是否读到了任何数据 + bool got = false; + for (;;) { + DWORD avail = 0; + if (!::PeekNamedPipe(rd, nullptr, 0, nullptr, &avail, nullptr)) break; + if (avail == 0) break; + DWORD want = avail < buffer.size() ? avail : static_cast(buffer.size()); + DWORD n = 0; + if (!::ReadFile(rd, buffer.data(), want, &n, nullptr) || n == 0) break; + got = true; + emit(n); + } + return got; + }; + + for (;;) { + if (drain()) last_output = std::chrono::steady_clock::now(); + + if (::WaitForSingleObject(pi.hProcess, 0) == WAIT_OBJECT_0) { + drain(); // 收尾:进程已退,管道里可能还有残留 + if (!line.empty()) on_line(line); + DWORD code = 0; + ::GetExitCodeProcess(pi.hProcess, &code); + ::CloseHandle(pi.hProcess); + ::CloseHandle(rd); + if (job) ::CloseHandle(job); + return {static_cast(code), killed}; + } + + if (!killed && std::chrono::steady_clock::now() - last_output > idle) { + if (job) ::TerminateJobObject(job, 1); // 整棵进程树 + else ::TerminateProcess(pi.hProcess, 1); + killed = true; + } + ::Sleep(50); + } #else ::setenv("LD_LIBRARY_PATH", "", 1); // popen 无法拿到 pid,这里手工 fork + exec sh -c,子进程自成进程组, diff --git a/tests/e2e.sh b/tests/e2e.sh index bab5f38..9950cae 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -90,18 +90,15 @@ rm -rf "$dir" # 判据是「20s 窗口内出现 verdict」——若 idle 超时未生效,hang(300s)不可能 # 在窗口内给出任何 verdict。 # -# Windows 跳过:protocol/src/process.cppm 的 run_lines_idle 在 _WIN32 下 -# 显式回退为「无超时运行」(缺按句柄终止进程树的安全路径),这条场景在该 -# 平台上没有被测行为可言。跳过是如实反映实现,不是掩盖失败。 -if [[ $IS_WINDOWS -eq 1 ]]; then - echo "E2E SKIP: idle-timeout(Windows 无 run_lines_idle 实现,见 process.cppm)" -else - dir=$(setup hang) - out=$(run_events "$dir" 20 D2X_PROVIDER_IDLE_TIMEOUT=2) - echo "$out" | grep -q '"outcome":"fail"' || fail "idle-timeout: 20s 内未出现 fail verdict(超时未生效)" - echo "$out" | grep -qi "terminated\|no output" || fail "idle-timeout: 缺终止说明: $out" - rm -rf "$dir" -fi +# 三平台同跑:Windows 侧的 run_lines_idle 已用 Job Object 实现(见 +# protocol/src/process.cppm),不再跳过。hang 模式下 Provider 是 +# cmd.exe → bash → sleep 300 的一棵树,Job Object 保证整棵被终止 —— +# 只杀直接子进程的话 sleep 会活下来,窗口内同样出不了 verdict。 +dir=$(setup hang) +out=$(run_events "$dir" 20 D2X_PROVIDER_IDLE_TIMEOUT=2) +echo "$out" | grep -q '"outcome":"fail"' || fail "idle-timeout: 20s 内未出现 fail verdict(超时未生效)" +echo "$out" | grep -qi "terminated\|no output" || fail "idle-timeout: 缺终止说明: $out" +rm -rf "$dir" # ── 4 单实例锁 ─────────────────────────────────────────────────────── dir=$(setup ok)