From 2b485bc855222b47e978767f95ee220dde561249 Mon Sep 17 00:00:00 2001 From: Le-Anh-Duy Date: Thu, 9 Jul 2026 01:27:57 +0700 Subject: [PATCH 1/2] Fix Windows compatibility: subprocess preexec_fn is POSIX-only subprocess.Popen(preexec_fn=..., start_new_session=True) raised "preexec_fn is not supported on Windows platforms" for every LLM call and test-runner invocation, causing /cmind.encode and friends to fail outright on Windows. Branch on platform: POSIX keeps start_new_session + preexec_fn (killpg-based tree kill); Windows uses CREATE_NEW_PROCESS_GROUP and a new _kill_process_tree() helper that shells out to `taskkill /T /F` to reap the process tree instead. Verified end-to-end: reinstalled the patched package as the local cmind-cli tool and ran a full /cmind.encode against an external repo on Windows; it now completes successfully (previously failed on every LLM call). --- .gitignore | 13 ++++- CoderMind/scripts/code_gen/test_runner.py | 69 +++++++++++------------ CoderMind/scripts/common/llm_client.py | 59 ++++++++++++++----- 3 files changed, 89 insertions(+), 52 deletions(-) diff --git a/.gitignore b/.gitignore index e5a7360..a45bd42 100644 --- a/.gitignore +++ b/.gitignore @@ -421,4 +421,15 @@ FodyWeavers.xsd CoderMind/.genreleases/ release_notes.md workspace/ -.cmind \ No newline at end of file +.cmind + +# CoderMind ignores (managed by `cmind init/update`) +.cmind/* +!.cmind/config.toml +.rpgkit/ +.venv_dev/ +.cmind_dev_env/ +.vscode/mcp.json +.vscode/tasks.json +.mcp.json +.claude/commands/ diff --git a/CoderMind/scripts/code_gen/test_runner.py b/CoderMind/scripts/code_gen/test_runner.py index 19a0a75..f28ba3f 100644 --- a/CoderMind/scripts/code_gen/test_runner.py +++ b/CoderMind/scripts/code_gen/test_runner.py @@ -10,7 +10,6 @@ import os import re -import signal import subprocess import sys import shutil @@ -22,6 +21,7 @@ from .test_output_parser import TestOutputAnalysis, _parse_stats, _SUMMARY_RE from .test_output_parser import analyze_test_output from common.llm_client import LLMClient +from common.llm_client import _IS_WINDOWS, _kill_process_tree, _set_pdeathsig import json as _json from common.import_normalizer import normalize_files from common.paths import FEATURE_SPEC_FILE, REPO_RPG_FILE @@ -35,13 +35,6 @@ ) -def _set_pdeathsig() -> None: - """Preexec hook: ask the kernel to send SIGTERM to this child when its parent dies (including SIGKILL). Called after fork() but before exec() so it runs in the child's address space. Silently ignored on non-Linux.""" - try: - import ctypes, signal as _s - ctypes.CDLL("libc.so.6").prctl(1, _s.SIGTERM) # PR_SET_PDEATHSIG = 1 - except Exception: - pass # ============================================================================ @@ -416,25 +409,26 @@ def run_pytest( if env: run_env.update(env) + popen_kwargs: Dict[str, Any] = dict( + cwd=repo_root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=run_env, + ) + if _IS_WINDOWS: + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True # own process group → killpg kills pytest + children + popen_kwargs["preexec_fn"] = _set_pdeathsig # PR_SET_PDEATHSIG: killed even when parent SIGKILL'd + try: - proc = subprocess.Popen( - cmd, - cwd=repo_root, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=run_env, - start_new_session=True, # own process group → killpg kills pytest + children - preexec_fn=_set_pdeathsig, # PR_SET_PDEATHSIG: killed even when parent SIGKILL'd - ) + proc = subprocess.Popen(cmd, **popen_kwargs) try: stdout_data, stderr_data = proc.communicate(timeout=timeout) except BaseException: - # Kill the entire pytest process group (covers forked workers, etc.) - try: - os.killpg(os.getpgid(proc.pid), signal.SIGTERM) - except Exception: - proc.kill() + # Kill the entire pytest process tree (covers forked workers, etc.) + _kill_process_tree(proc) proc.wait() raise @@ -558,24 +552,25 @@ def run_project_tests( if env: run_env.update(env) + popen_kwargs: Dict[str, Any] = dict( + cwd=repo_root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=run_env, + ) + if _IS_WINDOWS: + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True + popen_kwargs["preexec_fn"] = _set_pdeathsig + try: - proc = subprocess.Popen( - cmd, - cwd=repo_root, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=run_env, - start_new_session=True, - preexec_fn=_set_pdeathsig, - ) + proc = subprocess.Popen(cmd, **popen_kwargs) try: stdout_data, stderr_data = proc.communicate(timeout=timeout) except BaseException: - try: - os.killpg(os.getpgid(proc.pid), signal.SIGTERM) - except Exception: - proc.kill() + _kill_process_tree(proc) proc.wait() raise diff --git a/CoderMind/scripts/common/llm_client.py b/CoderMind/scripts/common/llm_client.py index e9fddd7..55c9866 100644 --- a/CoderMind/scripts/common/llm_client.py +++ b/CoderMind/scripts/common/llm_client.py @@ -9,6 +9,7 @@ import json import logging import os as _os +import platform as _platform import re import shlex import signal as _signal @@ -25,9 +26,11 @@ from . import paths as _paths from .paths import REPO_DIR as _REPO_DIR, WORKSPACE_ROOT as _WORKSPACE_ROOT +_IS_WINDOWS = _platform.system() == "Windows" + def _set_pdeathsig() -> None: - """Preexec hook: ask the kernel to send SIGTERM to this child when its parent dies (including SIGKILL). Called after fork() but before exec() so it runs in the child's address space. Silently ignored on non-Linux.""" + """Preexec hook: ask the kernel to send SIGTERM to this child when its parent dies (including SIGKILL). Called after fork() but before exec() so it runs in the child's address space. Silently ignored on non-Linux. Only ever passed as ``preexec_fn`` on POSIX (see ``generate``); Windows never touches this.""" try: import ctypes, signal as _s ctypes.CDLL("libc.so.6").prctl(1, _s.SIGTERM) # PR_SET_PDEATHSIG = 1 @@ -35,6 +38,31 @@ def _set_pdeathsig() -> None: pass +def _kill_process_tree(proc: subprocess.Popen) -> None: + """Kill *proc* and its full descendant tree, cross-platform. + + POSIX: the child was launched via ``start_new_session=True`` so it + leads its own process group; killpg reaches every descendant. + Windows: the child was launched with ``CREATE_NEW_PROCESS_GROUP`` + (``preexec_fn``/``killpg`` don't exist there); ``taskkill /T`` walks + the process tree by PID instead. + """ + if _IS_WINDOWS: + try: + subprocess.run( + ["taskkill", "/PID", str(proc.pid), "/T", "/F"], + capture_output=True, + check=False, + ) + except Exception: + proc.kill() + else: + try: + _os.killpg(_os.getpgid(proc.pid), _signal.SIGTERM) + except Exception: + proc.kill() + + # ---------------------------------------------------------------------------- # AI CLI command resolution # ---------------------------------------------------------------------------- @@ -377,29 +405,32 @@ def generate( cmd = shlex.split(self.tool) + trace_ctx.extra_args # Sub-agent runs in the project repo directory. - # start_new_session=True puts the child in its own process - # group so killpg kills the whole tree on parent exit. - # preexec_fn=_set_pdeathsig handles the SIGKILL case via - # PR_SET_PDEATHSIG (kernel sends SIGTERM to child on parent death). - proc = subprocess.Popen( - cmd, + # POSIX: start_new_session=True puts the child in its own + # process group so killpg kills the whole tree on parent + # exit; preexec_fn=_set_pdeathsig handles the SIGKILL case + # via PR_SET_PDEATHSIG (kernel sends SIGTERM to child on + # parent death). Neither is supported on Windows, where + # CREATE_NEW_PROCESS_GROUP + taskkill /T (see + # _kill_process_tree) plays the same role. + popen_kwargs: Dict[str, Any] = dict( stdin=trace_ctx.stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=trace_ctx.env, cwd=_REPO_DIR, - start_new_session=True, - preexec_fn=_set_pdeathsig, ) + if _IS_WINDOWS: + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True + popen_kwargs["preexec_fn"] = _set_pdeathsig + proc = subprocess.Popen(cmd, **popen_kwargs) try: stdout, stderr = proc.communicate(timeout=timeout) except BaseException: - # Kill the entire process group (agent + any pytest children) - try: - _os.killpg(_os.getpgid(proc.pid), _signal.SIGTERM) - except Exception: - proc.kill() + # Kill the entire process tree (agent + any pytest children) + _kill_process_tree(proc) proc.wait() raise result = subprocess.CompletedProcess( From c6e2362134e59d34a3543a4c782234dcec295a37 Mon Sep 17 00:00:00 2001 From: Qingtao Li <89620019+QingtaoLi1@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:04:54 +0800 Subject: [PATCH 2/2] Potential fix for pull request finding On Windows, _kill_process_tree ignores taskkill's return code. If taskkill fails (e.g. access denied / process already exited / PID reuse edge), this function returns without killing the process, but callers then do proc.wait(), which can hang indefinitely. Please fall back to proc.kill() when taskkill returns non-zero (or use check=True and catch CalledProcessError). Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CoderMind/scripts/common/llm_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CoderMind/scripts/common/llm_client.py b/CoderMind/scripts/common/llm_client.py index 55c9866..afaa23c 100644 --- a/CoderMind/scripts/common/llm_client.py +++ b/CoderMind/scripts/common/llm_client.py @@ -49,11 +49,13 @@ def _kill_process_tree(proc: subprocess.Popen) -> None: """ if _IS_WINDOWS: try: - subprocess.run( + res = subprocess.run( ["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, check=False, ) + if res.returncode != 0: + proc.kill() except Exception: proc.kill() else: