fix(system): survive a rollover racing the read, and stop trusting the scan

Three review findings, all applied.

Greptile P1, read race: a rollover can rename a candidate between the
existence check and the open, and the handler exposes no lock a route can
take. Per-file OSError now skips that file instead of 500ing the whole panel
-- which is what the single-file version did in the same situation, so this is
strictly better than before rather than a new guarantee. A roll landing
mid-walk can still shift which chunk a file holds, so a tail taken at that
instant may repeat or miss a block; the panel re-polls every 5s and the next
read is clean. Buying strict consistency would mean reaching into logging's
internals from a route.

Greptile P1, clear race: enumerating first left a window where a rollover
created a backup after the scan and its history survived a Clear that
reported success. Clear now works off the fixed name set -- every name the
handler can write is known up front, so there is nothing to enumerate and no
snapshot to go stale.

CodeRabbit: the CHANGELOG lines ended in (#1782), which reads as "this fixes
#1782" when the desktop path defect that thread is about is untouched. Now
(#1920).

Two tests added, both red before: a candidate vanishing mid-walk still fills
the request from the next file, and a Clear whose scan reported nothing still
empties the backups.

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:
Chang-Jin-Lee
2026-09-08 16:46:18 +09:00
co-authored by Claude Opus 5
parent 3beebc6d57
commit 17fda0d79f
3 changed files with 69 additions and 4 deletions
+2 -2
View File
@@ -50,8 +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)
- The Backend log tab keeps showing history across a log rollover, instead of going nearly empty until new lines arrive (#1920)
- Clearing the logs now empties the rotated log files too, so it frees the space it appears to (#1920)
- 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!
+23 -2
View File
@@ -330,7 +330,20 @@ def _tail_rolling(base: str, tail: int):
for path in candidates:
if remaining <= 0:
break
lines, count = _tail_file(path, remaining)
try:
lines, count = _tail_file(path, remaining)
except OSError:
# A rollover can rename a candidate between the existence check
# above and this open, and the handler holds no lock we can take
# from a route. Skip the file rather than 500 the whole panel over
# one member of the set — the previous single-file version failed
# the request outright in the same situation.
#
# A roll landing mid-walk can also shift which chunk a file holds,
# so a tail taken at that instant may repeat or miss a block. The
# panel re-polls every 5s and the next read is clean; buying strict
# consistency here would mean reaching into logging's internals.
continue
if count == 0:
continue
chunks.append(lines)
@@ -533,7 +546,15 @@ async def clear_system_logs():
reaches into those files — would have looked like it did nothing at all.
"""
cleared_any = False
targets = [LOG_PATH, *_rotated_log_paths(LOG_PATH), CRASH_LOG_PATH]
# The full fixed name set rather than a snapshot of what exists: enumerating
# first leaves a window where a rollover creates a backup after the scan and
# its history survives a Clear that reported success. Names the handler can
# ever write are known up front, so there is nothing to enumerate.
targets = [
LOG_PATH,
*(f"{LOG_PATH}.{i}" for i in range(1, _LOG_BACKUP_COUNT + 1)),
CRASH_LOG_PATH,
]
for p in targets:
if os.path.exists(p):
try:
@@ -102,6 +102,50 @@ def test_no_log_at_all_still_reports_absent(system_mod, tmp_path, monkeypatch):
assert res == {"lines": [], "path": str(tmp_path / "omnivoice.log"), "exists": False}
def test_a_file_that_vanishes_mid_walk_does_not_fail_the_panel(
system_mod, rolling, monkeypatch
):
"""A rollover can rename a candidate between the scan and the open.
The handler exposes no lock a route can take, so the walk skips the file
instead of failing the request. The single-file version 500'd the whole
panel in the same situation, so this is strictly better than before.
"""
real_tail = system_mod._tail_file
def flaky(path, tail):
if path.endswith("omnivoice.log.1"):
raise FileNotFoundError(path)
return real_tail(path, tail)
monkeypatch.setattr(system_mod, "_tail_file", flaky)
res = asyncio.run(system_mod.system_logs(tail=200))
# .1 is gone, so the walk falls through to .2 and still fills the request.
assert res["exists"] is True
assert len(res["lines"]) == 200
assert [os.path.basename(p) for p in res["paths"]] == ["omnivoice.log.2", "omnivoice.log"]
def test_clear_covers_a_backup_created_after_the_scan(system_mod, rolling, monkeypatch):
"""Clear works off the fixed name set, not a snapshot of what exists.
Enumerating first left a window where a rollover created a backup after the
scan and its history survived a Clear that reported success.
"""
monkeypatch.setattr(system_mod, "prefs_delete", lambda _key: None, raising=False)
# Stand in for the race: a scan that ran before the rollover would have
# reported no backups at all, and the version that trusted it truncated
# only the current file while .1 and .2 kept their history.
monkeypatch.setattr(system_mod, "_rotated_log_paths", lambda _base: [])
asyncio.run(system_mod.clear_system_logs())
for name in ("omnivoice.log", "omnivoice.log.1", "omnivoice.log.2"):
assert (rolling / name).stat().st_size == 0, f"{name} survived a Clear that trusted a stale scan"
def test_clear_empties_the_backups_too(system_mod, rolling, monkeypatch):
monkeypatch.setattr(system_mod, "prefs_delete", lambda _key: None, raising=False)