This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
"""Real CPU/RAM/disk-I/O limits for agent-run commands (Sandbox Security
|
||||
Layer). Uses ``psutil`` to read a process TREE's actual resource usage —
|
||||
``deps.py::run_cancellable`` polls :func:`check_limits` on the same cadence it
|
||||
already uses for cancel/timeout, and kills the tree the moment a configured
|
||||
cap is exceeded.
|
||||
|
||||
Best-effort by design: any single metric psutil can't read on this platform
|
||||
(e.g. ``io_counters()`` is unavailable on macOS without extra permissions) is
|
||||
silently skipped rather than raised — a monitoring gap must never crash the
|
||||
command it's watching.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
def check_limits(pid: int, limits: dict) -> Optional[str]:
|
||||
"""Return a human-readable reason if the process tree rooted at ``pid``
|
||||
exceeds one of ``limits`` (``cpu_percent``, ``memory_mb``, ``disk_mb`` —
|
||||
any subset, unset keys are not checked), else ``None``.
|
||||
|
||||
Sums the metric across the root process AND all its descendants, since a
|
||||
shell wrapping the real command (or a build tool forking workers) means
|
||||
the root process alone often under-reports actual usage."""
|
||||
try:
|
||||
root = psutil.Process(pid)
|
||||
except psutil.Error:
|
||||
return None # process already gone — nothing to enforce
|
||||
procs = [root]
|
||||
try:
|
||||
procs += root.children(recursive=True)
|
||||
except psutil.Error:
|
||||
pass
|
||||
|
||||
cpu_cap = limits.get("cpu_percent")
|
||||
mem_cap = limits.get("memory_mb")
|
||||
disk_cap = limits.get("disk_mb")
|
||||
total_cpu = total_mem_mb = total_disk_mb = 0.0
|
||||
|
||||
for p in procs:
|
||||
try:
|
||||
if cpu_cap is not None:
|
||||
total_cpu += p.cpu_percent(interval=None)
|
||||
if mem_cap is not None:
|
||||
total_mem_mb += p.memory_info().rss / (1024 * 1024)
|
||||
if disk_cap is not None:
|
||||
io = p.io_counters()
|
||||
total_disk_mb += (io.read_bytes + io.write_bytes) / (1024 * 1024)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
except (AttributeError, NotImplementedError):
|
||||
pass # io_counters() not supported on this platform — skip disk only
|
||||
|
||||
if mem_cap is not None and total_mem_mb > mem_cap:
|
||||
return f"memory limit exceeded ({total_mem_mb:.0f}MB > {mem_cap:.0f}MB)"
|
||||
if cpu_cap is not None and total_cpu > cpu_cap:
|
||||
return f"CPU limit exceeded ({total_cpu:.0f}% > {cpu_cap:.0f}%)"
|
||||
if disk_cap is not None and total_disk_mb > disk_cap:
|
||||
return f"disk I/O limit exceeded ({total_disk_mb:.0f}MB > {disk_cap:.0f}MB)"
|
||||
return None
|
||||
|
||||
|
||||
def prime_cpu_counter(pid: int) -> None:
|
||||
"""``Process.cpu_percent(interval=None)`` always returns 0.0 on its FIRST
|
||||
call for a given process (it measures the delta since the last call) —
|
||||
call this once right after spawning, before the first :func:`check_limits`
|
||||
poll, so the very first real measurement isn't silently skipped as 0%."""
|
||||
try:
|
||||
psutil.Process(pid).cpu_percent(interval=None)
|
||||
except psutil.Error:
|
||||
pass
|
||||
Reference in New Issue
Block a user