Files
hermes-agent/pm/progress.py
ethernet a89558e312 fix(pm): bound LiveTail memory for newline-free child output
A child that streams megabytes without a newline grew LiveTail's partial
line (and the retained tail) without limit: 8MB of output kept ~25MB.
Keep only the last MAX_LINE characters of an over-long line; its end is
the informative part.

The streamed-backend-output test now runs in verbose mode: live backend
output is the verbose/CI contract, and quiet mode deliberately contains it.
2026-09-24 14:32:29 -04:00

174 lines
6.3 KiB
Python

"""One output policy for long child processes: verbose in CI, contained elsewhere.
CI logs are the only record of a remote failure, so CI (``CI`` /
``GITHUB_ACTIONS``) or ``HERMES_VERBOSE=1`` streams child output unchanged.
Everywhere else a child collapses into one status line: on a terminal it is
rewritten in place with the child's latest line, off a terminal only the
start and finish are printed. A failure always prints the captured tail, so
containment never hides an error. ``HERMES_VERBOSE=0`` forces containment.
Stdlib-only: the bootstrap runner imports this from a pre-3.11 system Python.
"""
from __future__ import annotations
from collections import deque
import os
import re
import shutil
import subprocess
import sys
import time
from typing import IO, Callable, Mapping, Optional, Protocol, Sequence
TAIL_LINES = 80
# A child that never prints a newline (a bare progress stream, a binary blob) must not
# grow memory without bound; the end of an over-long line is the informative part.
MAX_LINE = 4096
_TRUE = {"1", "true", "yes", "on"}
_FALSE = {"0", "false", "no", "off"}
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
# A redraw per child line would dominate a fast install's runtime on slow terminals.
_REDRAW_INTERVAL = 0.05
class TextSink(Protocol):
def write(self, text: str, /) -> int: ...
def flush(self) -> None: ...
def verbose_output(env: Optional[Mapping[str, str]] = None) -> bool:
"""Whether child output streams unchanged instead of being contained."""
source = os.environ if env is None else env
explicit = source.get("HERMES_VERBOSE", "").strip().lower()
if explicit in _TRUE:
return True
if explicit in _FALSE:
return False
return any(source.get(key, "").strip().lower() not in ("", *_FALSE) for key in ("CI", "GITHUB_ACTIONS"))
def _is_terminal(stream: IO[str]) -> bool:
try:
return stream.isatty()
except (AttributeError, ValueError):
return False
class LiveTail:
"""A text sink that contains a child's output behind one status line.
``label=None`` runs silently and speaks only on failure, for steps too
quick to deserve a line of their own.
"""
def __init__(self, label: Optional[str], stream: Optional[IO[str]] = None, *,
hide: Optional[Callable[[str], bool]] = None, indent: str = "") -> None:
self.label = label
self.stream = sys.stdout if stream is None else stream
self.hide = hide
self.indent = indent
self.live = label is not None and _is_terminal(self.stream)
self.tail: deque[str] = deque(maxlen=TAIL_LINES)
self._partial = ""
self._drawn = 0
self._last_draw = 0.0
if label is not None:
if self.live:
self._draw(f"{indent}→ {label}…")
else:
self._emit(f"{indent}→ {label}…\n")
def write(self, text: str) -> int:
# npm and uv redraw with bare CRs; each redraw is a line of progress.
lines = (self._partial + text).replace("\r\n", "\n").replace("\r", "\n").split("\n")
self._partial = lines.pop()[-MAX_LINE:]
for line in lines:
self._line(line)
return len(text)
def flush(self) -> None:
return None
def close(self, ok: bool) -> None:
if self._partial:
self._line(self._partial)
self._partial = ""
if self.live:
self._draw("")
self._emit("\r")
if ok:
if self.label is not None:
self._emit(f"{self.indent}✓ {self.label}\n")
return
name = self.label or "command"
if not self.tail:
self._emit(f"{self.indent}✗ {name} failed\n")
return
self._emit(f"{self.indent}✗ {name} failed; last {len(self.tail)} lines of output:\n")
self._emit("".join(f"{self.indent} {line}\n" for line in self.tail))
def _line(self, line: str) -> None:
line = _ANSI.sub("", line[-MAX_LINE:]).rstrip()
if not line.strip():
return
self.tail.append(line)
if not self.live or (self.hide is not None and self.hide(line)):
return
now = time.monotonic()
if now - self._last_draw < _REDRAW_INTERVAL:
return
self._last_draw = now
self._draw(f"{self.indent}→ {self.label}… {line.strip()}")
def _draw(self, text: str) -> None:
# Pad instead of an ANSI erase: a bare CR works on every Windows console.
width = max(20, shutil.get_terminal_size().columns - 1)
text = text[:width]
self._emit("\r" + text + " " * max(0, self._drawn - len(text)))
self._drawn = len(text)
def _emit(self, text: str) -> None:
self.stream.write(text)
self.stream.flush()
def run_contained(command: Sequence[str], label: str, *, stream: Optional[IO[str]] = None,
hide: Optional[Callable[[str], bool]] = None, indent: str = "",
**kwargs) -> subprocess.CompletedProcess:
"""``subprocess.run(check=True)`` under the output policy."""
if verbose_output():
out = sys.stdout if stream is None else stream
out.write(f"{indent}→ {label}…\n")
out.flush()
return subprocess.run(command, check=True, **kwargs)
tail = LiveTail(label, stream, hide=hide, indent=indent)
captured = dict(stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
encoding="utf-8", errors="replace")
if not tail.live:
try:
result = subprocess.run(command, check=True, **captured, **kwargs)
except subprocess.CalledProcessError as exc:
tail.write(exc.output or "")
tail.close(False)
raise
except BaseException:
tail.close(False)
raise
tail.close(True)
return result
try:
with subprocess.Popen(command, **captured, **kwargs) as proc:
assert proc.stdout is not None # stdout=PIPE above.
for line in proc.stdout:
tail.write(line)
code = proc.wait()
except BaseException:
tail.close(False)
raise
tail.close(code == 0)
output = "\n".join(tail.tail)
if code:
raise subprocess.CalledProcessError(code, list(command), output=output)
return subprocess.CompletedProcess(list(command), code, output, None)