Files
Llamagochi/log_watcher.py
Vlad Doloman 311819b51c fix: sshfs polling, WebSocket broadcast, prompt progress bar, resizable stats panel
- log_watcher: add --reopen-log mode (close/reopen each poll) for sshfs/network
  filesystems; fix nonlocal clients bug causing UnboundLocalError on first broadcast;
  fix list(clients) copy to prevent RuntimeError on concurrent set mutation
- log_parser: capture progress=X.XX from prompt timing lines into Metrics.prompt_progress
- frontend: show progress bar during processing_prompt state with PROMPT N% label;
  widen stats panel to 300px; remove model name truncation; add drag handle on
  right border of stats panel to resize between 280px–840px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:02:34 +03:00

115 lines
4.0 KiB
Python

import asyncio
import os
from pathlib import Path
from typing import Callable, Awaitable, Optional
class LogWatcher:
POLL_INTERVAL = 0.1
FILE_RETRY_INTERVAL = 1.0
SEED_BYTES = 8192 # read last ~8 KB on open to seed initial state
def __init__(
self,
path: str,
on_line: Callable[[str], Awaitable[None]],
on_offline: Optional[Callable[[], Awaitable[None]]] = None,
offline_timeout: float = 60.0,
reopen: bool = False,
):
self._path = Path(path)
self._on_line = on_line
self._on_offline = on_offline
self._offline_timeout = offline_timeout
self._reopen = reopen
self._task: Optional[asyncio.Task] = None
def start(self) -> asyncio.Task:
self._task = asyncio.create_task(self._run())
return self._task
def stop(self):
if self._task:
self._task.cancel()
async def _run(self):
offline_since: Optional[float] = None
if self._reopen:
await self._run_reopen()
else:
await self._run_keepopen()
async def _run_keepopen(self):
"""Default: keep file open and poll readline(). Works on local filesystems."""
offline_since: Optional[float] = None
while True:
try:
with open(self._path, 'r', errors='replace') as f:
f.seek(0, 2)
end = f.tell()
f.seek(max(0, end - self.SEED_BYTES))
if f.tell() > 0:
f.readline() # discard partial first line
offline_since = None
while True:
line = f.readline()
if line:
await self._on_line(line)
else:
await asyncio.sleep(self.POLL_INTERVAL)
except OSError:
now = asyncio.get_event_loop().time()
if offline_since is None:
offline_since = now
elif now - offline_since >= self._offline_timeout and self._on_offline:
await self._on_offline()
offline_since = None
await asyncio.sleep(min(self.FILE_RETRY_INTERVAL, self._offline_timeout))
except asyncio.CancelledError:
return
async def _run_reopen(self):
"""Close and reopen each poll cycle. Use for network filesystems (e.g. sshfs)
that cache file content at open time and don't propagate remote appends to
held-open file descriptors."""
offline_since: Optional[float] = None
pos: int = -1 # -1 = not yet seeded
while True:
try:
with open(self._path, 'r', errors='replace') as f:
f.seek(0, 2)
end = f.tell()
if pos < 0:
f.seek(max(0, end - self.SEED_BYTES))
if f.tell() > 0:
f.readline() # discard partial first line
pos = f.tell()
elif end < pos:
pos = 0 # file truncated / rotated
f.seek(pos)
while True:
line = f.readline()
if line:
pos = f.tell()
await self._on_line(line)
else:
break
offline_since = None
await asyncio.sleep(self.POLL_INTERVAL)
except OSError:
now = asyncio.get_event_loop().time()
if offline_since is None:
offline_since = now
elif now - offline_since >= self._offline_timeout and self._on_offline:
await self._on_offline()
offline_since = None
await asyncio.sleep(min(self.FILE_RETRY_INTERVAL, self._offline_timeout))
except asyncio.CancelledError:
return