Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

249 lines
8.6 KiB
Python

"""Per-PID process helpers for the monitor-local-agents fleet view.
Pure collectors: no background loop, no caching, no UI wiring. The
wiring layer (#1490) batches calls in a REPL background task; the
registry layer (#1487) decides which PIDs to ask about.
``psutil`` is intentionally confined to this module. The helpers
exposed here (:func:`cwd_for_pid`, :func:`started_at_for_pid`,
:func:`env_value_for_pid`) let other layers query process state
without importing psutil themselves.
"""
from __future__ import annotations
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
import psutil
# 1 MiB exactly. The dataclass field below is named ``rss_mb`` because
# every monitoring tool in this space (htop, top, ps, k8s metrics,
# Datadog, Grafana) labels the same 1024² unit as "MB"; the constant
# stays precise so the unit math is unambiguous.
_BYTES_PER_MIB = 1024 * 1024
PROCESS_NOT_FOUND: tuple[type[BaseException], ...] = (psutil.NoSuchProcess,)
PROCESS_INACCESSIBLE_OR_GONE: tuple[type[BaseException], ...] = (
psutil.NoSuchProcess,
psutil.AccessDenied,
)
PROCESS_ERROR: tuple[type[BaseException], ...] = (psutil.Error,)
@dataclass(frozen=True)
class ProcessSnapshot:
"""Single-instant resource snapshot for a process.
Fields not available on the current platform or for the current
user are ``None`` rather than raising — file descriptors are
POSIX-only, and the connection count requires elevated privileges
on some systems.
"""
pid: int
cpu_percent: float
rss_mb: float
num_fds: int | None
num_connections: int | None
status: str
started_at: datetime
last_output_at: datetime | None = None
def pid_exists(pid: int) -> bool:
"""Return whether ``pid`` corresponds to a process the OS knows about.
Thin wrapper over ``psutil.pid_exists`` exposed here so ``psutil``
stays confined to this module per the issue #1489 acceptance
criterion. Unlike ``probe()``, this returns ``True`` for processes
we can't introspect (cross-user on macOS, restricted ``/proc``,
etc.) — the OS-level existence check doesn't traverse the access
boundary, which is exactly what the boot sweep (#1501) needs to
avoid pruning live foreign-user agents.
Returns ``False`` for PIDs outside the platform's valid range
(e.g. an int that overflows the kernel's PID type) — psutil
raises ``OverflowError`` or ``ValueError`` on those, which we
treat as "not a real PID" rather than propagating.
"""
try:
return psutil.pid_exists(pid)
except (OverflowError, ValueError):
return False
def process(pid: int) -> psutil.Process:
"""Return a handle for ``pid`` while keeping psutil access local."""
return psutil.Process(pid)
def process_iter(attrs: Iterable[str]) -> Iterator[psutil.Process]:
"""Yield process handles with preloaded attrs from the local probe module."""
return psutil.process_iter(list(attrs))
def process_has_open_codex_rollout(pid: int) -> bool:
"""Return whether ``pid`` has an open Codex ``rollout-*.jsonl`` file."""
try:
proc = psutil.Process(pid)
open_files = proc.open_files()
# The stdlib exceptions cover invalid/raced PIDs and platform-specific
# ``open_files()`` failures that psutil may surface directly.
except PROCESS_ERROR + (
ProcessLookupError,
OSError,
ValueError,
OverflowError,
):
return False
for open_file in open_files:
path = getattr(open_file, "path", None)
if isinstance(path, str):
name = Path(path).name
if name.startswith("rollout-") and name.endswith(".jsonl"):
return True
return False
def probe(pid: int, *, cpu_interval: float = 0.1) -> ProcessSnapshot | None:
"""Return a one-shot resource snapshot for ``pid``.
``cpu_interval`` blocks for that many seconds to compute an
accurate CPU percentage. Pass ``0.0`` for a non-blocking sample —
the first such call returns ``0.0`` because psutil needs a delta
baseline; callers that want accuracy without blocking should
manage their own ``psutil.Process`` instances and call this
function with ``cpu_interval=0.0`` on subsequent samples.
Returns ``None`` for PIDs that don't exist, are zombies, or whose
fields are inaccessible (typically processes owned by another user
on macOS or Linux setups with restricted ``/proc``). Never raises
``psutil.NoSuchProcess``, ``psutil.ZombieProcess``, or
``psutil.AccessDenied``.
"""
try:
proc = psutil.Process(pid)
except (psutil.NoSuchProcess, ProcessLookupError):
return None
try:
cpu = proc.cpu_percent(interval=cpu_interval)
with proc.oneshot():
rss_mb = proc.memory_info().rss / _BYTES_PER_MIB
num_fds = _safe_num_fds(proc)
num_connections = _safe_num_connections(proc)
status = proc.status()
started_at = datetime.fromtimestamp(proc.create_time(), tz=UTC)
except (psutil.NoSuchProcess, psutil.ZombieProcess, psutil.AccessDenied):
# Process exited or its core fields (memory, status, create
# time) are inaccessible to this user. The wiring layer in
# #1490 treats both as "no snapshot this tick" and renders an
# empty cell rather than tearing the REPL background task.
return None
return ProcessSnapshot(
pid=pid,
cpu_percent=cpu,
rss_mb=rss_mb,
num_fds=num_fds,
num_connections=num_connections,
status=status,
started_at=started_at,
)
def cwd_for_pid(pid: int) -> Path | None:
"""Return the cwd of ``pid``, or ``None`` on any psutil failure.
macOS hardened-runtime processes can deny ``cwd()`` even
cross-user-same-user. Callers treat ``None`` as "unobservable
this tick" and retry next tick rather than poison a cache.
"""
try:
proc = psutil.Process(pid)
return Path(proc.cwd())
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, OSError):
return None
def started_at_for_pid(pid: int) -> float | None:
"""Return the POSIX-epoch start time of ``pid``, or ``None``."""
try:
proc = psutil.Process(pid)
return float(proc.create_time())
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, OSError):
return None
def env_value_for_pid(pid: int, key: str) -> str | None:
"""Return env var ``key`` for ``pid``, or ``None``.
``environ()`` denies on macOS hardened-runtime processes even for
the same user; callers must accept ``None`` and fall back to
other resolution sources.
"""
try:
proc = psutil.Process(pid)
environ = proc.environ()
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, OSError):
return None
value = environ.get(key)
if isinstance(value, str) and value:
return value
return None
def open_files_for_pid(pid: int) -> tuple[Path, ...]:
"""Return open file paths for ``pid``, or an empty tuple on psutil failure."""
try:
proc = psutil.Process(pid)
files = proc.open_files()
except (
psutil.NoSuchProcess,
psutil.AccessDenied,
psutil.ZombieProcess,
OSError,
ValueError,
):
return ()
return tuple(Path(item.path) for item in files if item.path)
def _safe_num_fds(proc: psutil.Process) -> int | None:
"""File-descriptor count is POSIX-only; ``None`` on Windows.
``Process.num_fds`` is missing from the Windows-facing typeshed shape
and is absent at runtime; use ``getattr`` so ``mypy --platform win32``
stays clean.
"""
num_fds_fn = getattr(proc, "num_fds", None)
if num_fds_fn is None:
return None
try:
n = num_fds_fn()
except (psutil.AccessDenied, NotImplementedError, TypeError, ValueError):
return None
return int(n)
def _safe_num_connections(proc: psutil.Process) -> int | None:
"""Connection count requires elevated privileges on some platforms.
Lazy fallback via ``hasattr`` rather than ``getattr(..., default)``
because the latter eagerly evaluates ``proc.connections`` even when
``net_connections`` exists; a future psutil release that drops the
deprecated ``connections`` method would then raise ``AttributeError``
on the working code path.
"""
method = proc.net_connections if hasattr(proc, "net_connections") else proc.connections
try:
connections = method()
except (psutil.AccessDenied, NotImplementedError):
return None
return len(connections)