Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,5 @@ print/TUI 会显示准备信息。`--emit-events` 在 session 前每十秒发送
超时或调用期间取消会终止子进程树;调用期间 Ctrl+C 返回 130。

详见 [启动与传输契约](docs/provider-startup.md)。

从 2026.10.21.1 起,用户取消返回 130 和 `cancelled`,不会误报 Provider 故障或产生伪造的 fail 判定。
2 changes: 2 additions & 0 deletions docs/provider-startup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion protocol/mcpp.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
2 changes: 1 addition & 1 deletion protocol/src/transport.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export class ProcessProvider final : public IExerciseProvider {
mErrorCode, mCommand, args, std::filesystem::current_path().string(),
std::chrono::duration_cast<std::chrono::seconds>(std::chrono::steady_clock::now() - start).count(),
status.exit_code, out_tail, err_tail);
warn(mError);
if (!mCancelled) warn(mError);
}
return status;
}
Expand Down
8 changes: 7 additions & 1 deletion src/checker.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,13 @@ export int run(const std::string& start_target = "", bool emit_events = false) {
if (emit_events) sink = std::make_unique<emit::StdoutSink>();
else sink = std::make_unique<emit::UiSink>();
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 模块头注)。
Expand Down Expand Up @@ -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() +
Expand Down Expand Up @@ -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 的原因、
// 活性超时的终止说明)——它们没走过流式回调,不补发就永远到不了
// 前端/协议消费者。
Expand Down
2 changes: 1 addition & 1 deletion src/config.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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";
};

Expand Down
3 changes: 2 additions & 1 deletion src/emit.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
99 changes: 72 additions & 27 deletions tests/startup_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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]:
Expand Down
Loading