Repository navigation
fix(lark): hold the collector's Git reads to one budget #8154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
huangruiteng
merged 3 commits into
loopx-project:main
from
gcl-coder:fix/lark-collector-git-budget
Nov 6, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,245 @@ | ||
| """The Lark collector's config admission asks Git three questions. | ||
|
|
||
| Each of those reads used to be unbounded, so a Git blocked on a stale index | ||
| lock, a network mount or an oversized index held the command open with no | ||
| answer. These tests pin the shared budget, the early refusal, and the process | ||
| tree a blocked read would otherwise leave behind. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| import shlex | ||
| import shutil | ||
| import signal | ||
| import subprocess | ||
| import sys | ||
| import time | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
|
|
||
| from loopx.extensions.lark import event_collector | ||
| from loopx.extensions.lark.event_collector import _project_config_path | ||
|
|
||
|
|
||
| def _git_project(tmp_path: Path) -> Path: | ||
| project = tmp_path / "project" | ||
| project.mkdir() | ||
| subprocess.run(["git", "init", "-q", str(project)], check=True) | ||
| (project / ".gitignore").write_text(".loopx/\n", encoding="utf-8") | ||
| return project | ||
|
|
||
|
|
||
| def _config(project: Path) -> Path: | ||
| target = project / ".loopx" / "config" / "collector.json" | ||
| target.parent.mkdir(parents=True, exist_ok=True) | ||
| target.write_text("{}", encoding="utf-8") | ||
| return target | ||
|
|
||
|
|
||
| def _fake_git(tmp_path: Path, script: str, monkeypatch: pytest.MonkeyPatch) -> Path: | ||
| bin_dir = tmp_path / "fakebin" | ||
| bin_dir.mkdir(exist_ok=True) | ||
| fake = bin_dir / "git" | ||
| fake.write_text(script, encoding="utf-8") | ||
| fake.chmod(0o755) | ||
| monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") | ||
| return bin_dir | ||
|
|
||
|
|
||
| def test_a_git_that_cannot_start_refuses_the_config( | ||
| tmp_path: Path, monkeypatch: pytest.MonkeyPatch | ||
| ) -> None: | ||
| project = _git_project(tmp_path) | ||
| _config(project) | ||
|
|
||
| def cannot_start(command, **kwargs): | ||
| raise OSError("git is not installed") | ||
|
|
||
| monkeypatch.setattr(event_collector.subprocess, "Popen", cannot_start) | ||
|
|
||
| with pytest.raises(ValueError, match="needs a Git answer"): | ||
| _project_config_path(project, ".loopx/config/collector.json") | ||
|
|
||
|
|
||
| @pytest.mark.skipif(os.name != "posix", reason="the fake git is a POSIX script") | ||
| def test_the_next_question_is_not_asked_after_one_goes_unanswered( | ||
| tmp_path: Path, monkeypatch: pytest.MonkeyPatch | ||
| ) -> None: | ||
| project = _git_project(tmp_path) | ||
| _config(project) | ||
| asked = tmp_path / "asked.txt" | ||
| asked.write_text("", encoding="utf-8") | ||
| _fake_git( | ||
| tmp_path, | ||
| "#!/bin/sh\n" | ||
| f'echo "$*" >> {shlex.quote(str(asked))}\n' | ||
| 'for argument in "$@"; do\n' | ||
| ' case "$argument" in *--error-unmatch*) sleep 30 ;; esac\n' | ||
| "done\n" | ||
| 'if [ "$3" = "rev-parse" ]; then printf "%s\\n" "$2"; exit 0; fi\n' | ||
| "exit 0\n", | ||
| monkeypatch, | ||
| ) | ||
| monkeypatch.setattr(event_collector, "GIT_PROBE_TIMEOUT_SECONDS", 0.5) | ||
|
|
||
| started = time.monotonic() | ||
| with pytest.raises(ValueError, match="needs a Git answer"): | ||
| _project_config_path(project, ".loopx/config/collector.json") | ||
| elapsed = time.monotonic() - started | ||
|
|
||
| commands = asked.read_text(encoding="utf-8") | ||
| assert "rev-parse" in commands | ||
| assert "--error-unmatch" in commands | ||
| # The ignore flag of an unproven config cannot change the answer, so the | ||
| # third read is not started at all. | ||
| assert "check-ignore" not in commands | ||
| assert elapsed < 10, f"the admission spent {elapsed:.1f}s on two questions" | ||
|
|
||
|
|
||
| @pytest.mark.skipif(os.name != "posix", reason="the fake git is a POSIX script") | ||
| def test_a_git_that_never_answers_stays_inside_the_budget( | ||
| tmp_path: Path, monkeypatch: pytest.MonkeyPatch | ||
| ) -> None: | ||
| project = _git_project(tmp_path) | ||
| _config(project) | ||
| _fake_git(tmp_path, "#!/bin/sh\nsleep 30\n", monkeypatch) | ||
| monkeypatch.setattr(event_collector, "GIT_PROBE_TIMEOUT_SECONDS", 0.3) | ||
|
|
||
| started = time.monotonic() | ||
| with pytest.raises(ValueError, match="needs a Git answer"): | ||
| _project_config_path(project, ".loopx/config/collector.json") | ||
| elapsed = time.monotonic() - started | ||
|
|
||
| assert elapsed < 5.0, f"the probe held the command open for {elapsed:.2f}s" | ||
|
|
||
|
|
||
| @pytest.mark.skipif(os.name != "posix", reason="core.fsmonitor needs a POSIX hook") | ||
| def test_a_timed_out_probe_takes_the_helpers_it_started( | ||
| tmp_path: Path, monkeypatch: pytest.MonkeyPatch | ||
| ) -> None: | ||
| """Git starts an fsmonitor hook for `ls-files`, so the budget owns it too.""" | ||
|
|
||
| project = _git_project(tmp_path) | ||
| _config(project) | ||
| subprocess.run(["git", "-C", str(project), "config", "user.email", "f@e.invalid"], check=True) | ||
| subprocess.run(["git", "-C", str(project), "config", "user.name", "Fixture"], check=True) | ||
| (project / "tracked.txt").write_text("original\n", encoding="utf-8") | ||
| subprocess.run(["git", "-C", str(project), "add", "tracked.txt"], check=True) | ||
| subprocess.run(["git", "-C", str(project), "commit", "-qm", "fixture"], check=True) | ||
|
|
||
| heartbeats = tmp_path / "heartbeats" | ||
| heartbeats.write_text("", encoding="utf-8") | ||
| hook = project / ".git" / "blocking-fsmonitor" | ||
| hook.write_text( | ||
| "#!/bin/sh\n" | ||
| f"while :; do echo tick >> {shlex.quote(str(heartbeats))}; sleep 0.1; done\n", | ||
| encoding="utf-8", | ||
| ) | ||
| hook.chmod(0o755) | ||
| subprocess.run( | ||
| ["git", "-C", str(project), "config", "core.fsmonitor", str(hook)], check=True | ||
| ) | ||
| monkeypatch.setattr(event_collector, "GIT_PROBE_TIMEOUT_SECONDS", 0.8) | ||
|
|
||
| started = time.monotonic() | ||
| with pytest.raises(ValueError, match="needs a Git answer"): | ||
| _project_config_path(project, ".loopx/config/collector.json") | ||
| assert time.monotonic() - started < 6 | ||
|
|
||
| retired = heartbeats.stat().st_size | ||
| assert retired > 0, "the fixture hook never ran, so this proves nothing" | ||
| time.sleep(0.6) | ||
| assert heartbeats.stat().st_size == retired, ( | ||
| "the fsmonitor hook and its child outlived the probe budget" | ||
| ) | ||
|
|
||
|
|
||
| def test_an_ignored_untracked_config_still_loads(tmp_path: Path) -> None: | ||
| project = _git_project(tmp_path) | ||
| target = _config(project) | ||
|
|
||
| assert _project_config_path(project, ".loopx/config/collector.json") == target.resolve() | ||
|
|
||
|
|
||
| @pytest.mark.skipif(os.name != "posix", reason="a terminal Ctrl-C is a POSIX group signal") | ||
| def test_a_cancelled_command_takes_the_git_it_started(tmp_path: Path) -> None: | ||
| """Ctrl-C during the admission retires the session the read owns. | ||
|
|
||
| `collector-plan` dispatches to `plan_lark_event_collector`, so the child | ||
| below runs that entry in its own process, as the CLI would, with a `git` | ||
| that answers everything through real Git except the ownership read: that | ||
| one blocks, the shape a real `core.fsmonitor` hook has while Git waits on | ||
| it. The interrupt then goes to the child's own process group, which is the | ||
| scope a terminal Ctrl-C reaches. Git runs in a session of its own, so the | ||
| interrupt never reaches it: only the waiting call can retire it. | ||
| """ | ||
|
|
||
| project = _git_project(tmp_path) | ||
| _config(project) | ||
| real_git = shutil.which("git") | ||
| assert real_git is not None | ||
| heartbeats = tmp_path / "heartbeats" | ||
| heartbeats.write_text("", encoding="utf-8") | ||
| stop = tmp_path / "stop" | ||
| bin_dir = tmp_path / "blocking-bin" | ||
| bin_dir.mkdir() | ||
| fake = bin_dir / "git" | ||
| fake.write_text( | ||
| f"#!{sys.executable}\n" | ||
| "import os, sys, time\n" | ||
| "from pathlib import Path\n" | ||
| "if 'ls-files' not in sys.argv:\n" | ||
| f" os.execv({real_git!r}, [{real_git!r}] + sys.argv[1:])\n" | ||
| f"heartbeats, stop = Path({str(heartbeats)!r}), Path({str(stop)!r})\n" | ||
| "while not stop.exists():\n" | ||
| " with heartbeats.open('a', encoding='utf-8') as stream:\n" | ||
| " stream.write('tick\\n')\n" | ||
| " time.sleep(0.1)\n", | ||
| encoding="utf-8", | ||
| ) | ||
| fake.chmod(0o755) | ||
| environment = { | ||
| key: value | ||
| for key, value in os.environ.items() | ||
| if not key.startswith(("PYTEST", "COVERAGE", "COV_CORE")) | ||
| } | ||
| environment.update({ | ||
| "PYTHONPATH": str(Path(__file__).resolve().parents[1]), | ||
| "LOOPX_USAGE_PING": "0", | ||
| "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", | ||
| }) | ||
| command = subprocess.Popen( | ||
| [ | ||
| sys.executable, "-c", | ||
| "import sys\n" | ||
| "from loopx.extensions.lark.event_collector import plan_lark_event_collector\n" | ||
| "plan_lark_event_collector(project=sys.argv[1], config_path=sys.argv[2])\n", | ||
| str(project), ".loopx/config/collector.json", | ||
| ], | ||
| stdout=subprocess.PIPE, | ||
| stderr=subprocess.PIPE, | ||
| text=True, | ||
| env=environment, | ||
| start_new_session=True, | ||
| ) | ||
| try: | ||
| deadline = time.monotonic() + 25 | ||
| while heartbeats.stat().st_size == 0 and time.monotonic() < deadline: | ||
| time.sleep(0.05) | ||
| assert heartbeats.stat().st_size > 0, "the fixture never blocked, so this proves nothing" | ||
| assert command.poll() is None, "the command had already returned" | ||
| os.killpg(os.getpgid(command.pid), signal.SIGINT) | ||
| command.communicate(timeout=25) | ||
| assert command.returncode != 0 | ||
| retired = heartbeats.stat().st_size | ||
| time.sleep(0.6) | ||
| assert heartbeats.stat().st_size == retired, ( | ||
| "the blocked Git and its helper outlived the cancellation" | ||
| ) | ||
| finally: | ||
| stop.write_text("stop\n", encoding="utf-8") | ||
| if command.poll() is None: | ||
| os.killpg(os.getpgid(command.pid), signal.SIGKILL) | ||
| command.communicate(timeout=25) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] 在 Ctrl-C 退出前回收隔离的 Git 进程组
新增 start_new_session=True 后,终端向 CLI 进程组发送 SIGINT 不再覆盖 Git 组。这里仅在 TimeoutExpired 时调用清理;communicate 抛 KeyboardInterrupt 时直接退出。真实 loopx lark-inbox collector-plan + Git core.fsmonitor 对照中,基线取消后无存活进程,当前 head 留下 Git、hook 和辅助子进程,心跳在 CLI 退出后 400ms 从 8 增至 16。取消后的重试会累积后台工作。请在异常退出(至少 KeyboardInterrupt)时有界回收原组、释放管道并等待,再传播取消;保留超时拒绝语义。补真实入口的 SIGINT、后代停止及正常路径对照。
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
已修,新 head
9e4215e47。取消时由等待的人回收它启动的会话
_run_git_probe现在在communicate上区分两种「没等到答案」:TimeoutExpired:回收本次调用自己的会话,返回None,配置照旧被拒绝。KeyboardInterrupt、SystemExit等):同样走_retire_owned_probe回收同一个会话,然后raise原样抛出。独立会话让预算能收回 Git 的后代,代价就是这个会话收不到终端信号——那条路径上唯一的 owner 是正在
communicate的这次调用,所以退出前必须在同一个所有权范围内收尾。超时拒绝语义没有变,Ctrl-C仍是Ctrl-C。取消回归
新增
test_a_cancelled_command_takes_the_git_it_started:在独立进程里跑collector-plan分派的plan_lark_event_collector入口,PATH 前面的git把除ls-files外的命令都交给真实 Git,ls-files则阻塞并由子进程持续写心跳(即真实 fsmonitor hook 被等待时的形状)。等心跳出现、确认命令仍在等待后,向该进程所在进程组发SIGINT,也就是终端 Ctrl-C 的作用范围。e987193a0断言覆盖三点:取消后命令非零退出、心跳在返回后 0.6 秒内不再增长、以及 fixture 确实阻塞过(心跳先出现,避免空断言)。每次运行都会写 stop 文件并回收测试自己创建的进程组;用例按
os.name != "posix"skip。同一用例在改前 head 上失败。超时分支的真实
core.fsmonitor用例保留,正常/被跟踪拒绝/非 Git 三条路径对照不变;三个文件合计67 passed,ruff --select E4,E7,E9,F全绿。