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

454 lines
18 KiB
Python

"""Live stdout tail for the monitor-local-agents fleet view.
Backs the ``/fleet trace <pid>`` slash command. The attach path only
accepts *regular files* backing fd 1 of the target pid: TTY/PTY/pipe/
socket/anon_inode targets are rejected at attach time with a precise
error so we never compete with the legitimate consumer for bytes.
PTY interception for OpenSRE-spawned agents is left to a future change
once a spawn path lands.
Layered bounding (per the 4 MiB acceptance criterion):
* the reader thread publishes into a ``queue.Queue`` with a fixed
``maxsize``; on overflow the oldest chunk is dropped so the
stream stays fresh under burst writers
* :class:`TailBuffer` caps the *consumer-side* accumulator at
``DEFAULT_MAX_BYTES`` and drops whole chunks from the head so the
UTF-8 decode boundary in :meth:`TailBuffer.decoded` always sits
between chunks (no mid-codepoint splits)
"""
from __future__ import annotations
import contextlib
import os
import queue
import stat
import subprocess
import sys
import threading
from collections import deque
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Final
from tools.system.fleet_monitoring.probe import pid_exists
DEFAULT_MAX_BYTES: Final[int] = 4 * 1024 * 1024
DEFAULT_QUEUE_MAX: Final[int] = 128
DEFAULT_POLL_INTERVAL_S: Final[float] = 0.1
DEFAULT_READ_BUFFER: Final[int] = 4096
_LSOF_TIMEOUT_S: Final[float] = 5.0
_THREAD_JOIN_TIMEOUT_S: Final[float] = 1.0
_SENTINEL: Final[object] = object()
class AttachUnsupported(Exception):
"""Raised eagerly by :func:`attach` when the target cannot be tailed.
``reason`` is a short, user-facing message that the slash-command
handler renders directly. It must not contain markup or ANSI codes.
"""
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason
@dataclass(frozen=True)
class _ResolvedTarget:
"""Regular-file target backing fd 1 of a tracked pid."""
pid: int
path: Path
def _check_regular_file(path: Path, *, what: str) -> Path:
try:
st = path.stat()
except FileNotFoundError as exc:
raise AttachUnsupported(f"{what} target {path} no longer exists") from exc
except PermissionError as exc:
raise AttachUnsupported(
f"{what} target {path} is not readable (permission denied)"
) from exc
except OSError as exc:
raise AttachUnsupported(
f"{what} target {path} is unreachable: {exc.strerror or exc}"
) from exc
if not stat.S_ISREG(st.st_mode):
raise AttachUnsupported(f"{what} is not a regular file (got {stat.filemode(st.st_mode)})")
return path
def _resolve_linux_target(pid: int) -> _ResolvedTarget:
fd_link = Path(f"/proc/{pid}/fd/1")
try:
target = os.readlink(fd_link)
except FileNotFoundError as exc:
raise AttachUnsupported(f"no such pid {pid}") from exc
except PermissionError as exc:
raise AttachUnsupported(f"cannot inspect pid {pid} (permission denied)") from exc
except OSError as exc:
raise AttachUnsupported(f"cannot inspect pid {pid}: {exc.strerror or exc}") from exc
if target.startswith("socket:["):
raise AttachUnsupported("stdout is a socket; live tail not supported")
if target.startswith("pipe:["):
raise AttachUnsupported("stdout is a pipe; live tail not supported")
if target.startswith("anon_inode:"):
raise AttachUnsupported(f"stdout is {target}; live tail not supported")
if not target.startswith("/"):
raise AttachUnsupported(f"stdout target {target!r} is not a filesystem path")
if target.startswith(("/dev/pts/", "/dev/tty")):
raise AttachUnsupported("stdout is on a terminal; live tail not supported")
if target == "/dev/null":
raise AttachUnsupported("stdout is /dev/null; nothing to tail")
return _ResolvedTarget(pid=pid, path=_check_regular_file(Path(target), what="stdout"))
def _parse_lsof_fd1(stdout: str) -> tuple[str | None, str | None]:
"""Return ``(type, name)`` for the ``f1`` block in ``lsof -F ftn`` output.
Each fd block starts with ``f<num>`` and is followed by zero or more
field lines of the form ``<letter><value>`` (here: ``t`` for fd type,
``n`` for fd name). A new ``f`` line ends the previous block. Fields
we don't care about are silently skipped.
"""
fd: str | None = None
fd_type: str | None = None
fd_name: str | None = None
captured = False
for line in stdout.splitlines():
if not line:
continue
prefix, value = line[0], line[1:]
if prefix == "f":
if fd == "1":
captured = True
break
fd = value
fd_type = None
fd_name = None
elif fd == "1":
if prefix == "t":
fd_type = value
elif prefix == "n":
fd_name = value
if fd == "1" and not captured:
captured = True
if not captured:
return None, None
return fd_type, fd_name
def _resolve_macos_target(pid: int) -> _ResolvedTarget:
try:
proc = subprocess.run(
["lsof", "-F", "ftn", "-p", str(pid)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_LSOF_TIMEOUT_S,
check=False,
)
except FileNotFoundError as exc:
raise AttachUnsupported("lsof not found; cannot resolve stdout on this host") from exc
except subprocess.TimeoutExpired as exc:
raise AttachUnsupported(f"lsof timed out resolving pid {pid}") from exc
if proc.returncode != 0 and not proc.stdout:
detail = proc.stderr.strip() or "unknown error"
raise AttachUnsupported(f"no such pid {pid} (lsof: {detail})")
fd_type, fd_name = _parse_lsof_fd1(proc.stdout)
if fd_type is None and fd_name is None:
raise AttachUnsupported(f"pid {pid} has no fd 1 (lsof returned no stdout entry)")
if fd_type is None:
raise AttachUnsupported("lsof returned no type for stdout")
if fd_type != "REG":
kind = fd_type.upper()
# ``/dev/null`` and ``/dev/zero`` come back as CHR with their path in
# ``n``; surface a tail-specific message rather than the generic
# "stdout is on a terminal" reject so the user understands the cause.
if fd_name == "/dev/null":
raise AttachUnsupported("stdout is /dev/null; nothing to tail")
if kind == "CHR":
raise AttachUnsupported("stdout is on a terminal; live tail not supported")
if kind == "PIPE":
raise AttachUnsupported("stdout is a pipe; live tail not supported")
if kind in {"IPV4", "IPV6", "UNIX"}:
raise AttachUnsupported("stdout is a socket; live tail not supported")
raise AttachUnsupported(f"stdout fd type {kind} is not a regular file")
if not fd_name:
raise AttachUnsupported("lsof returned no name for stdout")
return _ResolvedTarget(pid=pid, path=_check_regular_file(Path(fd_name), what="stdout"))
def _resolve_target(pid: int) -> _ResolvedTarget:
if sys.platform == "win32":
raise AttachUnsupported("Windows is not supported")
# Guard non-positive ids before probing: ``psutil.pid_exists(0)`` can
# raise ``PermissionError`` on macOS, which the slash-command handler
# doesn't catch (it only catches :class:`AttachUnsupported`).
if pid <= 0:
raise AttachUnsupported(f"invalid pid {pid} (must be positive)")
if not pid_exists(pid):
raise AttachUnsupported(f"no such pid {pid}")
if sys.platform == "darwin":
return _resolve_macos_target(pid)
if sys.platform.startswith("linux"):
return _resolve_linux_target(pid)
raise AttachUnsupported(f"platform {sys.platform!r} is not supported")
class TailBuffer:
"""Bounded byte ring; bound is total bytes across whole chunks.
Drops *whole chunks* from the head on overflow. The cap is a soft
upper bound: the buffer only drops *after* an append exceeds it,
so the actual peak briefly exceeds the cap by at most the size of
the appending chunk. We never split a chunk, so the UTF-8 decode
boundary in :meth:`decoded` always sits between chunks.
"""
def __init__(self, max_bytes: int = DEFAULT_MAX_BYTES) -> None:
if max_bytes <= 0:
raise ValueError("max_bytes must be positive")
self._max_bytes = max_bytes
self._chunks: deque[bytes] = deque()
self._size = 0
def append(self, chunk: bytes) -> None:
if not chunk:
return
self._chunks.append(chunk)
self._size += len(chunk)
# Always retain at least one chunk so a single oversized append
# is still reachable (the alternative would silently drop a
# message bigger than the cap).
while self._size > self._max_bytes and len(self._chunks) > 1:
head = self._chunks.popleft()
self._size -= len(head)
def snapshot(self) -> bytes:
return b"".join(self._chunks)
def decoded(self) -> str:
return self.snapshot().decode("utf-8", errors="replace")
def __len__(self) -> int:
return self._size
class AttachSession:
"""Iterable + context manager owning the reader thread, fd, and queue.
Construct via :func:`attach` (never directly). The constructor
opens the file at the resolved target, seeks to EOF, and starts a
daemon reader thread. ``__iter__`` drains a bounded queue until the
reader posts a sentinel (process exited, file vanished, or
:meth:`close` was called).
"""
def __init__(
self,
target: _ResolvedTarget,
*,
buffer_bytes: int = DEFAULT_MAX_BYTES,
queue_max: int = DEFAULT_QUEUE_MAX,
poll_interval_s: float = DEFAULT_POLL_INTERVAL_S,
read_buffer: int = DEFAULT_READ_BUFFER,
) -> None:
self.target = target
self.buffer = TailBuffer(buffer_bytes)
# Set by :meth:`_reader_loop` when it exits because ``pid_exists``
# went False — i.e. the producer died. Stays False on
# user-initiated close (``stop_event``) or fd error, which lets
# the slash-command UI distinguish "process exited" from "you
# pressed Ctrl+C" in the trailer.
self.producer_exited = False
self._queue: queue.Queue[bytes | object] = queue.Queue(maxsize=queue_max)
self._stop_event = threading.Event()
self._poll_interval_s = poll_interval_s
self._read_buffer = read_buffer
self._closed = False
# Open + seek before spawning the thread so any OSError surfaces
# at the call site, not inside the iterator.
# SIM115 suppressed: the file handle is stored on self and kept open
# for the lifetime of the background reader thread (_reader_loop).
# A with-block would close it immediately on __init__ exit, before
# the thread has read anything. The fd is closed in close() / __del__.
try:
self._fd = open(target.path, "rb", buffering=0) # noqa: SIM115
except OSError as exc:
raise AttachUnsupported(f"cannot open {target.path}: {exc.strerror or exc}") from exc
try:
self._fd.seek(0, os.SEEK_END)
except OSError as exc:
self._fd.close()
raise AttachUnsupported(
f"cannot seek to EOF on {target.path}: {exc.strerror or exc}"
) from exc
self._thread = threading.Thread(
target=self._reader_loop,
name=f"agents-tail-{target.pid}",
daemon=True,
)
self._thread.start()
def _reader_loop(self) -> None:
try:
while not self._stop_event.is_set():
try:
chunk = self._fd.read(self._read_buffer)
except OSError:
break
if chunk:
self._publish(chunk)
continue
# EOF for now while the PID is still alive: we poll below until
# new bytes arrive or the process exits. A quiet writer leaves
# the rendered view unchanged; see trace limitations in
# docs/fleet.mdx.
#
# The only *exit* trigger here is PID death — we deliberately
# do NOT check ``self.target.path.exists()`` once the fd is
# open so we keep following the inode through rename/unlink
# (logrotate semantics), same as ``tail -f``. An exists-check
# would silently end the trace mid-incident while the agent
# still writes the original inode.
if not pid_exists(self.target.pid):
self.producer_exited = True
break
self._stop_event.wait(self._poll_interval_s)
finally:
# The sentinel is the only signal the consumer receives; if
# it never lands the iterator hangs on ``queue.get``. This
# post is also single-producer (the loop above has exited
# and ``_publish`` is no longer reachable), so the
# non-atomic drop-oldest dance below is safe — see the
# ``_publish`` comment for the invariant a future refactor
# has to preserve.
try:
self._queue.put_nowait(_SENTINEL)
except queue.Full:
# Queue is full of pending data chunks. Drop the head
# (oldest data) to make room — the consumer will see
# the sentinel as soon as it drains down to it.
with contextlib.suppress(queue.Empty):
self._queue.get_nowait()
with contextlib.suppress(queue.Full):
self._queue.put_nowait(_SENTINEL)
def _publish(self, chunk: bytes) -> None:
# **Single-producer invariant.** Only :meth:`_reader_loop` (one
# thread per session) calls this. The ``get_nowait`` + ``put_nowait``
# pair below is *not* atomic — with a second producer it would
# become a real race: the second producer could fill the slot we
# just freed, and the next ``put_nowait`` here would either drop
# *its* chunk instead of the oldest, or get another ``Full`` and
# silently lose the current chunk. If a future change adds
# another producer (e.g. multiplexing stderr alongside stdout),
# serialize this whole block with a ``threading.Lock`` first.
try:
self._queue.put_nowait(chunk)
except queue.Full:
# Drop-oldest to keep the stream fresh under burst writers;
# the TailBuffer cap on the consumer side preserves the
# acceptance memory bound regardless.
with contextlib.suppress(queue.Empty):
self._queue.get_nowait()
with contextlib.suppress(queue.Full):
self._queue.put_nowait(chunk)
def __iter__(self) -> Iterator[bytes]:
return self
def __next__(self) -> bytes:
while True:
try:
item = self._queue.get(timeout=self._poll_interval_s)
except queue.Empty:
if not self._thread.is_alive():
raise StopIteration from None
continue
if item is _SENTINEL:
raise StopIteration
assert isinstance(item, bytes)
# Append-on-yield keeps :attr:`buffer` in lockstep with what
# the consumer sees, so the slash-command renderer only has
# to call ``sess.buffer.decoded()`` — no risk of forgetting
# to feed the buffer and silently OOM'ing the live view.
self.buffer.append(item)
return item
def close(self) -> None:
if self._closed:
return
self._closed = True
self._stop_event.set()
self._thread.join(timeout=_THREAD_JOIN_TIMEOUT_S)
# Closing the fd while the reader is still inside ``read()`` is
# undefined behavior with buffered IO. With ``buffering=0`` it's
# tolerated (the read returns EBADF and the thread exits via
# ``except OSError``), but on the off-chance the reader is stuck
# on a slow/stalled FS we'd rather leak the fd than risk a
# crash — the daemon thread is reaped at process exit.
if not self._thread.is_alive():
with contextlib.suppress(OSError):
self._fd.close()
def __enter__(self) -> AttachSession:
return self
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
self.close()
def attach(
pid: int,
*,
buffer_bytes: int = DEFAULT_MAX_BYTES,
queue_max: int = DEFAULT_QUEUE_MAX,
poll_interval_s: float = DEFAULT_POLL_INTERVAL_S,
read_buffer: int = DEFAULT_READ_BUFFER,
) -> AttachSession:
"""Validate the target eagerly and return a ready-to-iterate session.
Raises :class:`AttachUnsupported` synchronously on any state we
cannot tail-from-EOF safely (Windows, missing pid, fd is a TTY/
PTY/pipe/socket/anon_inode/``/dev/null``, permission denied, file
vanished, open failed). Caller is responsible for closing the
session — preferably via ``with attach(pid) as sess: …``.
"""
target = _resolve_target(pid)
return AttachSession(
target,
buffer_bytes=buffer_bytes,
queue_max=queue_max,
poll_interval_s=poll_interval_s,
read_buffer=read_buffer,
)
__all__ = [
"DEFAULT_MAX_BYTES",
"DEFAULT_POLL_INTERVAL_S",
"DEFAULT_QUEUE_MAX",
"DEFAULT_READ_BUFFER",
"AttachSession",
"AttachUnsupported",
"TailBuffer",
"attach",
]