diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa83646..5835b36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,16 @@ jobs: with: python-version: '3.12' + - name: Session tests + run: mcpp test + + - name: Startup deadline and failure integration tests + shell: bash + run: | + D2X="$PWD/$(find target -type f \( -name d2x -o -name d2x.exe \) | head -1)" + if command -v cygpath >/dev/null; then D2X="$(cygpath -m "$D2X")"; fi + D2X="$D2X" python tests/startup_test.py + - name: Book port integration tests shell: bash run: | @@ -69,6 +79,14 @@ jobs: D2X="$PWD/$(find target -name d2x -type f | head -1)" D2X="$D2X" bash tests/e2e.sh + - name: Stage candidate with provenance + shell: bash + run: python tests/stage_candidate.py + - uses: actions/upload-artifact@v4 + with: + name: candidate-linux-x86_64 + path: candidate/ + build-macos: name: build (${{ matrix.os }}, mcpp) runs-on: ${{ matrix.os }} @@ -104,6 +122,16 @@ jobs: with: python-version: '3.12' + - name: Session tests + run: mcpp test + + - name: Startup deadline and failure integration tests + shell: bash + run: | + D2X="$PWD/$(find target -type f \( -name d2x -o -name d2x.exe \) | head -1)" + if command -v cygpath >/dev/null; then D2X="$(cygpath -m "$D2X")"; fi + D2X="$D2X" python tests/startup_test.py + - name: Book port integration tests shell: bash run: | @@ -111,6 +139,15 @@ jobs: if command -v cygpath >/dev/null; then D2X="$(cygpath -m "$D2X")"; fi D2X="$D2X" python tests/book_test.py + + - name: Stage candidate with provenance + shell: bash + run: python tests/stage_candidate.py + - uses: actions/upload-artifact@v4 + with: + name: candidate-${{ matrix.os }} + path: candidate/ + # Windows 10 / 11 两条产品线。 # # GitHub 不提供 Windows 客户端版的 x64 托管 runner,只有 Server 镜像;这里 @@ -163,6 +200,16 @@ jobs: with: python-version: '3.12' + - name: Session tests + run: mcpp test + + - name: Startup deadline and failure integration tests + shell: bash + run: | + D2X="$PWD/$(find target -type f \( -name d2x -o -name d2x.exe \) | head -1)" + if command -v cygpath >/dev/null; then D2X="$(cygpath -m "$D2X")"; fi + D2X="$D2X" python tests/startup_test.py + - name: Book port integration tests shell: bash run: | @@ -247,6 +294,15 @@ jobs: exit 1 fi + + - name: Stage candidate with provenance + shell: bash + run: python tests/stage_candidate.py + - uses: actions/upload-artifact@v4 + with: + name: candidate-${{ matrix.os }} + path: candidate/ + # 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 # than hanging on the init log (issue #24). The checker waits for file edits diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 007805e..75d1886 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,8 +171,9 @@ jobs: PYCODE - name: Create Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: + target_commitish: ${{ github.sha }} tag_name: v${{ inputs.version }} name: ${{ inputs.version }} draft: false diff --git a/README.md b/README.md index 2ad115e..5f30ba8 100644 --- a/README.md +++ b/README.md @@ -66,3 +66,31 @@ See the [architecture reference](.agents/docs/2026-07-20-d2x-architecture-refere ## Links [Forum](https://forum.d2learn.org) · [Course index](https://github.com/d2learn/xim-pkgindex-d2x) · [Project template](https://d2learn.github.io/d2x-project-template) · [xlings](https://github.com/openxlings/xlings) + +## Provider startup and diagnostics + +The first `d2x checker` can build the course Provider and download its toolchain. +`provider_startup_timeout` bounds the entire initial `describe` command (default +900 seconds, positive integers only). `D2X_PROVIDER_STARTUP_TIMEOUT` overrides +project config, global config and the default, in that order. Output does not +extend this deadline. After startup, `provider_idle_timeout` still limits silence +in `exercises` / `check` (default 120 seconds; <= 0 disables that idle limit). + +Preparation is visible in both built-in UIs. With `--emit-events`, a +`preparing` event precedes `session` and repeats every ten seconds: + +```json +{"event":"preparing","phase":"describe","elapsed":0,"timeout":900} +``` + +Consumers must tolerate preparation and diagnostic `output` events before +`session`. Initialization and transport failures emit an `error` with `code`, +`phase`, and `message`, and exit nonzero. Expected exercise failures remain +`verdict` events and wait for edits. Provider stdout/stderr are saved separately +in `.d2x/provider.stdout.log` and `.d2x/provider.stderr.log` for the current +checker invocation; stderr is also displayed live. Logs may contain Provider +output and should be checked before sharing. Timeout/interrupt terminates the +child process tree. Ctrl+C during a Provider call returns 130. + +See [the startup/transport contract](docs/provider-startup.md) for error codes, +compatibility and failure-injection commands. diff --git a/README.zh-CN.md b/README.zh-CN.md index df9941c..aa7620c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -64,3 +64,22 @@ cd d2mcpp && d2x checker # 开始学习——编辑、保存,自动重测并 ## 链接 [论坛](https://forum.d2learn.org) · [课程索引](https://github.com/d2learn/xim-pkgindex-d2x) · [项目模板](https://d2learn.github.io/d2x-project-template) · [xlings](https://github.com/openxlings/xlings) + +## Provider 首次启动与诊断 + +首次 `d2x checker` 可能需要构建课程 Provider 和准备工具链。 +`provider_startup_timeout` 控制初次 `describe` 的总期限,默认 900 秒,必须为正整数。 +优先级为 `D2X_PROVIDER_STARTUP_TIMEOUT` 环境变量、本地配置、全局配置、默认值。 +输出不会延长期限。后续 `exercises` / `check` 仍使用 `provider_idle_timeout` +限制静默,默认 120 秒;该 idle 值 <= 0 时关闭静默限制。 + +print/TUI 会显示准备信息。`--emit-events` 在 session 前每十秒发送一次 +`preparing`,stderr 以 output 事件展示;外部前端应允许这些事件先于 session。 +初始化或传输失败产生带 `code`、`phase`、`message` 的 error 事件并非零退出; +练习本身未通过仍产生 verdict 并等待修改。 + +本轮 checker 的 Provider 原始输出分别保存在 `.d2x/provider.stdout.log` 和 +`.d2x/provider.stderr.log`,stderr 同时实时呈现。分享前请检查日志内容。 +超时或调用期间取消会终止子进程树;调用期间 Ctrl+C 返回 130。 + +详见 [启动与传输契约](docs/provider-startup.md)。 diff --git a/docs/provider-startup.md b/docs/provider-startup.md new file mode 100644 index 0000000..9b96758 --- /dev/null +++ b/docs/provider-startup.md @@ -0,0 +1,51 @@ +# Provider startup and transport contract + +Since 2026.10.20.2, initial `describe` uses an absolute startup deadline (900s). +`exercises` and `check` use the existing idle limit (120s). The three Provider +verbs and protocol version 1 are unchanged. A valid describe must include +`protocol: 1` and a nonempty name. Old project configs need no changes. + +Project `.d2x.json` example: + +```json +{"buildtools":"my-provider","provider_startup_timeout":900,"provider_idle_timeout":120} +``` + +The startup field is a positive integer in seconds. Environment +`D2X_PROVIDER_STARTUP_TIMEOUT` overrides project, global and default values; +invalid effective settings fail before launching. `d2x config` preserves both +existing timeout fields when editing other settings. + +Frontend additions (unknown events should be ignored by older clients): + +```json +{"event":"preparing","phase":"describe","elapsed":0,"timeout":900} +{"event":"error","code":"startup_timeout","phase":"describe","message":"..."} +``` + +Preparation begins before session discovery, repeats every 10s, and does not +claim the child is making progress. It never refreshes the absolute deadline. +Only stdout is parsed as Provider NDJSON. Stderr cannot spoof protocol events. +Both streams drain independently, including large output without newlines. +Protocol lines above 1 MiB are discarded from parsing but retained in raw logs. +Unknown stdout lines are retained in logs; diagnostics never contaminate the +frontend stdout stream. Failure reports include bounded 8 KiB tails per stream, +command, cwd, elapsed time and child exit code when the command fails. + +Error codes: `configuration`, `instance_lock`, `startup_timeout`, `idle_timeout`, +`provider_exit`, `provider_protocol`, `empty_course`, `cancelled`. Phases are +`configuration`, `describe`, `exercises`, `check`. Infrastructure errors return 1; +Ctrl+C during an active Provider call returns 130. A Provider exiting nonzero or +reporting error after partial success never advances learning progress. +Empty enumeration is a distinct fatal empty-course result. Normal completion +returns 0; an exercise verdict fail/blocked remains an interactive learning state. + +Raw logs are overwritten when a new checker invocation begins; all three verbs +append to that invocation's logs. Timeouts/cancellation kill the process group on +POSIX and the Job Object on Windows, then reap the direct child. Startup fails +closed on Windows if process-tree containment cannot be established. + +Run portable failure injection with `D2X=/absolute/path/to/d2x python tests/startup_test.py`. +`tests/e2e.sh` additionally covers save/advance, locking and print output flushing. +CI stages candidate binaries with `provenance.json` containing repository, checkout +SHA, binary version and SHA256. These are test artifacts, not stable releases. diff --git a/mcpp.toml b/mcpp.toml index 304ba26..6b68ebd 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "d2x" -version = "2026.10.20.1" +version = "2026.10.20.2" description = "AI-powered development assistant for C++ projects" license = "Apache-2.0" repo = "https://github.com/d2learn/d2x" diff --git a/protocol/mcpp.toml b/protocol/mcpp.toml index 97d2840..bb6cad3 100644 --- a/protocol/mcpp.toml +++ b/protocol/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "d2x-protocol" -version = "2026.08.02.2" +version = "2026.10.20.2" description = "d2x Provider/Frontend 协议层:类型、编解码、传输(参考实现;规范性载体是协议文档,跨语言合规判据是 conformance 套件)" license = "Apache-2.0" diff --git a/protocol/src/process.cppm b/protocol/src/process.cppm index a000960..f2348a9 100644 --- a/protocol/src/process.cppm +++ b/protocol/src/process.cppm @@ -1,292 +1,227 @@ -// d2x.protocol.process — 协议传输的进程运行原语。 -// -// 协议层自带最小的「逐行读子进程输出」能力,不依赖 d2x core 的 platform -// 模块——依赖方向必须是 core → protocol,不能反过来。 +// Child-process transport. stdout is protocol input; stderr is diagnostics. module; - #include #include +#include #ifndef _WIN32 -# include -# include -# include -# include -# include +# include +# include +# include +# include #else -# define WIN32_LEAN_AND_MEAN -# define NOMINMAX // 否则 windows.h 的 min/max 宏会撞上标准库 -# include +# define WIN32_LEAN_AND_MEAN +# define NOMINMAX +# include #endif - export module d2x.protocol.process; - import std; namespace d2x::protocol::process { - -// 运行结果:exit_code 为平台归一化退出码(信号终止 → 128+sig); -// idle_killed 表示因活性超时被终止。 export struct RunStatus { - int exit_code = 0; + int exit_code = 0; bool idle_killed = false; + bool deadline_killed = false; + bool cancelled = false; }; -#ifndef _WIN32 +#ifdef _WIN32 +volatile LONG interrupted = 0; +BOOL WINAPI interrupt_handler(DWORD event) { + if (event != CTRL_C_EVENT && event != CTRL_BREAK_EVENT) return FALSE; + ::InterlockedExchange(&interrupted, 1); + return TRUE; +} +#else +volatile sig_atomic_t interrupted = 0; +void interrupt_handler(int) { interrupted = 1; } int normalize(int status) { - if (status == -1) return 127; - if (WIFEXITED(status)) return WEXITSTATUS(status); + if (WIFEXITED(status)) return WEXITSTATUS(status); if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); - return status; + return 127; } #endif -// 逐行运行(无超时)。stderr 并入 stdout;行尾 \n/\r\n 归一剥除; -// 结尾不带换行的最后一行同样回调。 -// -// spawn 前清空 LD_LIBRARY_PATH:Provider 链路曾因继承的私有 glibc loader -// 路径在嵌套进程里段错误(mcpp >= 0.0.104 已在其侧根治,这里保留是因为 -// 该保护对任意 Provider 启动器成立,不只 mcpp)。 -export RunStatus run_lines(const std::string& cmd, - const std::function& on_line) { -#ifndef _WIN32 - ::setenv("LD_LIBRARY_PATH", "", 1); -#endif - std::string full = cmd + " 2>&1"; -#ifdef _WIN32 - FILE* pipe = ::_popen(full.c_str(), "r"); -#else - FILE* pipe = ::popen(full.c_str(), "r"); -#endif - if (!pipe) return {127, false}; - - std::string line; - std::array buffer{}; - while (std::fgets(buffer.data(), buffer.size(), pipe) != nullptr) { - line += buffer.data(); - if (line.ends_with('\n')) { - line.pop_back(); - if (line.ends_with('\r')) line.pop_back(); - on_line(line); - line.clear(); - } - } - if (!line.empty()) on_line(line); - -#ifdef _WIN32 - int status = ::_pclose(pipe); - return {status == -1 ? 127 : status, false}; -#else - return {normalize(::pclose(pipe)), false}; -#endif -} - -// 逐行运行 + 活性超时:自上次收到任何输出起超过 idle 时长即判定挂死, -// 终止整棵进程树并返回 idle_killed=true。固定总时长会误杀 Provider 的 -// 冷启动构建(可达分钟级),活性模型只要求「持续有产出」。 -// -// 两个平台的做法不同但语义一致 ——「杀掉整棵进程树,而不只是直接子进程」: -// 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 - // 不用 _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); - +// The absolute deadline is independent of bytes received and host UI ticks. +// Drain only a bounded batch per poll so endless output cannot starve deadlines. +export RunStatus run_streams(const std::string& cmd, + std::chrono::milliseconds idle, std::chrono::milliseconds deadline, + const std::function& on_line, + const std::function& on_bytes = {}, + const std::function& on_tick = {}) { + auto started = std::chrono::steady_clock::now(); + auto last_output = started; + auto next_tick = started; std::string line; - std::array buffer{}; - auto last_output = std::chrono::steady_clock::now(); - bool killed = false; - bool escalated = false; - std::chrono::steady_clock::time_point kill_at{}; - - auto emit = [&](DWORD n) { - for (DWORD i = 0; i < n; ++i) { - char c = buffer[static_cast(i)]; + bool oversized = false; + RunStatus result; + auto consume = [&](std::string_view bytes, bool stderr_stream) { + if (on_bytes) on_bytes(bytes, stderr_stream); + if (stderr_stream) return; + for (char c : bytes) { if (c == '\n') { - if (line.ends_with('\r')) line.pop_back(); - on_line(line); + if (!oversized) { + if (line.ends_with('\r')) line.pop_back(); + on_line(line); + } line.clear(); - } else { - line += c; - } + oversized = false; + } else if (line.size() < 1024 * 1024) line += c; + else oversized = true; } }; - - // 全程 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); + auto should_stop = [&] { + auto now = std::chrono::steady_clock::now(); + if (on_tick && now >= next_tick) { + on_tick(std::chrono::duration_cast(now - started)); + next_tick = now + std::chrono::seconds(10); } - return got; + result.cancelled = interrupted != 0; + result.deadline_killed = deadline.count() > 0 && now - started >= deadline; + result.idle_killed = idle.count() > 0 && now - last_output >= idle; + return result.cancelled || result.deadline_killed || result.idle_killed; }; - - 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}; +#ifdef _WIN32 + SECURITY_ATTRIBUTES sa{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; + HANDLE rd[2]{}, wr[2]{}; + auto close_pipes = [&] { + for (int i = 0; i < 2; ++i) { + if (rd[i]) ::CloseHandle(rd[i]); + if (wr[i]) ::CloseHandle(wr[i]); } - - auto now = std::chrono::steady_clock::now(); - - if (!killed && now - last_output > idle) { - if (job) ::TerminateJobObject(job, 1); // 整棵进程树 - else ::TerminateProcess(pi.hProcess, 1); - killed = true; - kill_at = now; + }; + for (int i = 0; i < 2; ++i) { + if (!::CreatePipe(&rd[i], &wr[i], &sa, 0)) { + close_pipes(); return {127}; } - - // 终止之后进程仍不退出,就不能一直等下去 —— 这个循环存在的意义就是 - // 防止挂死,它自己挂死是最糟的结局。分两级兜底:先补一次直杀(Job - // 路径被拒时还有救),再到点就放弃等待、如实返回 idle_killed,由上层 - // 报「Provider 被终止」。退出码沿用 POSIX 分支被 KILL 时的 128+9。 - if (killed) { - auto since_kill = now - kill_at; - if (!escalated && since_kill > std::chrono::seconds(5)) { - ::TerminateProcess(pi.hProcess, 1); - escalated = true; - } - if (since_kill > std::chrono::seconds(15)) { - ::CloseHandle(pi.hProcess); - ::CloseHandle(rd); - if (job) ::CloseHandle(job); // KILL_ON_JOB_CLOSE 再补一刀 - return {137, true}; - } + ::SetHandleInformation(rd[i], HANDLE_FLAG_INHERIT, 0); + } + HANDLE job = ::CreateJobObjectW(nullptr, nullptr); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!job || !::SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits))) { + if (job) ::CloseHandle(job); + close_pipes(); return {127}; + } + STARTUPINFOA si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = wr[0]; si.hStdError = wr[1]; + si.hStdInput = ::GetStdHandle(STD_INPUT_HANDLE); + std::string full = "cmd.exe /D /S /C \"" + cmd + "\""; + std::vector command(full.begin(), full.end()); command.push_back('\0'); + PROCESS_INFORMATION pi{}; + BOOL ok = ::CreateProcessA(nullptr, command.data(), nullptr, nullptr, TRUE, + CREATE_SUSPENDED | CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi); + for (auto& w : wr) { ::CloseHandle(w); w = nullptr; } + if (!ok) { ::CloseHandle(job); close_pipes(); return {127}; } + // Never run an uncontained child: assignment failure must fail closed. + if (!::AssignProcessToJobObject(job, pi.hProcess)) { + ::TerminateProcess(pi.hProcess, 127); + ::WaitForSingleObject(pi.hProcess, INFINITE); + ::CloseHandle(pi.hThread); ::CloseHandle(pi.hProcess); + ::CloseHandle(job); close_pipes(); return {127}; + } + ::InterlockedExchange(&interrupted, 0); + ::SetConsoleCtrlHandler(interrupt_handler, TRUE); + ::ResumeThread(pi.hThread); ::CloseHandle(pi.hThread); + auto drain = [&](int channel) { + std::array buf{}; + for (int batch = 0; batch < 16; ++batch) { + DWORD available = 0, n = 0; + if (!::PeekNamedPipe(rd[channel], nullptr, 0, nullptr, &available, nullptr) || !available) break; + if (!::ReadFile(rd[channel], buf.data(), std::min(available, buf.size()), &n, nullptr) || !n) break; + last_output = std::chrono::steady_clock::now(); + consume(std::string_view(buf.data(), n), channel == 1); } + }; + for (;;) { + drain(0); drain(1); + // Check deadlines even when the shell has just exited. + if (should_stop()) { + ::TerminateJobObject(job, 1); + ::WaitForSingleObject(pi.hProcess, INFINITE); + break; + } + if (::WaitForSingleObject(pi.hProcess, 0) == WAIT_OBJECT_0) break; ::Sleep(50); } + // Close the job to stop descendants retaining pipes after the shell exits. + ::TerminateJobObject(job, 1); + drain(0); drain(1); + DWORD code = 127; ::GetExitCodeProcess(pi.hProcess, &code); + result.exit_code = static_cast(code); + ::CloseHandle(pi.hProcess); ::CloseHandle(job); close_pipes(); + ::SetConsoleCtrlHandler(interrupt_handler, FALSE); #else - ::setenv("LD_LIBRARY_PATH", "", 1); - // popen 无法拿到 pid,这里手工 fork + exec sh -c,子进程自成进程组, - // 超时可以杀掉整组(Provider 往往还有孙进程,只杀直接子进程会留孤儿)。 - int fds[2]; - if (::pipe(fds) != 0) return {127, false}; - + int fds[2][2]{{-1, -1}, {-1, -1}}; + auto close_pipes = [&] { + for (auto& pair : fds) for (int fd : pair) if (fd >= 0) ::close(fd); + }; + if (::pipe(fds[0]) || ::pipe(fds[1])) { close_pipes(); return {127}; } + struct sigaction action{}, old_int{}, old_term{}; + action.sa_handler = interrupt_handler; + sigemptyset(&action.sa_mask); + interrupted = 0; + ::sigaction(SIGINT, &action, &old_int); ::sigaction(SIGTERM, &action, &old_term); pid_t pid = ::fork(); - if (pid < 0) { ::close(fds[0]); ::close(fds[1]); return {127, false}; } if (pid == 0) { ::setpgid(0, 0); - ::dup2(fds[1], 1); - ::dup2(fds[1], 2); - ::close(fds[0]); - ::close(fds[1]); + ::sigaction(SIGINT, &old_int, nullptr); ::sigaction(SIGTERM, &old_term, nullptr); + ::dup2(fds[0][1], STDOUT_FILENO); ::dup2(fds[1][1], STDERR_FILENO); + close_pipes(); + ::unsetenv("LD_LIBRARY_PATH"); ::execl("/bin/sh", "sh", "-c", cmd.c_str(), static_cast(nullptr)); ::_exit(127); } - ::close(fds[1]); - ::fcntl(fds[0], F_SETFL, ::fcntl(fds[0], F_GETFL) | O_NONBLOCK); - - std::string line; - std::array buffer{}; - auto last_output = std::chrono::steady_clock::now(); - bool killed = false; - - auto drain = [&]() -> bool { // 返回是否读到了任何数据 - bool got = false; - for (;;) { - ssize_t n = ::read(fds[0], buffer.data(), buffer.size()); + if (pid < 0) { + close_pipes(); + ::sigaction(SIGINT, &old_int, nullptr); ::sigaction(SIGTERM, &old_term, nullptr); + return {127}; + } + ::setpgid(pid, pid); // close the parent/child process-group race + for (auto& pair : fds) { + ::close(pair[1]); pair[1] = -1; + ::fcntl(pair[0], F_SETFL, ::fcntl(pair[0], F_GETFL) | O_NONBLOCK); + } + auto drain = [&](int channel) { + std::array buf{}; + for (int batch = 0; batch < 16; ++batch) { + auto n = ::read(fds[channel][0], buf.data(), buf.size()); if (n <= 0) break; - got = true; - for (ssize_t 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; - } - } + last_output = std::chrono::steady_clock::now(); + consume(std::string_view(buf.data(), static_cast(n)), channel == 1); } - return got; }; - int status = 0; for (;;) { - struct pollfd pfd{fds[0], POLLIN, 0}; - ::poll(&pfd, 1, 200); - if (drain()) last_output = std::chrono::steady_clock::now(); - - pid_t r = ::waitpid(pid, &status, WNOHANG); - if (r == pid) { - drain(); - if (!line.empty()) on_line(line); - ::close(fds[0]); - return {normalize(status), killed}; - } - if (!killed && std::chrono::steady_clock::now() - last_output > idle) { - ::kill(-pid, SIGKILL); // 整个进程组 - killed = true; + drain(0); drain(1); + if (should_stop()) { + ::kill(-pid, SIGKILL); + while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) {} + break; } + auto waited = ::waitpid(pid, &status, WNOHANG); + if (waited == pid) break; + if (waited < 0 && errno != EINTR) { status = -1; break; } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); } + ::kill(-pid, SIGKILL); + drain(0); drain(1); close_pipes(); + ::sigaction(SIGINT, &old_int, nullptr); ::sigaction(SIGTERM, &old_term, nullptr); + result.exit_code = status == -1 ? 127 : normalize(status); #endif + if (!line.empty() && !oversized) on_line(line); + if (result.cancelled) result.exit_code = 130; + else if (result.deadline_killed || result.idle_killed) result.exit_code = 124; + return result; } +export RunStatus run_lines_idle(const std::string& cmd, std::chrono::milliseconds idle, + const std::function& on_line) { + return run_streams(cmd, idle, {}, on_line); +} +export RunStatus run_lines(const std::string& cmd, + const std::function& on_line) { + return run_streams(cmd, {}, {}, on_line); +} } // namespace d2x::protocol::process diff --git a/protocol/src/transport.cppm b/protocol/src/transport.cppm index e8405c0..9fa26df 100644 --- a/protocol/src/transport.cppm +++ b/protocol/src/transport.cppm @@ -58,6 +58,9 @@ export std::string shell_quote(std::string_view s) { export struct TransportOptions { // 活性超时:自上次输出起的最大静默时长;<=0 关闭。 std::chrono::milliseconds idle_timeout{std::chrono::seconds(120)}; + std::chrono::milliseconds startup_timeout{std::chrono::seconds(900)}; + std::function on_startup; + std::function on_diagnostic; // 协议层不依赖宿主日志——异常路径经回调上报(跳过的练习、超时终止等)。 std::function on_warning; }; @@ -71,20 +74,54 @@ export class ProcessProvider final : public IExerciseProvider { if (mOpts.on_warning) mOpts.on_warning(std::move(msg)); } - // 单次调用;逐行回调。返回运行状态(退出码 + 是否被活性超时终止)。 + std::string mError; + std::string mErrorCode; + std::string mContext; + bool mCancelled = false; + process::RunStatus invoke(std::string_view args, - const std::function& on_event) const { - auto status = process::run_lines_idle( - std::format("{} {}", mCommand, args), mOpts.idle_timeout, + const std::function& on_event) { + mError.clear(); mErrorCode.clear(); mCancelled = false; + std::string out_tail, err_tail; + bool protocol_error = false; + auto start = std::chrono::steady_clock::now(); + const bool startup = args == "describe"; + auto status = process::run_streams( + std::format("{} {}", mCommand, args), + startup ? std::chrono::milliseconds(0) : mOpts.idle_timeout, + startup ? mOpts.startup_timeout : std::chrono::milliseconds(0), [&](std::string_view line) { - // 非 JSON 行静默丢弃:启动器噪声是预期内的常态,不是异常。 - if (auto ev = codec::parse_event(line)) on_event(*ev); - }); - if (status.idle_killed) { - warn(std::format( - "provider idle-timeout: no output for {}s while running '{} {}' — killed", - std::chrono::duration_cast(mOpts.idle_timeout).count(), - mCommand, args)); + if (auto ev = codec::parse_event(line)) { + try { + if (ev->value("event", "") == "error") protocol_error = true; + on_event(*ev); + } catch (const nlohmann::json::exception&) { protocol_error = true; } + } + }, + [&](std::string_view bytes, bool is_stderr) { + auto& tail = is_stderr ? err_tail : out_tail; + tail.append(bytes); + if (tail.size() > 8192) tail.erase(0, tail.size() - 8192); + if (mOpts.on_diagnostic) mOpts.on_diagnostic(bytes, is_stderr); + }, startup ? mOpts.on_startup : std::function{}); + mCancelled = status.cancelled; + if (status.cancelled) mErrorCode = "cancelled"; + else if (status.deadline_killed) mErrorCode = "startup_timeout"; + else if (status.idle_killed) mErrorCode = "idle_timeout"; + else if (status.exit_code != 0) mErrorCode = "provider_exit"; + else if (protocol_error) mErrorCode = "provider_protocol"; + mContext = std::format( + "command='{} {}', cwd='{}', elapsed={}s, exit={}\nstdout tail:\n{}\nstderr tail:\n{}", + mCommand, args, std::filesystem::current_path().string(), + std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count(), + status.exit_code, out_tail, err_tail); + if (!mErrorCode.empty()) { + mError = std::format( + "Provider {}: command='{} {}', cwd='{}', elapsed={}s, exit={}\nstdout tail:\n{}\nstderr tail:\n{}", + mErrorCode, mCommand, args, std::filesystem::current_path().string(), + std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count(), + status.exit_code, out_tail, err_tail); + warn(mError); } return status; } @@ -93,14 +130,22 @@ public: explicit ProcessProvider(std::string command, TransportOptions opts = {}) : mCommand(std::move(command)), mOpts(std::move(opts)) {} + const std::string& error() const { return mError; } + const std::string& error_code() const { return mErrorCode; } + bool cancelled() const { return mCancelled; } + bool describe(std::string& name_out) override { bool seen = false; auto status = invoke("describe", [&](const nlohmann::json& ev) { if (ev.value("event", "") != "describe") return; - name_out = ev.value("name", "unknown"); - seen = true; + name_out = ev.value("name", ""); + seen = !name_out.empty() && ev.value("protocol", 0) == 1; }); - return seen && status.exit_code == 0; + if (!seen && mError.empty()) { + mErrorCode = "provider_protocol"; + mError = "Provider did not report a valid describe (protocol 1, nonempty name)\n" + mContext; + } + return seen && status.exit_code == 0 && mError.empty(); } std::vector exercises() override { @@ -132,6 +177,8 @@ public: found.push_back(std::move(ex)); }); + if (!mError.empty()) found.clear(); + // 比较器而非 ranges 投影:clang 20(MSVC 目标)对跨模块类型的 // ranges 投影在 PCM 代码生成阶段 ICE(Windows CI 实测)。 std::sort(found.begin(), found.end(), @@ -195,7 +242,11 @@ public: // 没收到 verdict 事件 = Provider 中途死了、输出被截断、或被活性 // 超时终止。绝不能当成通过——原始输出原样交给学习者,让他看见真相。 - if (!got_verdict) { + if (!got_verdict || !mError.empty()) { + if (mError.empty()) { + mErrorCode = "provider_protocol"; + mError = std::format("Provider did not report a verdict for '{}'\n{}", ex.id, mContext); + } verdict.outcome = Outcome::Fail; if (status.idle_killed) { verdict.output += std::format( @@ -209,6 +260,7 @@ public: "[d2x] provider did not report a verdict for '{}'", ex.id); } } + if (!mError.empty()) verdict.output += "\n" + mError; return verdict; } }; diff --git a/src/checker.cppm b/src/checker.cppm index 4d2caf1..d04b32c 100644 --- a/src/checker.cppm +++ b/src/checker.cppm @@ -111,20 +111,29 @@ export void status(bool emit_events = false) { } } -export void run(const std::string& start_target = "", bool emit_events = false) { +export int run(const std::string& start_target = "", bool emit_events = false) { // 先改道再做任何事:早退路径上的错误日志同样不能落进协议流。 // (曾经这行在 buildtools 检查之后,于是配置缺失时两行报错直接 // 打进了 stdout,外部客户端拿到的第一样东西就是非 JSON。) if (emit_events) log::to_stderr(true); + std::unique_ptr sink; + if (emit_events) sink = std::make_unique(); + else sink = std::make_unique(); + auto fail = [&](std::string_view code, std::string_view phase, std::string_view message) { + sink->error(code, phase, message); + log::error("{}", message); + return code == "cancelled" ? 130 : 1; + }; + try { // 单实例锁:两个监听循环互踩的破坏是静默的(见 instance_lock 模块头注)。 auto lock_path = std::filesystem::path(platform::get_rundir()) / ".d2x" / "checker.lock"; if (!instance_lock::acquire(lock_path, [&](long pid) { log::error("另一个 d2x checker (pid {}) 正在此仓库运行;如确认其已退出,删除 {} 后重试", pid, lock_path.string()); })) { - return; + return fail("instance_lock", "configuration", "Another checker is already running in this course"); } struct LockGuard { ~LockGuard() { instance_lock::release(); } } lock_guard; @@ -132,17 +141,27 @@ export void run(const std::string& start_target = "", bool emit_events = false) if (command.empty()) { log::error("未配置 buildtools —— 请在 .d2x.json 里指定 Provider 命令"); log::error("例如: \"buildtools\": \"mcpp run -q -p d2x/buildtools/mcpp --\""); - return; + return fail("configuration", "configuration", "Missing buildtools in .d2x.json"); } - // 内置前端走内存通道,外部客户端走管道,二者消费同一套事件类型。 - std::unique_ptr sink; - if (emit_events) sink = std::make_unique(); - else sink = std::make_unique(); + auto logs = std::filesystem::path(platform::get_rundir()) / ".d2x"; + std::filesystem::create_directories(logs); + std::ofstream stdout_log(logs / "provider.stdout.log", std::ios::trunc | std::ios::binary); + std::ofstream stderr_log(logs / "provider.stderr.log", std::ios::trunc | std::ios::binary); + const int startup_timeout = Config::provider_startup_timeout(); // 活性超时与告警回调经此接入协议层——core 负责配置与日志,机制在 transport。 auto provider = provider::ProcessProvider(command, provider::TransportOptions{ .idle_timeout = std::chrono::seconds(Config::provider_idle_timeout()), + .startup_timeout = std::chrono::seconds(startup_timeout), + .on_startup = [&](std::chrono::seconds elapsed) { + sink->preparing(static_cast(elapsed.count()), startup_timeout); + }, + .on_diagnostic = [&](std::string_view bytes, bool is_stderr) { + auto& file = is_stderr ? stderr_log : stdout_log; + file.write(bytes.data(), bytes.size()); file.flush(); + if (is_stderr) sink->output(bytes); + }, .on_warning = [](std::string msg) { log::warning("{}", msg); }, }); @@ -153,14 +172,16 @@ export void run(const std::string& start_target = "", bool emit_events = false) if (!provider.describe(provider_name)) { log::error("Provider 无响应: {}", command); log::error("请确认该命令可执行,且实现了 d2x Provider 协议(describe/exercises/check)"); - return; + return fail(provider.error_code(), "describe", provider.error() + + "\nSee .d2x/provider.stdout.log and .d2x/provider.stderr.log"); } log::info("Provider: {}", provider_name); auto exercises = provider.exercises(); + if (!provider.error().empty()) return fail(provider.error_code(), "exercises", provider.error()); if (exercises.empty()) { log::warning("Provider '{}' 没有返回任何练习", provider_name); - return; + return fail("empty_course", "exercises", "Provider returned no exercises"); } auto state = session::StateStore(state_path()); @@ -201,6 +222,7 @@ export void run(const std::string& start_target = "", bool emit_events = false) sink->output(std::string_view(verdict.output).substr(streamed)); } sink->verdict(verdict); + if (!provider.error().empty()) return fail(provider.error_code(), "check", provider.error()); if (verdict.outcome == Outcome::Pass) break; @@ -234,6 +256,10 @@ export void run(const std::string& start_target = "", bool emit_events = false) static_cast(session.completed_count()), ""); sink->done(); log::info("全部练习已完成 🎉"); + return 0; + } catch (const std::exception& e) { + return fail("configuration", "configuration", e.what()); + } } } // namespace checker diff --git a/src/cmdprocessor.cppm b/src/cmdprocessor.cppm index 75d2ee3..952bd97 100644 --- a/src/cmdprocessor.cppm +++ b/src/cmdprocessor.cppm @@ -125,16 +125,16 @@ int run_book(const cmdline::ParsedArgs& args) { } export int run(int argc, char* argv[]) { - int book_exit_code = 0; + int command_exit_code = 0; // An option builder's action() returns its immediate App. Build this // subcommand separately so subsequent commands stay attached to d2x. auto book_command = cmdline::App("book"); book_command.description("open project's book") .option("port").short_name('p').takes_value().value_name("PORT") .help("book port (1-65535); default: first available in 3000-3099") - .action([&book_exit_code](const cmdline::ParsedArgs& a) { + .action([&command_exit_code](const cmdline::ParsedArgs& a) { apply_global_options(a); - book_exit_code = run_book(a); + command_exit_code = run_book(a); }); auto app = cmdline::App("d2x") .version(std::string(Info::VERSION)) @@ -158,17 +158,17 @@ export int run(int argc, char* argv[]) { .subcommand("install") .description("install d2x package via xlings") .arg("package").required().help("package name") - .action([](const cmdline::ParsedArgs& a) { + .action([&command_exit_code](const cmdline::ParsedArgs& a) { apply_global_options(a); - xlings::install(std::string(a.positional(0))); + command_exit_code = xlings::install(std::string(a.positional(0))) ? 0 : 1; }) .subcommand(std::move(book_command)) .subcommand("checker") .description("run checker for d2x project's exercises") .arg("target").help("exercise name (substring match)") - .action([](const cmdline::ParsedArgs& a) { + .action([&command_exit_code](const cmdline::ParsedArgs& a) { apply_global_options(a); - checker::run(std::string(a.positional_or(0, "")), a.is_flag_set("emit-events")); + command_exit_code = checker::run(std::string(a.positional_or(0, "")), a.is_flag_set("emit-events")); }) .subcommand("status") .description("show exercise progress overview (read-only)") @@ -191,7 +191,7 @@ export int run(int argc, char* argv[]) { }); int parse_status = app.run(argc, argv); - return parse_status != 0 ? parse_status : book_exit_code; + return parse_status != 0 ? parse_status : command_exit_code; } } // namespace d2x::cmdprocessor diff --git a/src/config.cppm b/src/config.cppm index 82272e1..2bbb3c5 100644 --- a/src/config.cppm +++ b/src/config.cppm @@ -17,7 +17,7 @@ export struct EnvVars; export class Config; struct Info { - static constexpr std::string_view VERSION = "2026.10.20.1"; // 日期版本制 YYYY.MM.DD.N(见 2026-07-24 设计文档决策记录) + static constexpr std::string_view VERSION = "2026.10.20.2"; // 日期版本制 YYYY.MM.DD.N(见 2026-07-24 设计文档决策记录) static constexpr std::string_view REPO = "https://github.com/d2learn/d2x"; }; @@ -69,6 +69,7 @@ public: std::string editor; bool editor_is_set{false}; // 区分「未配置」与「显式配成空串(=关闭)」 int provider_idle_timeout{120}; // 秒;<=0 关闭活性超时 + std::optional provider_startup_timeout; std::optional book_port; // preserve the field, including its type LLMConfig llm; @@ -228,6 +229,29 @@ public: return (std::filesystem::path(platform::get_rundir()) / ".d2x.json").string(); } + // Resolve lazily: timeout errors must not break unrelated book/config commands. + [[nodiscard]] static int provider_startup_timeout() { + auto parse = [](std::string_view text) { + int seconds{}; + auto [end, ec] = std::from_chars(text.data(), text.data() + text.size(), seconds); + if (ec != std::errc{} || end != text.data() + text.size() || seconds <= 0) + throw std::runtime_error("provider_startup_timeout must be a positive integer in seconds"); + return seconds; + }; + if (const char* env = std::getenv("D2X_PROVIDER_STARTUP_TIMEOUT")) return parse(env); + for (auto scope : {Scope::Local, Scope::Global}) { + auto path = get_config_path(scope); + if (!std::filesystem::exists(path)) continue; + auto json = nlohmann::json::parse(std::ifstream(path)); + if (!json.contains("provider_startup_timeout")) continue; + auto& value = json["provider_startup_timeout"]; + if (!value.is_number_integer()) + throw std::runtime_error("provider_startup_timeout must be an integer in " + path); + return parse(value.dump()); + } + return 900; + } + struct BookPort { int value; std::string source; @@ -299,6 +323,8 @@ public: cfg.ui_backend = json.value("ui_backend", ""); cfg.buildtools = json.value("buildtools", ""); if (json.contains("book_port")) cfg.book_port = json["book_port"]; + if (json.contains("provider_startup_timeout")) cfg.provider_startup_timeout = json["provider_startup_timeout"]; + cfg.provider_idle_timeout = json.value("provider_idle_timeout", 120); // Load LLM config from nested "llm" object if (json.contains("llm") && json["llm"].is_object()) { @@ -338,6 +364,8 @@ public: json["ui_backend"] = cfg.ui_backend; json["buildtools"] = cfg.buildtools; if (cfg.book_port) json["book_port"] = *cfg.book_port; + if (cfg.provider_startup_timeout) json["provider_startup_timeout"] = *cfg.provider_startup_timeout; + json["provider_idle_timeout"] = cfg.provider_idle_timeout; // Save LLM config to nested "llm" object nlohmann::json llm_json; @@ -400,6 +428,8 @@ public: // Collect new config ConfigData new_cfg; new_cfg.book_port = cfg.book_port; + new_cfg.provider_startup_timeout = cfg.provider_startup_timeout; + new_cfg.provider_idle_timeout = cfg.provider_idle_timeout; new_cfg.lang = utils::ask_input("Language (zh/en/auto)", cfg.lang.empty() ? std::string{ConfigData::DEFAULT_LANG} : cfg.lang); new_cfg.ui_backend = utils::ask_input("UI Backend", cfg.ui_backend.empty() ? std::string{ConfigData::DEFAULT_UI_BACKEND} : cfg.ui_backend); new_cfg.buildtools = utils::ask_input("BuildTools", cfg.buildtools.empty() ? std::string{ConfigData::DEFAULT_BUILDTOOLS} : cfg.buildtools); @@ -425,6 +455,8 @@ public: std::println(" lang: {}", cfg.lang.empty() ? "(自动检测)" : cfg.lang); std::println(" ui_backend: {}", cfg.ui_backend.empty() ? std::string{ConfigData::DEFAULT_UI_BACKEND} : cfg.ui_backend); std::println(" buildtools: {}", cfg.buildtools.empty() ? std::string{ConfigData::DEFAULT_BUILDTOOLS} : cfg.buildtools); + std::println(" provider_startup_timeout: {}", cfg.provider_startup_timeout ? cfg.provider_startup_timeout->dump() : "900"); + std::println(" provider_idle_timeout: {}", cfg.provider_idle_timeout); std::println(" book_port: {}", cfg.book_port ? cfg.book_port->dump() : "(auto: 3000-3099)"); std::println(" llm.api_key: {}", cfg.llm.api_key.empty() ? "(未设置)" : cfg.llm.api_key.substr(0, 10) + "..."); std::println(" llm.api_url: {}", cfg.llm.api_url.empty() ? "(未设置)" : cfg.llm.api_url); diff --git a/src/emit.cppm b/src/emit.cppm index de7e729..b3231c5 100644 --- a/src/emit.cppm +++ b/src/emit.cppm @@ -33,6 +33,8 @@ export class IEventSink { public: virtual ~IEventSink() = default; + virtual void preparing(int elapsed, int timeout) = 0; + virtual void error(std::string_view code, std::string_view phase, std::string_view message) = 0; virtual void session(int total, int completed, std::string_view current) = 0; virtual void exercise(const Exercise& ex) = 0; virtual void stage(std::string_view name) = 0; @@ -48,11 +50,17 @@ public: // VSCode 插件、Web 前端、CI 都只是这条流的消费者,不需要链接 d2x。 export class StdoutSink final : public IEventSink { static void line(const nlohmann::json& ev) { - std::println("{}", ev.dump()); + std::println("{}", ev.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace)); std::fflush(stdout); // 前端是逐行读的,缓冲会让实时性失效 } public: + void preparing(int elapsed, int timeout) override { + line({{"event", "preparing"}, {"phase", "describe"}, {"elapsed", elapsed}, {"timeout", timeout}}); + } + void error(std::string_view code, std::string_view phase, std::string_view message) override { + line({{"event", "error"}, {"code", code}, {"phase", phase}, {"message", message}}); + } void session(int total, int completed, std::string_view current) override { line({{"event", "session"}, {"total", total}, {"completed", completed}, {"current", current}}); @@ -130,6 +138,16 @@ export class UiSink final : public IEventSink { } public: + void preparing(int elapsed, int timeout) override { + mStage = "preparing"; + mOutput = std::format("Preparing course environment; first use may take several minutes.\nWaiting: {}s / {}s", elapsed, timeout); + refresh(); + } + void error(std::string_view code, std::string_view phase, std::string_view message) override { + mStage.assign(phase); mOutcome = "error"; + mOutput = std::format("{}: {}", code, message); + refresh(); + } void session(int total, int completed, std::string_view current) override { mTotal = total; mCompleted = completed; diff --git a/src/xlings.cppm b/src/xlings.cppm index 711bce7..b7b5527 100644 --- a/src/xlings.cppm +++ b/src/xlings.cppm @@ -86,6 +86,21 @@ export bool install(const std::string& pkgname) { return true; } + // Course packages are source downloads. Resolve their declared workspace + // tools in the downloaded directory before claiming the course is ready. + auto course = std::filesystem::path(pkgname); + if (std::filesystem::exists(course / ".xlings.json")) { + auto original = std::filesystem::current_path(); + struct Restore { std::filesystem::path path; ~Restore() { + std::error_code ec; std::filesystem::current_path(path, ec); + }} restore{original}; + std::filesystem::current_path(course); + int deps = platform::normalize_exit_status(platform::exec("xlings install -y")); + if (deps != 0) { + std::println(stderr, "error: Course dependencies failed (exit {}). Enter {} and retry xlings install -y.", deps, pkgname); + return false; + } + } std::println(""); std::println("安装完成。开始练习:"); std::println(" cd {} && d2x checker", pkgname); diff --git a/tests/e2e.sh b/tests/e2e.sh index 9950cae..0580dd5 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -118,7 +118,7 @@ grep -q "ex-1" "$dir/page.out" || fail "flush: 重定向下页面不可见(13863 # 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=$(sed -n 's/^File: *//p' "$dir/page.out" | grep 'ex1.txt' | head -1) [[ "$file_val" == *ex1.txt ]] \ || fail "path: 练习页未展示 ex1.txt: '$file_val'" [[ "$file_val" != /* && "$file_val" != \\* ]] \ diff --git a/tests/stage_candidate.py b/tests/stage_candidate.py new file mode 100644 index 0000000..e1a2012 --- /dev/null +++ b/tests/stage_candidate.py @@ -0,0 +1,19 @@ +"""Publish an uninstalled CI binary with exact source and content identity.""" +import hashlib +import json +from pathlib import Path +import shutil +import subprocess + +binary = next(p for p in Path('target').rglob('*') if p.is_file() and p.name in ('d2x', 'd2x.exe')) +dest = Path('candidate') +dest.mkdir() +shutil.copy2(binary, dest / binary.name) +version = subprocess.check_output([str(binary.resolve()), '--version'], text=True).strip() +(dest / 'provenance.json').write_text(json.dumps({ + 'repository': 'd2learn/d2x', + 'sha': subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(), + 'version': version, + 'file': binary.name, + 'sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), +}, indent=2) + '\n') diff --git a/tests/startup_test.py b/tests/startup_test.py new file mode 100644 index 0000000..d65d34e --- /dev/null +++ b/tests/startup_test.py @@ -0,0 +1,222 @@ +"""Portable checker startup/transport failure injection; no course/toolchain needed.""" +import json +import os +from pathlib import Path +import shlex +import signal +import subprocess +import sys +import tempfile +import time +import unittest + + +def emit(**event): + print(json.dumps(event), flush=True) + + +def provider(): + mode, verb = os.environ.get('TEST_MODE', 'ok'), sys.argv[2] + if verb == 'describe': + if mode == 'slow': + time.sleep(2) + if mode in ('hang', 'chatty', 'tree'): + if mode == 'tree': + child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(120)']) + Path('child.pid').write_text(str(child.pid)) + while True: + if mode == 'chatty': + print('still waiting', file=sys.stderr, flush=True) + time.sleep(.1) + if mode == 'empty': + return + if mode == 'bad-version': + emit(event='describe', name='test', protocol=99) + return + if mode == 'stderr-json': + print('{"event":"describe","name":"test","protocol":1}', file=sys.stderr) + return + if mode == 'flood': + sys.stdout.write('x' * 1500000 + '\n') + sys.stderr.write('diagnostic ' * 20000 + '\n') + emit(event='describe', name='test', protocol=1) + if mode == 'exit': + print('compiler failed here', file=sys.stderr, flush=True) + sys.exit(7) + elif verb == 'exercises': + if mode == 'enumerate-empty': + return + emit(event='exercise', id='one', order=1, files=[str(Path('one.txt').resolve())]) + if mode == 'enumerate-exit': + sys.exit(8) + elif verb == 'check': + if mode == 'check-hang': + time.sleep(120) + emit(event='verdict', outcome='pass', stage='run') + if mode == 'check-exit': + sys.exit(9) + + +class Startup(unittest.TestCase): + def run_case(self, mode='ok', config=None, env=None, ui='events', omit_startup=False): + with tempfile.TemporaryDirectory(prefix='d2x startup ') as folder: + root = Path(folder) + quote = (lambda s: '"' + s + '"') if os.name == 'nt' else shlex.quote + command = ' '.join(quote(s) for s in [sys.executable, str(Path(__file__).resolve()), '--provider']) + data = {'buildtools': command, 'provider_startup_timeout': 5, 'editor': ''} + data.update(config or {}) + if omit_startup: + data.pop("provider_startup_timeout") + (root / '.d2x.json').write_text(json.dumps(data)) + (root / 'one.txt').write_text('one') + settings = {**os.environ, 'TEST_MODE': mode, 'D2X_PROVIDER_IDLE_TIMEOUT': '1', **(env or {})} + start = time.monotonic() + p = subprocess.run([os.environ['D2X'], 'checker', '--ui', 'print', *(['--emit-events'] if ui == 'events' else [])], + cwd=root, env=settings, capture_output=True, encoding='utf-8', errors='replace', timeout=15) + self.assertLess(time.monotonic() - start, 12) + events = [json.loads(line) for line in p.stdout.splitlines()] if ui == 'events' else [] + if (root / 'child.pid').exists(): + child = int((root / 'child.pid').read_text()) + def running(): + if os.name == 'nt': + import ctypes + api = ctypes.windll.kernel32 + api.OpenProcess.restype = ctypes.c_void_p + api.GetExitCodeProcess.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)] + api.CloseHandle.argtypes = [ctypes.c_void_p] + handle = api.OpenProcess(0x1000, False, child) + if not handle: + return False + code = ctypes.c_ulong() + ok = api.GetExitCodeProcess(handle, ctypes.byref(code)) + api.CloseHandle(handle) + return bool(ok and code.value == 259) + state = subprocess.run(['ps', '-o', 'stat=', '-p', str(child)], capture_output=True, text=True).stdout.strip() + return bool(state and not state.startswith(('Z', 'X'))) + limit = time.monotonic() + 2 + while running() and time.monotonic() < limit: + time.sleep(.05) + self.assertFalse(running(), 'Provider descendant survived timeout') + logs = {path.name: path.read_text(errors='replace') for path in (root / '.d2x').glob('provider.*.log')} + return p, events, logs + + def test_old_config_and_slow_start(self): + default, events, _ = self.run_case(omit_startup=True) + self.assertEqual(default.returncode, 0) + self.assertEqual(next(e for e in events if e['event'] == 'preparing')['timeout'], 900) + for mode in ['ok', 'slow', 'flood']: + p, events, logs = self.run_case(mode) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertTrue(any(e['event'] == 'preparing' for e in events)) + self.assertEqual(events[-1]['event'], 'done') + if mode == 'flood': + self.assertIn('diagnostic', logs['provider.stderr.log']) + self.assertIn('x' * 100, logs['provider.stdout.log']) + + def test_startup_deadline_cannot_be_fed_by_stderr(self): + for mode in ['hang', 'chatty', 'tree']: + p, events, _ = self.run_case(mode, {'provider_startup_timeout': 1}) + self.assertNotEqual(p.returncode, 0) + self.assertEqual(events[-1]['code'], 'startup_timeout') + self.assertEqual(events[-1]['phase'], 'describe') + + def test_protocol_and_child_failures(self): + for mode, code, phase in [('empty', 'provider_protocol', 'describe'), + ('stderr-json', 'provider_protocol', 'describe'), + ('bad-version', 'provider_protocol', 'describe'), + ('exit', 'provider_exit', 'describe'), + ('enumerate-exit', 'provider_exit', 'exercises'), + ('enumerate-empty', 'empty_course', 'exercises'), + ('check-exit', 'provider_exit', 'check'), + ('check-hang', 'idle_timeout', 'check')]: + p, events, _ = self.run_case(mode) + self.assertNotEqual(p.returncode, 0, mode) + self.assertEqual(events[-1]['event'], 'error', mode) + self.assertEqual(events[-1]['code'], code, mode) + self.assertEqual(events[-1]['phase'], phase, mode) + if mode == 'exit': + self.assertIn('compiler failed here', events[-1]['message']) + + def test_timeout_validation_and_env_precedence(self): + for value in [0, -1, '5', True, 1.5]: + p, events, _ = self.run_case(config={'provider_startup_timeout': value}) + self.assertNotEqual(p.returncode, 0) + self.assertEqual(events[-1]['code'], 'configuration') + p, _, _ = self.run_case(config={'provider_startup_timeout': 0}, env={'D2X_PROVIDER_STARTUP_TIMEOUT': '5'}) + self.assertEqual(p.returncode, 0, p.stderr) + p, _, _ = self.run_case(env={'D2X_PROVIDER_STARTUP_TIMEOUT': '5garbage'}) + self.assertNotEqual(p.returncode, 0) + + @unittest.skipIf(os.name == 'nt', 'POSIX signal delivery; Windows Job timeout covered above') + def test_cancel_reaps_process_tree(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + command = ' '.join(shlex.quote(s) for s in [sys.executable, str(Path(__file__).resolve()), '--provider']) + (root / '.d2x.json').write_text(json.dumps({'buildtools': command})) + with subprocess.Popen([os.environ['D2X'], 'checker', '--emit-events'], cwd=root, + env={**os.environ, 'TEST_MODE': 'tree'}, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) as process: + limit = time.monotonic() + 5 + while not (root / 'child.pid').exists() and time.monotonic() < limit: + time.sleep(.05) + self.assertTrue((root / 'child.pid').exists()) + child = int((root / 'child.pid').read_text()) + process.send_signal(signal.SIGINT) + out, err = process.communicate(timeout=5) + self.assertEqual(process.returncode, 130, err) + self.assertEqual(json.loads(out.splitlines()[-1])['code'], 'cancelled') + # A killed orphan may be a zombie until init reaps it; it must + # no longer execute, even when the direct shell already died. + state = Path(f'/proc/{child}/stat') + if state.exists(): + self.assertIn(state.read_text().split()[2], ['Z', 'X']) + else: + with self.assertRaises(ProcessLookupError): + os.kill(child, 0) + self.assertFalse((root / '.d2x/checker.lock').exists()) + + def test_install_coordinates_course_dependencies(self): + for failure in [False, True]: + with tempfile.TemporaryDirectory(prefix='d2x install ') as folder: + root = Path(folder) + bin_dir = root / 'bin' + bin_dir.mkdir() + command = f'"{sys.executable}" "{Path(__file__).resolve()}" --xlings' + shim = bin_dir / ('xlings.cmd' if os.name == 'nt' else 'xlings') + shim.write_text(('@echo off\n' + command + ' %*\n') if os.name == 'nt' else + '#!/bin/sh\nexec ' + command + ' "$@"\n') + shim.chmod(0o755) + p = subprocess.run([os.environ['D2X'], 'install', 'demo'], cwd=root, + env={**os.environ, 'PATH': str(bin_dir) + os.pathsep + os.environ['PATH'], + 'TEST_INSTALL_FAIL_DEP': '1' if failure else '0'}, + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=10) + self.assertEqual(p.returncode, 1 if failure else 0, p.stdout + p.stderr) + self.assertTrue((root / 'demo/dependencies-attempted').exists()) + if failure: + self.assertNotIn('安装完成', p.stdout) + + def test_print_preparing_and_error(self): + p, _, _ = self.run_case('empty', ui='print') + self.assertNotEqual(p.returncode, 0) + self.assertIn('Preparing course environment', p.stdout) + self.assertIn('provider_protocol', p.stdout) + + +if __name__ == '__main__': + if '--xlings' in sys.argv: + args = sys.argv[2:] + if args == ['--version']: + print('xlings 2026.10.20.3') + elif args == ['install', 'd2x:demo', '-y']: + Path('demo').mkdir() + Path('demo/.d2x.json').write_text('{}') + Path('demo/.xlings.json').write_text('{"workspace":{}}') + elif args == ['install', '-y']: + Path('dependencies-attempted').write_text(str(Path.cwd())) + sys.exit(7 if os.environ['TEST_INSTALL_FAIL_DEP'] == '1' else 0) + else: + sys.exit(99) + elif '--provider' in sys.argv: + provider() + else: + unittest.main()