Skip to content
Open
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
13 changes: 12 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -421,4 +421,15 @@ FodyWeavers.xsd
CoderMind/.genreleases/
release_notes.md
workspace/
.cmind
.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/
69 changes: 32 additions & 37 deletions CoderMind/scripts/code_gen/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import os
import re
import signal
import subprocess
import sys
import shutil
Expand All @@ -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
Comment thread
QingtaoLi1 marked this conversation as resolved.
Comment on lines 23 to 25
from common.import_normalizer import normalize_files
from common.paths import FEATURE_SPEC_FILE, REPO_RPG_FILE
Expand All @@ -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


# ============================================================================
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
61 changes: 47 additions & 14 deletions CoderMind/scripts/common/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json
import logging
import os as _os
import platform as _platform
import re
Comment on lines 11 to 13
import shlex
import signal as _signal
Expand All @@ -25,16 +26,45 @@
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
except Exception:
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:
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:
try:
_os.killpg(_os.getpgid(proc.pid), _signal.SIGTERM)
except Exception:
proc.kill()


# ----------------------------------------------------------------------------
# AI CLI command resolution
# ----------------------------------------------------------------------------
Expand Down Expand Up @@ -377,29 +407,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(
Expand Down