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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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` не меняется вовсе.
Expand Down
7 changes: 5 additions & 2 deletions docs/MODULE_MAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Карта модулей

Актуально для **v1.32.1** (`BUILDER_VERSION = 14` — схема индекса НЕ менялась, пересборка индексов при обновлении не требуется).
Актуально для **v1.32.2** (`BUILDER_VERSION = 14` — схема индекса НЕ менялась, пересборка индексов при обновлении не требуется).

Числа-снимки ниже застолблены тестами — если правишь сущность, обнови и число, и тест:

Expand Down Expand Up @@ -48,15 +48,17 @@
- **Граф вызовов**: каждое ребро резолвится в стабильный `callee_key = "<rel_path>::<casefold(метод)>"` (`_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`

### Детектирование формата
- **`format_detector.py`** — определение CF/EDT (`detect_format`, `SourceFormat`, `FormatInfo`), парсинг путей BSL-файлов (`parse_bsl_path`, `BslFileInfo`), `METADATA_CATEGORIES`, `MODULE_TYPE_MAP`. **v1.32.0 — гейт чужих форматов**: `SourceSupport` (`supported`/`foreign_with_bsl`/`foreign_no_bsl`), `has_our_format_descriptor` + `probe_bsl` + `classify_source` и 4 агент-/человеко-facing текста. Признак нашего формата — ВАЛИДНАЯ сигнатура дескриптора, не имя файла: CF — корень `{MDClasses}MetaDataObject` и ПЕРВЫЙ дочерний `Configuration`, EDT — корень `{mdclass}Configuration`; читается префиксно (`iterparse`, 2 start-события), боевой многомегабайтный `Configuration.xml` целиком не парсится. Раскладка — корень / прямой подкаталог / один каталог-обёртка; листинги `os.scandir` только уровней 0–1, кандидаты уровня 2 проверяются двумя точечными `open()` (без этого чужое 35k-дерево стоило бы ~800 мс на каждом `rlm_start`). `probe_bsl` повторяет discovery `IndexBuilder` (`rglob("*.bsl")`, включая скрытые каталоги) и возвращает tri-state `found`/`none`/`unknown`. `detect_format`/`SourceFormat` НЕ менялись, и `classify_source` им не доверяет. → _(нет внутренних зависимостей)_
- **`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()`. → _(нет внутренних зависимостей)_
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
92 changes: 92 additions & 0 deletions src/rlm_tools_bsl/_git_process.py
Original file line number Diff line number Diff line change
@@ -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)
42 changes: 9 additions & 33 deletions src/rlm_tools_bsl/bsl_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
Loading