fix(web): bound dashboard action log tails

(cherry picked from commit a4188a3e24)
This commit is contained in:
luyifan 2026-07-05 01:04:31 +08:00 committed by kshitij
parent 7d0ddbb2ff
commit 492be28e50
2 changed files with 73 additions and 7 deletions

View file

@ -3041,6 +3041,9 @@ async def run_debug_share_endpoint(body: DebugShareRequest | None = None):
# ---------------------------------------------------------------------------
_ACTION_LOG_DIR: Path = get_hermes_home() / "logs"
_ACTION_LOG_TAIL_MAX_BYTES = 256 * 1024
_ACTION_LOG_TAIL_INITIAL_CHUNK_BYTES = 8 * 1024
_ACTION_LOG_TAIL_MAX_CHUNK_BYTES = 64 * 1024
# Short ``name`` (from the URL) → absolute log file path.
_ACTION_LOG_FILES: Dict[str, str] = {
@ -3142,17 +3145,50 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
def _tail_lines(path: Path, n: int) -> List[str]:
"""Return the last ``n`` lines of ``path``. Reads the whole file — fine
for our small per-action logs. Binary-decoded with ``errors='replace'``
so log corruption doesn't 500 the endpoint."""
if not path.exists():
"""Return the last ``n`` lines of ``path`` without loading huge logs."""
if n <= 0 or not path.exists():
return []
try:
text = path.read_text(encoding="utf-8", errors="replace")
size = path.stat().st_size
except OSError:
return []
lines = text.splitlines()
return lines[-n:] if n > 0 else lines
if size <= 0:
return []
min_offset = max(0, size - _ACTION_LOG_TAIL_MAX_BYTES)
offset = size
chunk_size = _ACTION_LOG_TAIL_INITIAL_CHUNK_BYTES
newline_count = 0
chunks: List[bytes] = []
drop_partial_first_line = False
try:
with path.open("rb") as handle:
while offset > min_offset and newline_count <= n:
read_size = min(chunk_size, offset - min_offset)
offset -= read_size
handle.seek(offset)
chunk = handle.read(read_size)
chunks.append(chunk)
newline_count += chunk.count(b"\n")
chunk_size = min(
chunk_size * 2,
_ACTION_LOG_TAIL_MAX_CHUNK_BYTES,
)
if offset > 0:
handle.seek(offset - 1)
drop_partial_first_line = handle.read(1) != b"\n"
except OSError:
return []
lines = (
b"".join(reversed(chunks))
.decode("utf-8", errors="replace")
.splitlines()
)
if drop_partial_first_line and lines:
lines = lines[1:]
return lines[-n:]
def _gateway_subcommand(profile: Optional[str], verb: str) -> List[str]:

View file

@ -1620,6 +1620,36 @@ class TestWebServerEndpoints:
"pid": 99,
}
def test_action_status_tails_large_log_without_read_text(self, tmp_path, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.setattr(web_server, "_ACTION_LOG_DIR", tmp_path)
web_server._ACTION_PROCS.pop("hermes-update", None)
web_server._ACTION_RESULTS.pop("hermes-update", None)
log_path = tmp_path / web_server._ACTION_LOG_FILES["hermes-update"]
log_path.write_text(
"stale-start\n"
+ ("x" * (web_server._ACTION_LOG_TAIL_MAX_BYTES + 1024))
+ "\ntail-one\ntail-two\n",
encoding="utf-8",
)
assert log_path.stat().st_size > web_server._ACTION_LOG_TAIL_MAX_BYTES
original_read_text = Path.read_text
def fail_if_status_reads_whole_log(path, *args, **kwargs):
if path == log_path:
raise AssertionError("action status must not read the entire log")
return original_read_text(path, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", fail_if_status_reads_whole_log)
resp = self.client.get("/api/actions/hermes-update/status?lines=3")
assert resp.status_code == 200
assert resp.json()["lines"] == ["tail-one", "tail-two"]
def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch):
import gateway.config as gateway_config