Skip to content
Merged
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
237 changes: 189 additions & 48 deletions loopx/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from .control_plane.todos.active_state_editing import COMPLETED_WORK_ARCHIVE_HEADING
from .control_plane.todos.active_state_todo_parser import active_state_incomplete_warning
from .control_plane.todos.authoring_scope import todo_contract_diagnostics
from .extensions.process_runtime import terminate_process_tree
from .history import (
RunHistoryAudit,
build_run_history_audit,
Expand Down Expand Up @@ -319,54 +320,135 @@ def _credential_hits_are_all_references(line: str) -> bool:
TERMINAL_TODO_STATUSES = {TODO_STATUS_DONE, TODO_STATUS_DEFERRED, "completed", "closed", "archived"}


def _git_worktree_root(path: Path) -> Path | None:
path = path.resolve()
target = path if path.is_dir() else path.parent
# A scan reads a checkout the operator may not control, so a Git that blocks on
# a stale index lock, a network mount or an oversized index must not hold the
# scan open. Git runs helpers of its own -- an fsmonitor hook is the common one
# on `ls-files` -- so a budget that stops the caller has to stop that work too.
GIT_PROBE_TIMEOUT_SECONDS = 5.0


class GitProbeUnavailable(RuntimeError):
"""Git did not answer an ownership question inside the scan budget.

A missing answer is not a negative one. Callers of this module decide
whether an unanswered question lets them pass; they cannot read it as
"not tracked", because a tracked private file would then be published.
"""


@dataclass(frozen=True)
class _GitAnswer:
returncode: int
stdout: str


def _retire_owned_probe(process: subprocess.Popen[str]) -> None:
"""Stop this probe's own process group, then drop its pipes unread.

A descendant that inherited the pipes keeps them open after the leader
dies, so draining them would wait on work this call no longer owns.
"""

try:
terminate_process_tree(process, grace_seconds=0.2)
except (OSError, RuntimeError):
pass
finally:
for stream in (process.stdout, process.stderr):
if stream is not None:
stream.close()
try:
process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
pass


def _git_query(*arguments: str) -> _GitAnswer | None:
"""One bounded local Git read, or None when it cannot answer in budget.

Git is started in a session of its own so an exhausted budget retires the
helpers that read would have started along with it. That session also keeps
a terminal Ctrl-C away from Git, so an interrupted wait retires the same
helpers before the cancellation keeps travelling.
"""

argv = ["git", *arguments]
try:
root = subprocess.run(
["git", "-C", str(target), "rev-parse", "--show-toplevel"],
process = subprocess.Popen(
argv,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
capture_output=True,
check=True,
).stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
start_new_session=os.name == "posix",
)
except OSError:
return None
try:
stdout, _stderr = process.communicate(timeout=GIT_PROBE_TIMEOUT_SECONDS)

Copy link
Copy Markdown
Collaborator

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 check --scan-root + Git core.fsmonitor 对照中,基线取消后无存活进程,当前 head 留下 Git、hook 和辅助子进程,心跳在 CLI 退出后 400ms 从 8 增至 15。取消后的重试会累积后台工作。请在异常退出(至少 KeyboardInterrupt)时有界回收原组、释放管道并等待,再传播取消;保留超时拒绝语义。补真实入口的 SIGINT、后代停止及正常路径对照。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这条成立:独立会话让终端 SIGINT 只到 CLI 自己的进程组,communicate 抛 KeyboardInterrupt 时会直接从唯一的持有者那里逃出去,那个会话里的 Git、hook 和辅助子进程都留着。

已在本分支补上:_git_query 在 except subprocess.TimeoutExpired 之后加了一个 except BaseException 分支,先 _retire_owned_probe(process) 回收本次拥有的会话,再原样 raise。超时仍然返回 unavailable 并回收,取消仍然是取消,正常路径不变。

回归用真实 Git 加被阻塞的 ls-files,等心跳出现后向扫描的进程组发 SIGINT:断言非零退出、返回后 0.6 秒心跳不再增长。这条用例在改前的 head(91c2cc681)上失败,本提交上通过;既有的超时用例与正常对照不变。

新 head:804cabfb2。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

补一句:上一条报的 head 804cabfb2 有一处 lint 漏网——tests/test_contract_git_probe_cancel.py 里未使用的 import json(F401)、Popen 参数列表里的隐式字符串拼接(ISC004)、以及 import 排序(I001)。提交前的检查没有按退出码拦住,是我的疏漏,已经修掉并 amend。

当前 head 是 7462420d3,逻辑没有变化:tests/test_contract_git_probe_cancel.py + tests/test_contract_git_probe_budget.py 仍是 6 passed;新测试文件在仓库默认规则下 ruff check 全绿,loopx/contract.py 在 CI 的 E4/E7/E9/F 选择集下也全绿。

except subprocess.TimeoutExpired:
_retire_owned_probe(process)
return None
except BaseException:
# The operator cancelled the scan. An exhausted budget keeps answering
# "unavailable", while Ctrl-C retires this read's helpers and stays the
# cancellation the caller raised.
_retire_owned_probe(process)
raise
return _GitAnswer(returncode=process.returncode, stdout=stdout)


def _git_worktree_root(path: Path) -> Path | None:
"""Return the worktree root, or None when Git says this is not one.

Raises :class:`GitProbeUnavailable` when Git could not answer at all, so
callers cannot mistake silence for "no repository here".
"""

path = path.resolve()
target = path if path.is_dir() else path.parent
answer = _git_query("-C", str(target), "rev-parse", "--show-toplevel")
if answer is None:
raise GitProbeUnavailable("git did not answer the worktree probe")
if answer.returncode != 0:
return None
root = answer.stdout.strip()
if not root:
return None
return Path(root).resolve()


def _git_probe(path: Path) -> dict[str, Any]:
"""Report Git ownership of one path; ``None`` marks a question Git did not answer."""

path = path.resolve()
repo_root = _git_worktree_root(path)
try:
repo_root = _git_worktree_root(path)
except GitProbeUnavailable:
return {"inside_worktree": None, "tracked": None, "ignored": None}
if repo_root is None:
return {"inside_worktree": False, "tracked": False, "ignored": False}
try:
rel_path = str(path.relative_to(repo_root))
except ValueError:
rel_path = str(path)
tracked = (
subprocess.run(
["git", "-C", str(repo_root), "ls-files", "--error-unmatch", "--", rel_path],
text=True,
encoding="utf-8",
errors="replace",
capture_output=True,
check=False,
).returncode
== 0
)
ignored = (
subprocess.run(
["git", "-C", str(repo_root), "check-ignore", "-q", "--", rel_path],
text=True,
encoding="utf-8",
errors="replace",
capture_output=True,
check=False,
).returncode
== 0
)

def _ask(*arguments: str) -> bool | None:
answer = _git_query(*arguments)
return None if answer is None else answer.returncode == 0

tracked = _ask("-C", str(repo_root), "ls-files", "--error-unmatch", "--", rel_path)
if tracked is None:
# Ownership is already unproven, and no caller can act on the ignore
# flag of a file whose ownership is unknown; the second read is wasted.
return {
"inside_worktree": True,
"root": str(repo_root),
"tracked": None,
"ignored": None,
}
ignored = _ask("-C", str(repo_root), "check-ignore", "-q", "--", rel_path)
return {
"inside_worktree": True,
"root": str(repo_root),
Expand Down Expand Up @@ -941,6 +1023,13 @@ def _authority_source_diagnostics(


def _tracked_scan_files(scan_root: Path) -> list[Path]:
"""Enumerate the Git-owned files under one scan root.

Raises :class:`GitProbeUnavailable` when Git did not answer: an incomplete
enumeration would silently drop the files a pruned directory hides, which
is exactly what the caller cannot afford to miss.
"""

scan_root = scan_root.resolve()
repo_root = _git_worktree_root(scan_root)
if repo_root is None:
Expand All @@ -952,14 +1041,9 @@ def _tracked_scan_files(scan_root: Path) -> list[Path]:
if not rel_root:
rel_root = "."

tracked = subprocess.run(
["git", "-C", str(repo_root), "ls-files", "-z", "--", rel_root],
text=True,
encoding="utf-8",
errors="replace",
capture_output=True,
check=False,
)
tracked = _git_query("-C", str(repo_root), "ls-files", "-z", "--", rel_root)
if tracked is None:
raise GitProbeUnavailable("git did not answer the tracked-file enumeration")
if tracked.returncode != 0:
return []

Expand Down Expand Up @@ -1063,14 +1147,24 @@ def _probe_directory_listing(


def iter_scan_files(
scan_root: Path, *, unreadable_directories: list[tuple[Path, str]] | None = None
scan_root: Path,
*,
unreadable_directories: list[tuple[Path, str]] | None = None,
unavailable_git_probes: list[str] | None = None,
) -> list[Path]:
scan_root = scan_root.resolve()
if scan_root.is_file():
return [scan_root]
files: list[Path] = []
unreadable = unreadable_directories if unreadable_directories is not None else []
tracked_files = _tracked_scan_files(scan_root)
try:
tracked_files = _tracked_scan_files(scan_root)
except GitProbeUnavailable:
# Walk what is reachable, but remember that the Git side of the
# enumeration is missing so the scan cannot be called clean.
if unavailable_git_probes is not None:
unavailable_git_probes.append(str(scan_root))
tracked_files = []
# The root is probed even when dependency pruning skips the walk, so an
# unreadable scan target cannot pass because of how it was named.
_probe_directory_listing(scan_root, unreadable)
Expand Down Expand Up @@ -1126,6 +1220,7 @@ def scan_public_boundary(
credential_reference_hits: list[str] = []
unreadable_files: list[str] = []
unreadable_directories: list[tuple[Path, str]] = []
unavailable_git_probes: list[str] = []
missing_scan_roots: list[str] = []
files: list[Path] = []
file_roots: dict[Path, Path] = {}
Expand Down Expand Up @@ -1153,16 +1248,25 @@ def record_hit(hit: str, *, blocking: bool = True) -> None:
# A narrow explicit input cannot erase private ancestry. A real
# worktree is its own namespace, even beneath a private parent;
# outside Git, retain the complete canonical ancestry.
classification_root = (
_git_worktree_root(resolved_scan_root) or Path(resolved_scan_root.anchor)
)
try:
classification_root = (
_git_worktree_root(resolved_scan_root)
or Path(resolved_scan_root.anchor)
)
except GitProbeUnavailable:
# The canonical ancestry is the wider answer: it can only add
# private state, never hide it.
classification_root = Path(resolved_scan_root.anchor)
unavailable_git_probes.append(str(resolved_scan_root))
if _is_local_private_state_path(resolved_scan_root, classification_root):
# Explicit outgoing selection cannot turn an ignored private file
# or a pruned private directory into a successful empty scan.
record_hit(f"{rel_or_abs(resolved_scan_root, classification_root)}: local_private_state")
continue
for file_path in iter_scan_files(
resolved_scan_root, unreadable_directories=unreadable_directories
resolved_scan_root,
unreadable_directories=unreadable_directories,
unavailable_git_probes=unavailable_git_probes,
):
files.append(file_path)
if _is_local_private_state_path(file_path, classification_root):
Expand All @@ -1180,10 +1284,25 @@ def public_files() -> Iterator[Path]:
# Filtering precedes submission: a worker must never open untracked
# local-private state. Policy, Git ownership and content classification
# stay here, in the existing scan owner, not in the I/O adapter.
ownership_unavailable = False
for path in files:
root = file_roots.get(path, path)
if path in private_files:
if ownership_unavailable:
# One unanswered question already decides the scan; asking
# again would only spend another budget on the same hang.
unavailable_git_probes.append(rel_or_abs(path, root))
skipped_private_state_files.append(rel_or_abs(path, root))
continue
git = _git_probe(path)
if git.get("tracked") is None:
ownership_unavailable = True
# Git did not answer, so ownership is unproven. Private
# state is still never opened, and the scan stops asking
# once one answer is missing.
unavailable_git_probes.append(rel_or_abs(path, root))
skipped_private_state_files.append(rel_or_abs(path, root))
continue
if not git.get("tracked"):
skipped_private_state_files.append(rel_or_abs(path, root))
if git.get("inside_worktree") and not git.get("ignored"):
Expand Down Expand Up @@ -1262,7 +1381,13 @@ def public_files() -> Iterator[Path]:
else:
record_hit(hit, blocking=rule.blocking)
return {
"ok": not (blocking_hits or missing_scan_roots or unreadable_files or unreadable_directories),
"ok": not (
blocking_hits
or missing_scan_roots
or unreadable_files
or unreadable_directories
or unavailable_git_probes
),
"scan_roots": [str(path) for path in scan_roots],
"files": len(files),
"scanned_files": len(files) - len(skipped_private_state_files),
Expand All @@ -1275,6 +1400,9 @@ def public_files() -> Iterator[Path]:
_unreadable_directory_labels(unreadable_directories, directory_roots)
),
"missing_scan_roots": missing_scan_roots,
# A Git question that was not answered leaves the boundary unknown:
# this list keeps a partial answer from being reported as clean.
"unavailable_git_probes": sorted(set(unavailable_git_probes)),
"allowed_hits": allowed_hits,
"private_state_git_warnings": private_state_git_warnings,
"policy": policy,
Expand Down Expand Up @@ -1456,16 +1584,23 @@ def add_global_error(code: str, message: str) -> None:
str(item) for item in boundary.get("unreadable_directories") or []
]
unreadable_files = [str(item) for item in boundary.get("unreadable_files") or []]
unavailable_git_probes = [
str(item) for item in boundary.get("unavailable_git_probes") or []
]
boundary_warnings = [str(item) for item in boundary.get("warnings") or []]
public_boundary_scan = {
"state": "completed",
"ok": bool(boundary.get("ok")) and not (
missing_scan_roots or unreadable_directories or unreadable_files
missing_scan_roots
or unreadable_directories
or unreadable_files
or unavailable_git_probes
),
"scanned_files": int(boundary.get("scanned_files") or 0),
"missing_scan_roots": missing_scan_roots,
"unreadable_directories": unreadable_directories,
"unreadable_files": unreadable_files,
"unavailable_git_probes": unavailable_git_probes,
"warnings": boundary_warnings,
}
for missing_root in missing_scan_roots:
Expand All @@ -1483,6 +1618,12 @@ def add_global_error(code: str, message: str) -> None:
"public_boundary_scan_file_unreadable",
f"scan file could not be read: {unreadable_file}",
)
for unavailable_probe in unavailable_git_probes:
add_global_error(
"public_boundary_scan_git_unavailable",
"Git could not answer ownership for: "
f"{unavailable_probe}",
)
if public_boundary_scan["ok"]:
label = "completed with warnings" if boundary_warnings else "clean"
checks.append(f"public boundary scan {label}: {boundary.get('scanned_files')} files")
Expand Down
Loading
Loading