diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8e43b1b17d9..b023c18dd59 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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]: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 5daf308c647..748800ca1b2 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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