From 7be0ceaa573d96550805d9b350850d04635df20a Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 10:10:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(provider):=20=E6=9E=84=E5=BB=BA=E6=9C=9F?= =?UTF-8?q?=E5=8F=91=E5=BF=83=E8=B7=B3,=E9=81=BF=E5=85=8D=E5=86=B7?= =?UTF-8?q?=E6=9C=BA=E9=A6=96=E6=AC=A1=E8=A2=AB=20d2x=20=E6=B4=BB=E6=80=A7?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E8=AF=AF=E6=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 有反馈说 d2x checker 在 Windows 10 上会卡住。除了已修的 cmd.exe 重定向,还有 一条与平台无关、冷机才撞得到的: mcpp test --message-format json 在整个构建期一个字节都不产出。 这是实测结论,不是推断 —— 带不带 -q 都一样:stdout 只有末尾那两行 JSON, stderr 全空(机器可读模式把人读输出整个收编进 JSON 了)。所以从 d2x 的视角看, Provider 从 stage("compile") 之后就彻底沉默,直到构建结束。 冷机第一次要在这段沉默里备工具链与 std 模块。一旦超过 d2x 的活性超时 (provider_idle_timeout,默认 120s),正常构建会被当成挂死而终止;更糟的是学习者 改一次文件就重试一次、每次都在同一处被杀,表现为「怎么改都过不去」—— 与「卡住」难以区分。 做法:capture_stdout 把读取交给工作线程(fgets 是阻塞的,单线程在沉默期根本回 不到我们手里),主线程每 20s 发一条 output 事件。一举两得:持续喂活 d2x 的计时 器,并让学习者看见首次构建正在进行,而不是对着黑屏干等。 比单纯调大 120s 阈值更对症 —— 阈值调多大都是猜,心跳把「有进展」变成可观测 事实。runner 保持协议无关:回调由 main.cpp 注入,发射逻辑不下沉。 验证: - 心跳会发 —— 临时把间隔设为 0 跑一次,确认 output 事件出现(改回 20s 后 热构建 0 条,不打扰正常路径) - 无回归 —— d2x/buildtools/tests/e2e.sh zh:协议冒烟 52 练习 ✓ / pristine 全部保持未通过 ✓ / 52/52 参考答案全部通过 ✓ --- d2x/buildtools/src/main.cpp | 11 +++++++- d2x/buildtools/src/runner.cppm | 49 ++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/d2x/buildtools/src/main.cpp b/d2x/buildtools/src/main.cpp index 6b9e229..01fd454 100644 --- a/d2x/buildtools/src/main.cpp +++ b/d2x/buildtools/src/main.cpp @@ -87,7 +87,16 @@ int cmd_check(const fs::path& root, std::string_view id) { fs::current_path(root, ec); d2x::emit::stage("compile"); - auto res = d2x::runner::run_mcpp_test(it->member, it->test_name, result_file); + // 构建期心跳:mcpp 在 --message-format json 下全程不产出字节,冷机首次要在 + // 这段沉默里备工具链与 std 模块。不发心跳的话 d2x 的活性超时(默认 120s) + // 会把正常构建当成挂死杀掉,学习者每改一次文件就再被杀一次。 + auto res = d2x::runner::run_mcpp_test( + it->member, it->test_name, result_file, + [](std::chrono::seconds elapsed) { + d2x::emit::output(std::format( + "[mcpp] 仍在构建…… 已用 {}s(首次运行需准备工具链与 std 模块,可能数分钟)\n", + elapsed.count())); + }); if (!res.package_error.empty()) { // 包级构建失败:harness 或工程本身坏了 —— 这是课程基础设施问题, diff --git a/d2x/buildtools/src/runner.cppm b/d2x/buildtools/src/runner.cppm index 74c2aec..fe0636d 100644 --- a/d2x/buildtools/src/runner.cppm +++ b/d2x/buildtools/src/runner.cppm @@ -31,7 +31,23 @@ export struct Captured { // 注:旧实现这里要先 unsetenv("LD_LIBRARY_PATH") 绕嵌套 mcpp 的 glibc // 段错误 —— mcpp 已在上游根治(merged_environ 剥离私有 glibc 条目), // workaround 随之删除。 -export Captured capture_stdout(const std::string& cmd) { +// +// on_heartbeat:构建期的「还活着」信号,参数是已经等了多久;不传则完全不介入。 +// +// 为什么需要它:`mcpp test --message-format json` 在整个构建期**一个字节都不 +// 产出** —— 实测带不带 `-q` 都一样,stdout 只有末尾那两行 JSON、stderr 全空 +// (机器可读模式下人读输出被整个收编进 JSON 了)。于是从 d2x 的视角看,Provider +// 从 stage("compile") 之后就彻底沉默,直到构建结束。 +// +// 冷机第一次跑要在这段沉默里备工具链与 std 模块,一旦超过 d2x 的活性超时 +// (provider_idle_timeout,默认 120s)就会被当成挂死而终止 —— 而且学习者改一次 +// 文件就重试一次、每次都在同一处被杀,表现为「怎么改都过不去」。 +// +// 心跳同时解决两件事:持续喂活 d2x 的计时器,并让学习者看见首次构建正在进行, +// 而不是对着黑屏怀疑卡死。 +export Captured capture_stdout(const std::string& cmd, + const std::function& on_heartbeat = {}, + std::chrono::seconds heartbeat_every = std::chrono::seconds{20}) { Captured result; // 丢弃 stderr 的写法必须分平台:_popen 走的是 cmd.exe,那里没有 // /dev/null —— `2>/dev/null` 会被当成「重定向到 \dev\null 这个路径」, @@ -52,8 +68,30 @@ export Captured capture_stdout(const std::string& cmd) { #endif if (!pipe) return {127, std::format("failed to spawn: {}", cmd)}; - char buf[4096]; - while (std::fgets(buf, sizeof(buf), pipe)) result.output += buf; + // 读取交给工作线程,主线程才有机会按节奏发心跳。fgets 是阻塞的,单线程下 + // 沉默期内根本回不到我们手里。 + std::string collected; + std::atomic finished{false}; + std::thread reader([&] { + char buf[4096]; + while (std::fgets(buf, sizeof(buf), pipe)) collected += buf; + finished.store(true, std::memory_order_release); + }); + + if (on_heartbeat) { + const auto started = std::chrono::steady_clock::now(); + auto next = started + heartbeat_every; + while (!finished.load(std::memory_order_acquire)) { + std::this_thread::sleep_for(std::chrono::milliseconds{200}); + auto now = std::chrono::steady_clock::now(); + if (now >= next) { + on_heartbeat(std::chrono::duration_cast(now - started)); + next = now + heartbeat_every; + } + } + } + reader.join(); // pipe 归 reader 用,必须先 join 再 pclose + result.output = std::move(collected); #ifdef _WIN32 int status = ::_pclose(pipe); @@ -130,7 +168,8 @@ std::string field(std::string_view line, std::string_view key) { // 邻居测试(如 …/1 匹配 …/10),所以逐行解析后按 test 名精确挑。 export McppTestResult run_mcpp_test(const std::string& member, const std::string& test_name, - const fs::path& result_file) { + const fs::path& result_file, + const std::function& on_heartbeat = {}) { std::error_code ec; fs::remove(result_file, ec); // harness 是追加写的,清掉上一轮残留 fs::create_directories(result_file.parent_path(), ec); @@ -142,7 +181,7 @@ export McppTestResult run_mcpp_test(const std::string& member, auto cmd = std::format("mcpp test -q -p {} {} --message-format json", member, test_name); - auto cap = capture_stdout(cmd); + auto cap = capture_stdout(cmd, on_heartbeat); McppTestResult out; std::istringstream lines(cap.output);