diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d6443..222af4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.32.2] — 2026-08-12 + +Hotfix зависания Git-хелперов на Windows после истечения subprocess-timeout. Контракты хелперов, стратегия, состав MCP-тулов, схема SQLite и `BUILDER_VERSION` (14) не тронуты — пересборка индексов не требуется. + +### Исправлено +- **Git-команды могли зависнуть навсегда уже после штатного timeout на Windows.** `git.exe` из `Git\\cmd` запускает дочерний процесс из `Git\\mingw64\\bin`; прежний `subprocess.run(..., capture_output=True, timeout=...)` завершал только непосредственного родителя, после чего без ограничения ждал закрытия stdout/stderr. Дочерний Git сохранял унаследованные pipe-хэндлы открытыми, поэтому `rlm_start`, обновление индекса и `git_search` не возвращали управление даже после истечения настроенного лимита. Теперь все восемь Git-вызовов идут через единый bounded runner: stdin закрыт через `DEVNULL`, на Windows при timeout дерево завершается системным `taskkill /T /F` с собственным лимитом, затем всегда применяется fallback к непосредственному процессу, а чтение pipe и ожидание также ограничены. Исходный `TimeoutExpired` сохраняется, поэтому существующие fallback-сценарии хелперов работают как раньше; на других ОС cleanup непосредственного процесса тоже больше не может уйти в неограниченное ожидание. + ## [1.32.1] — 2026-08-08 Hotfix зависания `rlm_start` на Windows при stdio-транспорте. Контракты хелперов, стратегия, состав MCP-тулов, схема SQLite и `BUILDER_VERSION` (14) не тронуты — пересборка индексов не требуется. Поведение на `streamable-http` не меняется вовсе. diff --git a/docs/MODULE_MAP.md b/docs/MODULE_MAP.md index 92a6902..f3dc1a6 100644 --- a/docs/MODULE_MAP.md +++ b/docs/MODULE_MAP.md @@ -1,6 +1,6 @@ # Карта модулей -Актуально для **v1.32.1** (`BUILDER_VERSION = 14` — схема индекса НЕ менялась, пересборка индексов при обновлении не требуется). +Актуально для **v1.32.2** (`BUILDER_VERSION = 14` — схема индекса НЕ менялась, пересборка индексов при обновлении не требуется). Числа-снимки ниже застолблены тестами — если правишь сущность, обнови и число, и тест: @@ -48,8 +48,9 @@ - **Граф вызовов**: каждое ребро резолвится в стабильный `callee_key = "::"` (`_make_callee_key` — единый источник для build и query) по двум tier'ам — local (`B()`) и common_exported (`A.B()` через `_build_common_exported`); неоднозначные/платформенные → `NULL` by design. `_reresolve_qualified_callers` держит инвариант `update ≡ build` по `callee_key`. Перф: выражение-индексы `idx_calls_callee_short`/`idx_calls_callee` (единый источник `_callee_short_expr`/`_callee_match_clause`) + FK-индекс `idx_meth_module`, оба с безусловным self-heal в `_update_locked`. - **Чтение (read-only слой поверх схемы)**: `get_callers` (+`edge_exact`), `get_inbound_edges` (не-call рёбра: подписки, регламентные задания, обработчики форм, CFE-перехваты — в общем пространстве `callee_key`), `resolve_target_identity`, `find_metadata_refs_from`, `sample_method_definitions` (дешёвая проба многозначности имени для ambiguity-guard `find_path`), exact-ридеры (`get_roles_exact`/`get_event_subscriptions_exact`/`get_functional_options_exact`) и счётчики (`count_regions`/`count_module_headers`/`count_metadata_references`/`count_code_usages`/`count_overrides_by_extension_root`). Read-time фильтр шума в `get_register_movements`/`get_register_writers` (`_MOVEMENT_METHOD_NOISE` — `Движения.Записать()` и прочие методы набора не выдаются за регистры даже на старом индексе, без пересборки). Кириллица в substring-поиске — через `py_lower(col) LIKE py_lower(?)`, а не `COLLATE NOCASE` (тот фолдит только ASCII). Декоратор `@_transient_safe` гасит ДВА транзиентных `OperationalError` in-place пересборки (`no such table`, `database is locked`) и ре-raise'ит всё остальное. - **Общее с хелперами**: `_scan_module` (multiline/comment/string-aware сканер — используется и в FS-fallback `bsl_helpers`), `_iter_metadata_xml_files` (path-scan XML/MDO — DRY с extension pass `bsl_helpers`), `_git_grep` + санитайзеры (`_sanitize_grep_path` нормализует внутренний backslash, режет ведущие `/`/`\`, drive/glob/`..`/UNC). + - **Git-процессы (v1.32.2)**: все восемь точек запуска Git используют `_git_process.run_git`; timeout сохраняет прежнюю семантику fallback, но cleanup процесса и pipe теперь ограничен по времени, включая завершение дерева процессов на Windows. - → `bsl_knowledge` (`BSL_PATTERNS`, `_merge_proc_continuations`), `cache`, `format_detector`, `bsl_xml_parsers`, `extension_detector` + → `_git_process`, `bsl_knowledge` (`BSL_PATTERNS`, `_merge_proc_continuations`), `cache`, `format_detector`, `bsl_xml_parsers`, `extension_detector` - **`bsl_xml_parsers.py`** — парсеры XML-метаданных 1С, **оба формата (CF и EDT)** за общим фасадом (`parse_metadata_xml` → `dict | None`, `parse_form_xml`, `parse_rights_xml`, `parse_event_subscription_xml`, `parse_scheduled_job_xml`, `parse_enum_xml`, `parse_predefined_items`, `parse_functional_option_xml`, `parse_http_service_xml`, `parse_web_service_xml`, `parse_xdto_package_xml`/`parse_xdto_types`, `parse_exchange_plan_content`, `parse_defined_type`, `parse_pvh_characteristics`, `parse_command_parameter_type`, `canonicalize_type_ref`, `normalize_type_string`). `_RU_META_FORMS` — единый источник RU/EN форм метаданных + производные карты (`_CODE_MANAGER_COLLECTIONS`/`_CODE_QUERY_COLLECTIONS`/`_RU_REFTYPE_TO_CANONICAL`) для code-usage-экстрактора; leaf → импортируется и в `bsl_index`, и в `bsl_helpers` без циклов. → `format_detector` ### Детектирование формата @@ -57,6 +58,7 @@ - **`extension_detector.py`** — обнаружение расширений 1С и перехватов методов: `ConfigRole` (MAIN/EXTENSION), `ExtensionContext`, `detect_extension_context`, `resolve_config_root`, `find_extension_overrides` (скан `&Вместо/&Перед/&После/&ИзменениеИКонтроль`; опциональные diagnostics фиксируют неполный обход и нечитаемые BSL-файлы), `_build_warnings`, `_ext_list_cap`. → `format_detector`, `helpers` ### Инфраструктура +- **`_git_process.py`** — leaf (v1.32.2): единый `run_git(args, timeout)` с `DEVNULL` для stdin и UTF-8/`errors="replace"` для captured stdout/stderr. При timeout Windows-дерево best-effort завершается через системный `taskkill.exe /T /F` с отдельным лимитом; затем на всех ОС завершается непосредственный процесс, а drain pipe и ожидание имеют собственные границы. Всегда пере-брасывает исходный `subprocess.TimeoutExpired`. → _(нет внутренних зависимостей)_ - **`helpers.py`** — generic-тулбокс песочницы (не BSL-специфичный): `make_helpers(base_path, idx_reader=None)` → `read_file`, `read_files`, `grep`, `grep_summary`, `grep_read`, `glob_files`, `tree`, `find_files` + резолвер `_resolve_safe` (base-only). Кэши файлов/грепа, `_SKIP_DIRS`/`_BINARY_EXTENSIONS` (переиспользуются `extension_detector`, `bsl_helpers`, `server`), индексные fast-path'ы через `idx_reader` с FS-fallback на zero-hit. → `regex_safety` - **`regex_safety.py`** — leaf-guard против catastrophic backtracking (ReDoS): `has_catastrophic_nesting(pattern)` + общий текст ошибки `NESTED_QUANTIFIER_ERROR`. Зовётся ПЕРВЫМ действием на ОБОИХ входах grep (`helpers.grep`, `bsl_helpers.safe_grep`) — до кэша/`re.compile`/прогрева индекса. Эвристика по СТРУКТУРЕ (вложенные неограниченные кванторы), не полноценный wall-clock-kill: bounded-кванторы (`(\d{4})+`) не блокируются. → _(нет внутренних зависимостей)_ - **`log_retention.py`** — leaf: ротация `server.log` по ВРЕМЕНИ на старте (`purge_log_older_than`, окно `RLM_LOG_RETENTION_DAYS`, дефолт 20; `log_retention_days()`). Лог append-only и хронологический → достаточно одной точки среза. Работает на БАЙТАХ с ASCII-регексом (хвост переписывается verbatim — старые логи смешанной кодировки не портятся), понимает timestamp watchdog-строк службы, перезапись атомарная (temp + `os.replace`), **никогда не бросает исключений**. Должен вызываться ДО открытия файла писателем: на Windows — из службы (`_service_win`), иначе — из `server.main()`. → _(нет внутренних зависимостей)_ @@ -124,6 +126,7 @@ graph TD bsl_helpers --> _format bsl_index --> bsl_knowledge + bsl_index --> _git_process bsl_index --> cache bsl_index --> format_detector bsl_index --> bsl_xml_parsers diff --git a/pyproject.toml b/pyproject.toml index 1d1787b..2ed4a13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rlm-tools-bsl" -version = "1.32.1" +version = "1.32.2" description = "MCP server for token-efficient 1C BSL codebase analysis" readme = "README.md" license = { file = "LICENSE" } diff --git a/src/rlm_tools_bsl/_git_process.py b/src/rlm_tools_bsl/_git_process.py new file mode 100644 index 0000000..984833a --- /dev/null +++ b/src/rlm_tools_bsl/_git_process.py @@ -0,0 +1,92 @@ +"""Bounded execution of Git subprocesses.""" + +from __future__ import annotations + +import os +import subprocess +from collections.abc import Sequence +from os import PathLike +from pathlib import Path +from typing import IO + +_TASKKILL_TIMEOUT_SECONDS = 5 +_POST_TIMEOUT_DRAIN_SECONDS = 2 +_POST_TIMEOUT_WAIT_SECONDS = 2 + + +def _taskkill_tree(pid: int) -> None: + """Best-effort termination of a Windows process tree.""" + system_root = Path(os.environ.get("SystemRoot", r"C:\Windows")) + taskkill = system_root / "System32" / "taskkill.exe" + try: + subprocess.run( + [str(taskkill), "/PID", str(pid), "/T", "/F"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=_TASKKILL_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, ValueError, subprocess.TimeoutExpired): + pass + + +def _kill_direct_process(process: subprocess.Popen[str]) -> None: + """Best-effort fallback for the immediate child process.""" + if process.poll() is not None: + return + try: + process.kill() + except OSError: + pass + + +def _close_pipe(pipe: IO[str] | None) -> None: + if pipe is None: + return + try: + pipe.close() + except (OSError, ValueError): + pass + + +def _drain_after_timeout(process: subprocess.Popen[str]) -> None: + """Collect pipes only while collection remains bounded.""" + try: + process.communicate(timeout=_POST_TIMEOUT_DRAIN_SECONDS) + return + except (OSError, ValueError, subprocess.TimeoutExpired): + _close_pipe(process.stdout) + _close_pipe(process.stderr) + + try: + process.wait(timeout=_POST_TIMEOUT_WAIT_SECONDS) + except (OSError, ValueError, subprocess.TimeoutExpired): + _kill_direct_process(process) + + +def run_git( + args: Sequence[str | PathLike[str]], + timeout: float, +) -> subprocess.CompletedProcess[str]: + """Run Git command *args* with a bounded timeout.""" + command = list(args) + process = subprocess.Popen( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + try: + stdout, stderr = process.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + if os.name == "nt": + _taskkill_tree(process.pid) + _kill_direct_process(process) + _drain_after_timeout(process) + raise + + return subprocess.CompletedProcess(command, process.returncode, stdout, stderr) diff --git a/src/rlm_tools_bsl/bsl_index.py b/src/rlm_tools_bsl/bsl_index.py index 43c54c3..b81aa1d 100644 --- a/src/rlm_tools_bsl/bsl_index.py +++ b/src/rlm_tools_bsl/bsl_index.py @@ -23,6 +23,7 @@ from pathlib import Path from typing import NamedTuple +from rlm_tools_bsl._git_process import run_git from rlm_tools_bsl.bsl_knowledge import BSL_PATTERNS, _merge_proc_continuations from rlm_tools_bsl.cache import _paths_hash from rlm_tools_bsl.format_detector import BslFileInfo, parse_bsl_path @@ -1080,15 +1081,6 @@ def _find_metadata_xml(obj_dir: Path, category: str) -> Path | None: _git_exe: str | None | bool = None # cached: str=path, False=not found, None=not yet searched -# Common kwargs for all git subprocess calls. -# errors="replace" handles cp1251 stderr from git on Windows services. -_GIT_SUBPROCESS_KW: dict = { - "capture_output": True, - "text": True, - "encoding": "utf-8", - "errors": "replace", -} - class _GitDirtyResult(NamedTuple): """Result of a git dirty / changed-files detection. @@ -1219,11 +1211,7 @@ def _git_available(base_path: str) -> bool: logger.debug("_git_available: _find_git returned None") return False try: - r = subprocess.run( - [*cmd, "rev-parse", "--is-inside-work-tree"], - **_GIT_SUBPROCESS_KW, - timeout=10, - ) + r = run_git([*cmd, "rev-parse", "--is-inside-work-tree"], timeout=10) ok = r.returncode == 0 and r.stdout.strip() == "true" if not ok: logger.info( @@ -1249,11 +1237,7 @@ def _git_repo_info(base_path: str) -> tuple[str, str] | None: if cmd is None: return None try: - r = subprocess.run( - [*cmd, "rev-parse", "--show-toplevel"], - **_GIT_SUBPROCESS_KW, - timeout=10, - ) + r = run_git([*cmd, "rev-parse", "--show-toplevel"], timeout=10) if r.returncode != 0: return None git_root = r.stdout.strip() @@ -1271,11 +1255,7 @@ def _git_head_sha(base_path: str) -> str | None: if cmd is None: return None try: - r = subprocess.run( - [*cmd, "rev-parse", "HEAD"], - **_GIT_SUBPROCESS_KW, - timeout=10, - ) + r = run_git([*cmd, "rev-parse", "HEAD"], timeout=10) return r.stdout.strip() if r.returncode == 0 else None except (subprocess.TimeoutExpired, OSError): return None @@ -1296,11 +1276,7 @@ def _git_changed_files(base_path: str, since_commit: str, prefix: str) -> _GitDi if cmd is None: return None try: - r = subprocess.run( - [*cmd, "merge-base", "--is-ancestor", since_commit, "HEAD"], - **_GIT_SUBPROCESS_KW, - timeout=60, - ) + r = run_git([*cmd, "merge-base", "--is-ancestor", since_commit, "HEAD"], timeout=60) if r.returncode != 0: return None except (subprocess.TimeoutExpired, OSError): @@ -1313,7 +1289,7 @@ def _git_changed_files(base_path: str, since_commit: str, prefix: str) -> _GitDi def _run_critical(args: list[str]) -> set[str] | None: """Run a critical git command; ``None`` on failure aborts the fast path.""" try: - r = subprocess.run(args, **_GIT_SUBPROCESS_KW, timeout=60) + r = run_git(args, timeout=60) if r.returncode != 0: return None return _lines_to_set(r.stdout) @@ -1329,7 +1305,7 @@ def _run_best_effort(args: list[str], label: str, parser=_lines_to_set) -> set[s """ nonlocal unreliable_reason try: - r = subprocess.run(args, **_GIT_SUBPROCESS_KW, timeout=best_effort_timeout) + r = run_git(args, timeout=best_effort_timeout) if r.returncode != 0: logger.info("_git_changed_files: %s rc=%d, marking unreliable", label, r.returncode) if unreliable_reason is None: @@ -1446,7 +1422,7 @@ def _git_current_dirty(base_path: str, prefix: str) -> _GitDirtyResult: def _run(cmd: list[str], label: str, parser=_lines_to_set) -> set[str]: nonlocal unreliable_reason try: - r = subprocess.run(cmd, **_GIT_SUBPROCESS_KW, timeout=best_effort_timeout) + r = run_git(cmd, timeout=best_effort_timeout) if r.returncode == 0: return parser(r.stdout) if unreliable_reason is None: @@ -1805,7 +1781,7 @@ def _fail(kind: str, **extra) -> None: t = timeout if timeout is not None else _git_grep_timeout() try: - r = subprocess.run(grep_cmd, **_GIT_SUBPROCESS_KW, timeout=t) + r = run_git(grep_cmd, timeout=t) except (subprocess.TimeoutExpired, OSError, ValueError) as exc: logger.info("_git_grep: %s: %s", type(exc).__name__, exc) kind = "timeout" if isinstance(exc, subprocess.TimeoutExpired) else "spawn_failed" diff --git a/tests/test_git_delta.py b/tests/test_git_delta.py index 312d464..d5d5bef 100644 --- a/tests/test_git_delta.py +++ b/tests/test_git_delta.py @@ -99,16 +99,16 @@ def _install_worktree_timeout(monkeypatch) -> None: work-tree probes (which carry ``--ignore-cr-at-eol`` or ``ls-files --others``) raise ``TimeoutExpired``, marking detection unreliable. """ - real_run = subprocess.run + real_run_git = bsl_index_mod.run_git - def fake_run(args, **kwargs): + def fake_run_git(args, timeout): is_worktree_diff = "--ignore-cr-at-eol" in args is_untracked = "ls-files" in args and "--others" in args if is_worktree_diff or is_untracked: - raise subprocess.TimeoutExpired(cmd=args, timeout=kwargs.get("timeout", 60)) - return real_run(args, **kwargs) + raise subprocess.TimeoutExpired(cmd=args, timeout=timeout) + return real_run_git(args, timeout) - monkeypatch.setattr(bsl_index_mod.subprocess, "run", fake_run) + monkeypatch.setattr(bsl_index_mod, "run_git", fake_run_git) # --------------------------------------------------------------------------- @@ -171,6 +171,19 @@ def git_bsl_project_with_metadata(tmp_path, monkeypatch): class TestGitAvailable: + def test_uses_common_runner(self, monkeypatch): + calls = [] + + def fake_run_git(args, timeout): + calls.append((args, timeout)) + return subprocess.CompletedProcess(args, 0, "true\n", "") + + monkeypatch.setattr(bsl_index_mod, "_git_base_cmd", lambda base_path: ["git", "-C", base_path]) + monkeypatch.setattr(bsl_index_mod, "run_git", fake_run_git) + + assert _git_available("repo") is True + assert calls == [(["git", "-C", "repo", "rev-parse", "--is-inside-work-tree"], 10)] + def test_inside_repo(self, git_bsl_project): assert _git_available(str(git_bsl_project)) is True diff --git a/tests/test_git_process.py b/tests/test_git_process.py new file mode 100644 index 0000000..2b391a3 --- /dev/null +++ b/tests/test_git_process.py @@ -0,0 +1,129 @@ +"""Tests for the bounded Git subprocess runner.""" + +import importlib.util +import os +import subprocess +import sys +import time + +import pytest + +from _process_test_utils import pid_alive, wait_until +from rlm_tools_bsl import _git_process as git_process_mod +from rlm_tools_bsl._git_process import run_git + + +def test_git_process_module_exists(): + assert importlib.util.find_spec("rlm_tools_bsl._git_process") is not None + + +def test_run_git_captures_unicode_and_return_code(): + result = run_git( + [ + sys.executable, + "-c", + "import sys; " + "sys.stdout.reconfigure(encoding='utf-8'); " + "sys.stderr.reconfigure(encoding='utf-8'); " + "print('Привет'); print('ошибка', file=sys.stderr); raise SystemExit(7)", + ], + timeout=5, + ) + + assert result.returncode == 7 + assert result.stdout.strip() == "Привет" + assert result.stderr.strip() == "ошибка" + + +def test_run_git_supplies_eof_to_stdin(): + started = time.monotonic() + + result = run_git( + [sys.executable, "-c", "import sys; print(len(sys.stdin.read()))"], + timeout=5, + ) + + assert result.returncode == 0 + assert result.stdout.strip() == "0" + assert time.monotonic() - started < 5 + + +def test_run_git_timeout_is_bounded(): + started = time.monotonic() + + with pytest.raises(subprocess.TimeoutExpired): + run_git([sys.executable, "-c", "import time; time.sleep(60)"], timeout=0.2) + + assert time.monotonic() - started < 10 + + +@pytest.mark.skipif(os.name != "nt", reason="Windows process-tree regression") +def test_run_git_timeout_terminates_descendants_holding_pipes(tmp_path): + parent_pid_file = tmp_path / "parent.pid" + child_pid_file = tmp_path / "child.pid" + script = tmp_path / "spawn_descendant.py" + script.write_text( + "\n".join( + [ + "import os", + "import subprocess", + "import sys", + "import time", + "from pathlib import Path", + "parent_pid_file, child_pid_file = map(Path, sys.argv[1:])", + "child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])", + "parent_pid_file.write_text(str(os.getpid()), encoding='ascii')", + "child_pid_file.write_text(str(child.pid), encoding='ascii')", + "print('ready', flush=True)", + "time.sleep(60)", + ] + ), + encoding="utf-8", + ) + started = time.monotonic() + + with pytest.raises(subprocess.TimeoutExpired): + run_git([sys.executable, str(script), str(parent_pid_file), str(child_pid_file)], timeout=1) + + elapsed = time.monotonic() - started + assert elapsed < 10 + assert parent_pid_file.exists() + assert child_pid_file.exists() + parent_pid = int(parent_pid_file.read_text(encoding="ascii")) + child_pid = int(child_pid_file.read_text(encoding="ascii")) + assert wait_until(lambda: not pid_alive(parent_pid)) + assert wait_until(lambda: not pid_alive(child_pid)) + + +@pytest.mark.skipif(os.name != "nt", reason="Windows taskkill fallback") +def test_run_git_preserves_timeout_when_taskkill_fails(monkeypatch, tmp_path): + pid_file = tmp_path / "process.pid" + script = tmp_path / "sleep.py" + script.write_text( + "\n".join( + [ + "import os", + "import sys", + "import time", + "from pathlib import Path", + "Path(sys.argv[1]).write_text(str(os.getpid()), encoding='ascii')", + "time.sleep(60)", + ] + ), + encoding="utf-8", + ) + + def fail_taskkill(*args, **kwargs): + raise OSError("taskkill unavailable") + + monkeypatch.setattr(git_process_mod.subprocess, "run", fail_taskkill) + started = time.monotonic() + + with pytest.raises(subprocess.TimeoutExpired) as raised: + run_git([sys.executable, str(script), str(pid_file)], timeout=3) + + assert raised.value.timeout == 3 + assert time.monotonic() - started < 10 + assert pid_file.exists() + pid = int(pid_file.read_text(encoding="ascii")) + assert wait_until(lambda: not pid_alive(pid)) diff --git a/uv.lock b/uv.lock index ee10fbb..23d8b2e 100644 --- a/uv.lock +++ b/uv.lock @@ -1115,7 +1115,7 @@ wheels = [ [[package]] name = "rlm-tools-bsl" -version = "1.32.1" +version = "1.32.2" source = { editable = "." } dependencies = [ { name = "anthropic" },