Skip to content

Commit 7be0cea

Browse files
committed
fix(provider): 构建期发心跳,避免冷机首次被 d2x 活性超时误杀
有反馈说 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 参考答案全部通过 ✓
1 parent f14391a commit 7be0cea

2 files changed

Lines changed: 54 additions & 6 deletions

File tree

d2x/buildtools/src/main.cpp

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,16 @@ int cmd_check(const fs::path& root, std::string_view id) {
8787
fs::current_path(root, ec);
8888

8989
d2x::emit::stage("compile");
90-
auto res = d2x::runner::run_mcpp_test(it->member, it->test_name, result_file);
90+
// 构建期心跳:mcpp 在 --message-format json 下全程不产出字节,冷机首次要在
91+
// 这段沉默里备工具链与 std 模块。不发心跳的话 d2x 的活性超时(默认 120s)
92+
// 会把正常构建当成挂死杀掉,学习者每改一次文件就再被杀一次。
93+
auto res = d2x::runner::run_mcpp_test(
94+
it->member, it->test_name, result_file,
95+
[](std::chrono::seconds elapsed) {
96+
d2x::emit::output(std::format(
97+
"[mcpp] 仍在构建…… 已用 {}s(首次运行需准备工具链与 std 模块,可能数分钟)\n",
98+
elapsed.count()));
99+
});
91100

92101
if (!res.package_error.empty()) {
93102
// 包级构建失败:harness 或工程本身坏了 —— 这是课程基础设施问题,

d2x/buildtools/src/runner.cppm

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,23 @@ export struct Captured {
3131
// 注:旧实现这里要先 unsetenv("LD_LIBRARY_PATH") 绕嵌套 mcpp 的 glibc
3232
// 段错误 —— mcpp 已在上游根治(merged_environ 剥离私有 glibc 条目),
3333
// workaround 随之删除。
34-
export Captured capture_stdout(const std::string& cmd) {
34+
//
35+
// on_heartbeat:构建期的「还活着」信号,参数是已经等了多久;不传则完全不介入。
36+
//
37+
// 为什么需要它:`mcpp test --message-format json` 在整个构建期**一个字节都不
38+
// 产出** —— 实测带不带 `-q` 都一样,stdout 只有末尾那两行 JSON、stderr 全空
39+
// (机器可读模式下人读输出被整个收编进 JSON 了)。于是从 d2x 的视角看,Provider
40+
// 从 stage("compile") 之后就彻底沉默,直到构建结束。
41+
//
42+
// 冷机第一次跑要在这段沉默里备工具链与 std 模块,一旦超过 d2x 的活性超时
43+
// (provider_idle_timeout,默认 120s)就会被当成挂死而终止 —— 而且学习者改一次
44+
// 文件就重试一次、每次都在同一处被杀,表现为「怎么改都过不去」。
45+
//
46+
// 心跳同时解决两件事:持续喂活 d2x 的计时器,并让学习者看见首次构建正在进行,
47+
// 而不是对着黑屏怀疑卡死。
48+
export Captured capture_stdout(const std::string& cmd,
49+
const std::function<void(std::chrono::seconds)>& on_heartbeat = {},
50+
std::chrono::seconds heartbeat_every = std::chrono::seconds{20}) {
3551
Captured result;
3652
// 丢弃 stderr 的写法必须分平台:_popen 走的是 cmd.exe,那里没有
3753
// /dev/null —— `2>/dev/null` 会被当成「重定向到 \dev\null 这个路径」,
@@ -52,8 +68,30 @@ export Captured capture_stdout(const std::string& cmd) {
5268
#endif
5369
if (!pipe) return {127, std::format("failed to spawn: {}", cmd)};
5470

55-
char buf[4096];
56-
while (std::fgets(buf, sizeof(buf), pipe)) result.output += buf;
71+
// 读取交给工作线程,主线程才有机会按节奏发心跳。fgets 是阻塞的,单线程下
72+
// 沉默期内根本回不到我们手里。
73+
std::string collected;
74+
std::atomic<bool> finished{false};
75+
std::thread reader([&] {
76+
char buf[4096];
77+
while (std::fgets(buf, sizeof(buf), pipe)) collected += buf;
78+
finished.store(true, std::memory_order_release);
79+
});
80+
81+
if (on_heartbeat) {
82+
const auto started = std::chrono::steady_clock::now();
83+
auto next = started + heartbeat_every;
84+
while (!finished.load(std::memory_order_acquire)) {
85+
std::this_thread::sleep_for(std::chrono::milliseconds{200});
86+
auto now = std::chrono::steady_clock::now();
87+
if (now >= next) {
88+
on_heartbeat(std::chrono::duration_cast<std::chrono::seconds>(now - started));
89+
next = now + heartbeat_every;
90+
}
91+
}
92+
}
93+
reader.join(); // pipe 归 reader 用,必须先 join 再 pclose
94+
result.output = std::move(collected);
5795

5896
#ifdef _WIN32
5997
int status = ::_pclose(pipe);
@@ -130,7 +168,8 @@ std::string field(std::string_view line, std::string_view key) {
130168
// 邻居测试(如 …/1 匹配 …/10),所以逐行解析后按 test 名精确挑。
131169
export McppTestResult run_mcpp_test(const std::string& member,
132170
const std::string& test_name,
133-
const fs::path& result_file) {
171+
const fs::path& result_file,
172+
const std::function<void(std::chrono::seconds)>& on_heartbeat = {}) {
134173
std::error_code ec;
135174
fs::remove(result_file, ec); // harness 是追加写的,清掉上一轮残留
136175
fs::create_directories(result_file.parent_path(), ec);
@@ -142,7 +181,7 @@ export McppTestResult run_mcpp_test(const std::string& member,
142181

143182
auto cmd = std::format("mcpp test -q -p {} {} --message-format json",
144183
member, test_name);
145-
auto cap = capture_stdout(cmd);
184+
auto cap = capture_stdout(cmd, on_heartbeat);
146185

147186
McppTestResult out;
148187
std::istringstream lines(cap.output);

0 commit comments

Comments
 (0)