fix(system): tail the backend log across a rollover, and clear the backups
main.py rolls omnivoice.log at 2 MB into .1/.2/.3, and /system/logs read only the current file. For the minutes after a rollover the Backend tab showed a handful of lines while up to 6 MB of history sat in omnivoice.log.1. Measured with 3 lines in the current file and 500 in each of two backups: tail=200 returned 3 lines and reported total_lines: 3. That is the panel CONTRIBUTING and the engine guides tell a reporter to paste from, so the gap costs a round trip on every bug report that lands near a roll. The tail now reaches into the rotated siblings, but only when the current file cannot satisfy the request -- the panel polls every 5s and opening 6 MB of backups on each call would be a bad trade for a case that only matters right after a roll. The response gains a `paths` list so a report can say whether its tail crossed a boundary. Clear is in the same commit because the two are coupled: it truncated only omnivoice.log, so it freed almost nothing, and once the tail can see the backups a Clear that leaves them looks like it did nothing at all. Found while reading #1782, and it does NOT close it. That thread's blank panel is the desktop path, which never reaches this route -- details in a comment there. Refs #1782 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9790d28922
commit
3beebc6d57
@@ -50,6 +50,8 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The Backend log tab keeps showing history across a log rollover, instead of going nearly empty until new lines arrive (#1782)
|
||||
- Clearing the logs now empties the rotated log files too, so it frees the space it appears to (#1782)
|
||||
- Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang!
|
||||
- Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht!
|
||||
- Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht!
|
||||
|
||||
@@ -297,6 +297,53 @@ def _tail_file(path: str, tail: int):
|
||||
return all_lines[-tail:], len(all_lines)
|
||||
|
||||
|
||||
# Must track main.py's _WindowsSafeRotatingFileHandler(backupCount=3). The
|
||||
# handler rolls omnivoice.log at 2 MB into .1/.2/.3, so up to 6 MB of history
|
||||
# lives in files this module used to ignore entirely.
|
||||
_LOG_BACKUP_COUNT = 3
|
||||
|
||||
|
||||
def _rotated_log_paths(base: str) -> list[str]:
|
||||
"""Existing `<base>.1 … .N`, newest first."""
|
||||
return [p for p in (f"{base}.{i}" for i in range(1, _LOG_BACKUP_COUNT + 1)) if os.path.exists(p)]
|
||||
|
||||
|
||||
def _tail_rolling(base: str, tail: int):
|
||||
"""Tail `base`, reaching into its rotated siblings when it runs short.
|
||||
|
||||
A rollover leaves omnivoice.log nearly empty, and the Backend tab then
|
||||
showed a handful of lines — or none — while the failure the user was asked
|
||||
to copy sat in omnivoice.log.1. Reading the current file first keeps the
|
||||
common case at one file read; the backups are only touched when they are
|
||||
the only place the requested lines can come from.
|
||||
|
||||
Returns (lines oldest-first, total lines across the files read, paths read
|
||||
oldest-first). The total counts only the files it had to open — it stops as
|
||||
soon as `tail` is satisfied, so it is "how much is behind these lines",
|
||||
not the size of the whole rotation set.
|
||||
"""
|
||||
chunks: list[list[str]] = []
|
||||
paths: list[str] = []
|
||||
total = 0
|
||||
remaining = tail
|
||||
candidates = [p for p in [base, *_rotated_log_paths(base)] if os.path.exists(p)]
|
||||
for path in candidates:
|
||||
if remaining <= 0:
|
||||
break
|
||||
lines, count = _tail_file(path, remaining)
|
||||
if count == 0:
|
||||
continue
|
||||
chunks.append(lines)
|
||||
paths.append(path)
|
||||
total += count
|
||||
remaining -= len(lines)
|
||||
# Files were visited newest-first; the reader wants oldest-first.
|
||||
out: list[str] = []
|
||||
for chunk in reversed(chunks):
|
||||
out.extend(chunk)
|
||||
return out, total, list(reversed(paths))
|
||||
|
||||
|
||||
def _tauri_log_candidates():
|
||||
"""Likely paths for Tauri-side logs, most useful first.
|
||||
|
||||
@@ -356,12 +403,24 @@ async def system_logs(tail: int = 200):
|
||||
except Exception:
|
||||
tail = 200
|
||||
|
||||
path = LOG_PATH if os.path.exists(LOG_PATH) else CRASH_LOG_PATH
|
||||
if not os.path.exists(path):
|
||||
if os.path.exists(LOG_PATH) or _rotated_log_paths(LOG_PATH):
|
||||
base = LOG_PATH
|
||||
else:
|
||||
base = CRASH_LOG_PATH
|
||||
if not os.path.exists(base) and not _rotated_log_paths(base):
|
||||
return {"lines": [], "path": LOG_PATH, "exists": False}
|
||||
path = base
|
||||
try:
|
||||
lines, total = await asyncio.to_thread(_tail_file, path, tail)
|
||||
return {"lines": lines, "path": path, "exists": True, "total_lines": total}
|
||||
lines, total, paths = await asyncio.to_thread(_tail_rolling, base, tail)
|
||||
return {
|
||||
"lines": lines,
|
||||
"path": path,
|
||||
"exists": True,
|
||||
"total_lines": total,
|
||||
# Which files the tail actually came from, oldest first. A bug
|
||||
# report can then say whether it crossed a rollover.
|
||||
"paths": paths,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
@@ -467,9 +526,15 @@ def _read_from_pos(path: str, pos: int) -> list[str]:
|
||||
|
||||
@router.post("/system/logs/clear")
|
||||
async def clear_system_logs():
|
||||
"""Truncate the rolling runtime log and the crash log (what the Backend tab reads)."""
|
||||
"""Truncate the rolling runtime log and the crash log (what the Backend tab reads).
|
||||
|
||||
Includes the rotated siblings. Truncating only omnivoice.log left up to
|
||||
6 MB in .1/.2/.3, so Clear freed almost nothing and — now that the tail
|
||||
reaches into those files — would have looked like it did nothing at all.
|
||||
"""
|
||||
cleared_any = False
|
||||
for p in (LOG_PATH, CRASH_LOG_PATH):
|
||||
targets = [LOG_PATH, *_rotated_log_paths(LOG_PATH), CRASH_LOG_PATH]
|
||||
for p in targets:
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
await asyncio.to_thread(_truncate_file, p)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""The Backend log tab must survive a log rollover.
|
||||
|
||||
`main.py` attaches a `RotatingFileHandler(maxBytes=2MB, backupCount=3)`, so
|
||||
`omnivoice.log` is rolled into `.1/.2/.3` and starts again from empty. The tail
|
||||
endpoint read only the current file, so for the minutes after a rollover the
|
||||
panel showed a handful of lines — or none — while up to 6 MB of history, the
|
||||
failure included, sat in `omnivoice.log.1`.
|
||||
|
||||
That matters more than a cosmetic gap: `.github/ISSUE_TEMPLATE` and the engine
|
||||
guides both ask a reporter to paste "the backend log" from that panel, and
|
||||
#1782 is a thread where three rounds went into an empty Logs panel. A rollover
|
||||
is not the only way to get one, but it is one this endpoint can rule out.
|
||||
|
||||
Measured before the fix, with 3 lines in the current file and 500 in each of
|
||||
two backups: `tail=200` returned 3 lines and reported `total_lines: 3`.
|
||||
|
||||
Clear is in the same file because the two are coupled — once the tail reaches
|
||||
into the backups, a Clear that truncates only `omnivoice.log` looks like it did
|
||||
nothing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def system_mod():
|
||||
from api.routers import system
|
||||
|
||||
return system
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rolling(tmp_path, system_mod, monkeypatch):
|
||||
"""A rolled-over log set: 3 fresh lines, 500 + 500 in the backups."""
|
||||
base = tmp_path / "omnivoice.log"
|
||||
base.write_text("".join(f"fresh {i}\n" for i in range(3)), encoding="utf-8")
|
||||
(tmp_path / "omnivoice.log.1").write_text(
|
||||
"".join(f"older {i}\n" for i in range(500)), encoding="utf-8"
|
||||
)
|
||||
(tmp_path / "omnivoice.log.2").write_text(
|
||||
"".join(f"oldest {i}\n" for i in range(500)), encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(system_mod, "LOG_PATH", str(base))
|
||||
monkeypatch.setattr(system_mod, "CRASH_LOG_PATH", str(tmp_path / "crash_log.txt"))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_tail_reaches_across_a_rollover(system_mod, rolling):
|
||||
res = asyncio.run(system_mod.system_logs(tail=200))
|
||||
|
||||
assert len(res["lines"]) == 200
|
||||
# Oldest first, and the newest line is still the newest line on disk.
|
||||
assert res["lines"][0].strip() == "older 303"
|
||||
assert res["lines"][-1].strip() == "fresh 2"
|
||||
# It crossed exactly one boundary and stopped there.
|
||||
assert [os.path.basename(p) for p in res["paths"]] == ["omnivoice.log.1", "omnivoice.log"]
|
||||
|
||||
|
||||
def test_the_common_case_still_reads_one_file(system_mod, tmp_path, monkeypatch):
|
||||
"""No rollover in play → the backups are not opened at all.
|
||||
|
||||
The panel polls every 5s, so reaching into 6 MB of backups on every call
|
||||
would be a poor trade for a case that only matters right after a roll.
|
||||
"""
|
||||
base = tmp_path / "omnivoice.log"
|
||||
base.write_text("".join(f"line {i}\n" for i in range(500)), encoding="utf-8")
|
||||
(tmp_path / "omnivoice.log.1").write_text("stale\n" * 500, encoding="utf-8")
|
||||
monkeypatch.setattr(system_mod, "LOG_PATH", str(base))
|
||||
monkeypatch.setattr(system_mod, "CRASH_LOG_PATH", str(tmp_path / "crash_log.txt"))
|
||||
|
||||
res = asyncio.run(system_mod.system_logs(tail=200))
|
||||
|
||||
assert [os.path.basename(p) for p in res["paths"]] == ["omnivoice.log"]
|
||||
assert res["lines"][0].strip() == "line 300"
|
||||
assert "stale" not in "".join(res["lines"])
|
||||
|
||||
|
||||
def test_a_backup_alone_is_still_a_log(system_mod, tmp_path, monkeypatch):
|
||||
"""The window between the roll and the first new line is not "no log"."""
|
||||
base = tmp_path / "omnivoice.log"
|
||||
(tmp_path / "omnivoice.log.1").write_text("only in the backup\n", encoding="utf-8")
|
||||
monkeypatch.setattr(system_mod, "LOG_PATH", str(base))
|
||||
monkeypatch.setattr(system_mod, "CRASH_LOG_PATH", str(tmp_path / "crash_log.txt"))
|
||||
assert not base.exists()
|
||||
|
||||
res = asyncio.run(system_mod.system_logs(tail=200))
|
||||
|
||||
assert res["exists"] is True
|
||||
assert [line.strip() for line in res["lines"]] == ["only in the backup"]
|
||||
|
||||
|
||||
def test_no_log_at_all_still_reports_absent(system_mod, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(system_mod, "LOG_PATH", str(tmp_path / "omnivoice.log"))
|
||||
monkeypatch.setattr(system_mod, "CRASH_LOG_PATH", str(tmp_path / "crash_log.txt"))
|
||||
|
||||
res = asyncio.run(system_mod.system_logs(tail=200))
|
||||
|
||||
assert res == {"lines": [], "path": str(tmp_path / "omnivoice.log"), "exists": False}
|
||||
|
||||
|
||||
def test_clear_empties_the_backups_too(system_mod, rolling, monkeypatch):
|
||||
monkeypatch.setattr(system_mod, "prefs_delete", lambda _key: None, raising=False)
|
||||
|
||||
res = asyncio.run(system_mod.clear_system_logs())
|
||||
|
||||
assert res == {"cleared": True}
|
||||
for name in ("omnivoice.log", "omnivoice.log.1", "omnivoice.log.2"):
|
||||
assert (rolling / name).stat().st_size == 0, f"{name} survived Clear"
|
||||
Reference in New Issue
Block a user