diff --git a/README.md b/README.md index 5f30ba8..c795b30 100644 --- a/README.md +++ b/README.md @@ -94,3 +94,5 @@ 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. + +Since 2026.10.21.1, user cancellation returns 130 and a `cancelled` code without Provider-failure diagnostics or a synthetic fail verdict. diff --git a/README.zh-CN.md b/README.zh-CN.md index aa7620c..ef8d13f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -83,3 +83,5 @@ print/TUI 会显示准备信息。`--emit-events` 在 session 前每十秒发送 超时或调用期间取消会终止子进程树;调用期间 Ctrl+C 返回 130。 详见 [启动与传输契约](docs/provider-startup.md)。 + +从 2026.10.21.1 起,用户取消返回 130 和 `cancelled`,不会误报 Provider 故障或产生伪造的 fail 判定。 diff --git a/docs/provider-startup.md b/docs/provider-startup.md index 9b96758..1b3d2f6 100644 --- a/docs/provider-startup.md +++ b/docs/provider-startup.md @@ -49,3 +49,5 @@ Run portable failure injection with `D2X=/absolute/path/to/d2x python tests/star `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. + +Since 2026.10.21.1, user cancellation returns 130 and a `cancelled` code without Provider-failure diagnostics or a synthetic fail verdict. diff --git a/mcpp.toml b/mcpp.toml index 6b68ebd..47ef8ed 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "d2x" -version = "2026.10.20.2" +version = "2026.10.21.1" 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 bb6cad3..4dacb7d 100644 --- a/protocol/mcpp.toml +++ b/protocol/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "d2x-protocol" -version = "2026.10.20.2" +version = "2026.10.21.1" description = "d2x Provider/Frontend 协议层:类型、编解码、传输(参考实现;规范性载体是协议文档,跨语言合规判据是 conformance 套件)" license = "Apache-2.0" diff --git a/protocol/src/transport.cppm b/protocol/src/transport.cppm index 9fa26df..ecf0965 100644 --- a/protocol/src/transport.cppm +++ b/protocol/src/transport.cppm @@ -121,7 +121,7 @@ export class ProcessProvider final : public IExerciseProvider { 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); + if (!mCancelled) warn(mError); } return status; } diff --git a/src/checker.cppm b/src/checker.cppm index d04b32c..ad4f787 100644 --- a/src/checker.cppm +++ b/src/checker.cppm @@ -122,9 +122,13 @@ export int run(const std::string& start_target = "", bool emit_events = false) { 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) { + if (code == "cancelled") { + sink->error(code, phase, "Cancelled by user"); + return 130; + } sink->error(code, phase, message); log::error("{}", message); - return code == "cancelled" ? 130 : 1; + return 1; }; try { // 单实例锁:两个监听循环互踩的破坏是静默的(见 instance_lock 模块头注)。 @@ -170,6 +174,7 @@ export int run(const std::string& start_target = "", bool emit_events = false) { // 再打 "No targets found for checking.",两层都在误导学员。 std::string provider_name; if (!provider.describe(provider_name)) { + if (provider.cancelled()) return fail("cancelled", "describe", ""); log::error("Provider 无响应: {}", command); log::error("请确认该命令可执行,且实现了 d2x Provider 协议(describe/exercises/check)"); return fail(provider.error_code(), "describe", provider.error() + @@ -215,6 +220,7 @@ export int run(const std::string& start_target = "", bool emit_events = false) { }; auto verdict = provider.check(exercise, progress); + if (provider.cancelled()) return fail("cancelled", "check", ""); // verdict.output 里可能有传输层事后补充的说明(缺 verdict 的原因、 // 活性超时的终止说明)——它们没走过流式回调,不补发就永远到不了 // 前端/协议消费者。 diff --git a/src/config.cppm b/src/config.cppm index 2bbb3c5..7e94f3f 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.2"; // 日期版本制 YYYY.MM.DD.N(见 2026-07-24 设计文档决策记录) + static constexpr std::string_view VERSION = "2026.10.21.1"; // 日期版本制 YYYY.MM.DD.N(见 2026-07-24 设计文档决策记录) static constexpr std::string_view REPO = "https://github.com/d2learn/d2x"; }; diff --git a/src/emit.cppm b/src/emit.cppm index b3231c5..61f2600 100644 --- a/src/emit.cppm +++ b/src/emit.cppm @@ -144,7 +144,8 @@ public: refresh(); } void error(std::string_view code, std::string_view phase, std::string_view message) override { - mStage.assign(phase); mOutcome = "error"; + mStage = code == "cancelled" ? "cancelled" : std::string(phase); + mOutcome = code == "cancelled" ? "" : "error"; mOutput = std::format("{}: {}", code, message); refresh(); } diff --git a/tests/startup_test.py b/tests/startup_test.py index d65d34e..c50b941 100644 --- a/tests/startup_test.py +++ b/tests/startup_test.py @@ -50,7 +50,10 @@ def provider(): if mode == 'enumerate-exit': sys.exit(8) elif verb == 'check': - if mode == 'check-hang': + if mode in ('check-hang', 'check-tree'): + if mode == 'check-tree': + child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(120)']) + Path('child.pid').write_text(str(child.pid)) time.sleep(120) emit(event='verdict', outcome='pass', stage='run') if mode == 'check-exit': @@ -147,33 +150,75 @@ def test_timeout_validation_and_env_precedence(self): 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()) + for mode in ['tree', 'check-tree']: + with tempfile.TemporaryDirectory() 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']) + (root / '.d2x.json').write_text(json.dumps({'buildtools': command})) + (root / 'one.txt').write_text('one') + allocated_console = False + process = None + if os.name == 'nt': + import ctypes + api = ctypes.windll.kernel32 + if not api.GetConsoleCP(): + self.assertTrue(api.AllocConsole(), 'Cannot allocate the test console') + allocated_console = True + try: + process = subprocess.Popen([os.environ['D2X'], 'checker', '--emit-events'], cwd=root, + env={**os.environ, 'TEST_MODE': mode}, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding='utf-8', errors='replace', + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == 'nt' else 0) + 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.CTRL_BREAK_EVENT if os.name == 'nt' else signal.SIGINT) + out, err = process.communicate(timeout=5) + self.assertEqual(process.returncode, 130, err) + events = [json.loads(line) for line in out.splitlines()] + self.assertEqual(events[-1]['code'], 'cancelled') + self.assertEqual(events[-1]['phase'], 'describe' if mode == 'tree' else 'check') + self.assertNotIn('Provider 无响应', err) + self.assertNotIn('WARNING', err) + self.assertFalse(any(e.get('event') == 'verdict' for e in events)) + if os.name == 'nt': + 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] + api.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + handle = api.OpenProcess(0x101000, False, child) + if handle: + try: + self.assertEqual(api.WaitForSingleObject(handle, 2000), 0) + code = ctypes.c_ulong() + api.GetExitCodeProcess(handle, ctypes.byref(code)) + self.assertNotEqual(code.value, 259, 'Cancelled descendant is still active') + finally: + api.CloseHandle(handle) + else: + limit = time.monotonic() + 2 + while True: + state = subprocess.run(['ps', '-o', 'stat=', '-p', str(child)], + capture_output=True, text=True).stdout.strip() + if not state or state.startswith(('Z', 'X')): + break + self.assertLess(time.monotonic(), limit, 'Cancelled descendant is still active') + time.sleep(.05) + self.assertFalse((root / '.d2x/checker.lock').exists()) + finally: + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + if allocated_console: + api.FreeConsole() def test_install_coordinates_course_dependencies(self): for failure in [False, True]: