fix: updater survives Settings saves + cache unsniffable MLGs (v1.6.1)
Two reliability fixes for the Series 3 tray app: 1. Auto-updater no longer dies on a Settings save. _restart_watcher set+replaced the shared stop_event that the updater loop also keyed off, so saving settings could kill the updater thread (race). Give the updater its own app-lifetime event (_app_stop). Same fix as thor-watcher 0.4.1. 2. Unsniffable .MLG files are now cached, so each is sniffed + logged once per session instead of every 5-min scan. A long-lived autocall folder with a few hundred unidentifiable files was flooding the log (~600K lines in 10 days) and re-reading every header each scan. Adds test_series3_tray.py (4 lifecycle tests) and test_scan_latest.py (2 scan tests). Full suite 50 passing. Bump to v1.6.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [6-25-26] — v1.6.1
|
||||
|
||||
Reliability fixes for the tray app's auto-updater and scan logging.
|
||||
|
||||
### Fixed
|
||||
- **Auto-updater no longer dies when Settings are saved.** Saving the Settings dialog restarts the watcher thread via `_restart_watcher`, which sets and then replaces the shared `stop_event`. Because the updater loop keyed off that *same* event, saving settings would terminate the auto-updater thread (a race — it could survive one save and die on the next). The watcher kept running and reporting heartbeats, but the agent silently stopped checking for / applying updates until the tray app was relaunched. The updater now keys off its own app-lifetime event (`_app_stop`) that watcher restarts never touch; it stops only on Exit or when applying an update. (Same fix as thor-watcher 0.4.1.)
|
||||
- **Unsniffable `.MLG` files are logged once, not every scan.** A `.MLG` file the watcher couldn't extract a unit ID from was never cached, so every scan (~every 5 min) re-sniffed *and* re-emitted its `[unsniffable-recent]` log line. With a few hundred such files in a long-lived Blastware autocall folder that meant tens of thousands of duplicate lines per day (a 10-day-old log had grown to ~600K lines). The scan now caches the unsniffable result, so each file is sniffed and logged once per session — and the redundant per-scan header reads are gone.
|
||||
- Added `test_scan_latest.py` (unsniffable cached / logged-once + happy-path sniff) and `test_series3_tray.py` (updater survives a watcher restart; stops on exit).
|
||||
|
||||
## [6-25-26] — v1.6.0
|
||||
|
||||
Optional dual-send mirror so a standby server stays a live replica.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Series 3 Watcher v1.6.0
|
||||
# Series 3 Watcher v1.6.1
|
||||
|
||||
Monitors Instantel **Series 3 (Minimate)** call-in activity on a Blastware server. Runs as a **system tray app** that starts automatically on login, reports heartbeats to terra-view, and self-updates from Gitea.
|
||||
|
||||
@@ -188,7 +188,7 @@ where the corresponding server-side work lives.
|
||||
|
||||
## Versioning
|
||||
|
||||
Follows **Semantic Versioning**. Current release: **v1.6.0**.
|
||||
Follows **Semantic Versioning**. Current release: **v1.6.1**.
|
||||
See `CHANGELOG.md` for full history.
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
[Setup]
|
||||
AppName=Series 3 Watcher
|
||||
AppVersion=1.6.0
|
||||
AppVersion=1.6.1
|
||||
AppPublisher=Terra-Mechanics Inc.
|
||||
DefaultDirName={pf}\Series3Watcher
|
||||
DefaultGroupName=Series 3 Watcher
|
||||
|
||||
+7
-4
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Series 3 Watcher — System Tray Launcher v1.6.0
|
||||
Series 3 Watcher — System Tray Launcher v1.6.1
|
||||
Requires: pystray, Pillow, tkinter (stdlib)
|
||||
|
||||
Run with: pythonw series3_tray.py (no console window)
|
||||
@@ -335,7 +335,8 @@ def _show_cancel_message():
|
||||
class WatcherTray:
|
||||
def __init__(self):
|
||||
self.state = {}
|
||||
self.stop_event = threading.Event()
|
||||
self.stop_event = threading.Event() # watcher lifecycle; replaced on restart
|
||||
self._app_stop = threading.Event() # app lifetime; only set on exit/update
|
||||
self._watcher_thread = None
|
||||
self._icon = None
|
||||
# Lock guards _rebuild_menu calls from the updater thread
|
||||
@@ -394,6 +395,7 @@ class WatcherTray:
|
||||
subprocess.Popen(["explorer", HERE])
|
||||
|
||||
def _exit(self, icon, item):
|
||||
self._app_stop.set()
|
||||
self.stop_event.set()
|
||||
icon.stop()
|
||||
|
||||
@@ -459,7 +461,7 @@ class WatcherTray:
|
||||
last_status = None
|
||||
update_check_counter = 0 # check for updates every ~5 min (30 * 10s ticks)
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
while not self._app_stop.is_set():
|
||||
icon_status = self._tray_status()
|
||||
|
||||
if self._icon is not None:
|
||||
@@ -487,7 +489,7 @@ class WatcherTray:
|
||||
self._do_update(url)
|
||||
return # exit loop; swap bat will relaunch
|
||||
|
||||
self.stop_event.wait(timeout=10)
|
||||
self._app_stop.wait(timeout=10)
|
||||
|
||||
def _do_update(self, download_url=None):
|
||||
"""Notify tray icon then apply update. If url is None, fetch it first."""
|
||||
@@ -502,6 +504,7 @@ class WatcherTray:
|
||||
|
||||
success = apply_update(download_url)
|
||||
if success:
|
||||
self._app_stop.set()
|
||||
self.stop_event.set()
|
||||
if self._icon is not None:
|
||||
self._icon.stop()
|
||||
|
||||
+10
-7
@@ -237,13 +237,16 @@ def scan_latest(
|
||||
uid = cached[1]
|
||||
else:
|
||||
uid = sniff_unit_from_mlg(fpath, header_bytes)
|
||||
if not uid:
|
||||
# If unsniffable but very recent, log for later inspection
|
||||
if (recent_cutoff is not None) and (mtime >= recent_cutoff):
|
||||
if logger:
|
||||
logger("[unsniffable-recent] {}".format(fpath))
|
||||
continue # skip file if no unit ID found in header
|
||||
# Cache the result either way — including unsniffable (uid=None) —
|
||||
# so the same file isn't re-sniffed and re-logged on every scan.
|
||||
cache[fpath] = (mtime, uid)
|
||||
if (not uid) and (recent_cutoff is not None) and (mtime >= recent_cutoff):
|
||||
# Log once, on first sight, for later inspection.
|
||||
if logger:
|
||||
logger("[unsniffable-recent] {}".format(fpath))
|
||||
|
||||
if not uid:
|
||||
continue # no unit ID in header — skip (cached above, won't re-log)
|
||||
|
||||
if (uid not in latest) or (mtime > latest[uid]["mtime"]):
|
||||
latest[uid] = {"mtime": mtime, "fname": e.name, "path": fpath}
|
||||
@@ -253,7 +256,7 @@ def scan_latest(
|
||||
|
||||
|
||||
# --- API heartbeat / SFM telemetry helpers ---
|
||||
VERSION = "1.6.0"
|
||||
VERSION = "1.6.1"
|
||||
|
||||
|
||||
def _read_log_tail(log_file: str, n: int = 25) -> Optional[list]:
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Series 3 Watcher — Settings Dialog v1.6.0
|
||||
Series 3 Watcher — Settings Dialog v1.6.1
|
||||
|
||||
Provides a Tkinter settings dialog that doubles as a first-run wizard.
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Tests for scan_latest's handling of unsniffable .MLG files.
|
||||
|
||||
Regression guard: a .MLG file the watcher can't extract a unit ID from must
|
||||
be cached (as "no id") so it isn't re-sniffed and re-logged on every scan.
|
||||
Previously the unsniffable result was never cached, so the
|
||||
`[unsniffable-recent]` warning was re-emitted every scan cycle — ~200 files
|
||||
x 288 scans/day flooded the log with hundreds of thousands of duplicate lines.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import series3_watcher
|
||||
|
||||
|
||||
class UnsniffableCaching(unittest.TestCase):
|
||||
def test_unsniffable_recent_file_logged_once_across_scans(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
# A .MLG file with no BE####/BA#### pattern → sniff returns None.
|
||||
path = os.path.join(d, "junk001.MLG")
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"\x00" * 512)
|
||||
|
||||
calls = []
|
||||
cache = {}
|
||||
recent_cutoff = time.time() - 86400.0 # file (mtime≈now) counts as recent
|
||||
|
||||
for _ in range(3):
|
||||
series3_watcher.scan_latest(
|
||||
watch=d,
|
||||
header_bytes=2048,
|
||||
cache=cache,
|
||||
recent_cutoff=recent_cutoff,
|
||||
max_age_days=365,
|
||||
logger=calls.append,
|
||||
)
|
||||
|
||||
unsniff = [c for c in calls if "[unsniffable-recent]" in c]
|
||||
self.assertEqual(
|
||||
len(unsniff), 1,
|
||||
"unsniffable file should log once across scans, got {}: {}".format(
|
||||
len(unsniff), unsniff),
|
||||
)
|
||||
|
||||
def test_sniffable_file_still_returned(self):
|
||||
# Sanity: a file WITH a unit id is still detected (happy path intact).
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "evt001.MLG")
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"hdr\x00BE12599\x00hdr") # contains a sniffable unit id
|
||||
cache = {}
|
||||
result = series3_watcher.scan_latest(
|
||||
watch=d, header_bytes=2048, cache=cache,
|
||||
recent_cutoff=time.time() - 86400.0, max_age_days=365, logger=None,
|
||||
)
|
||||
self.assertIn("BE12599", result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Tests for series3_tray's thread lifecycle.
|
||||
|
||||
Regression guard: saving the Settings dialog restarts the watcher thread via
|
||||
``_restart_watcher``, which sets and replaces the shared ``stop_event``. The
|
||||
auto-updater must key off its own lifetime event (``_app_stop``) so a watcher
|
||||
restart never kills it; it should only stop when the app actually exits.
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
# Series3's tray pulls in Windows-only GUI deps at import time; stub them.
|
||||
for _name in ("pystray", "PIL", "PIL.Image", "PIL.ImageDraw"):
|
||||
sys.modules.setdefault(_name, mock.MagicMock())
|
||||
|
||||
import series3_tray # noqa: E402
|
||||
|
||||
|
||||
class TrayThreadLifecycle(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# No-op watcher loop so restarting the watcher doesn't spin up the real
|
||||
# Blastware scanner / network heartbeat.
|
||||
patcher = mock.patch.object(
|
||||
series3_tray.watcher, "run_watcher", lambda state, stop_event: None
|
||||
)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_app_stop_is_separate_lifetime_event(self):
|
||||
app = series3_tray.WatcherTray()
|
||||
self.assertIsInstance(app._app_stop, threading.Event)
|
||||
self.assertIsNot(app._app_stop, app.stop_event)
|
||||
self.assertFalse(app._app_stop.is_set())
|
||||
|
||||
def test_exit_signals_app_stop(self):
|
||||
app = series3_tray.WatcherTray()
|
||||
icon = mock.MagicMock()
|
||||
app._exit(icon, None)
|
||||
self.assertTrue(app._app_stop.is_set())
|
||||
icon.stop.assert_called_once()
|
||||
|
||||
def test_restart_watcher_leaves_watcher_stop_unset(self):
|
||||
app = series3_tray.WatcherTray()
|
||||
app._restart_watcher()
|
||||
self.assertFalse(app.stop_event.is_set())
|
||||
|
||||
def test_updater_survives_watcher_restart_then_stops_on_exit(self):
|
||||
app = series3_tray.WatcherTray()
|
||||
app._icon = None
|
||||
with mock.patch.object(series3_tray, "check_for_update", return_value=(None, None)), \
|
||||
mock.patch.object(app, "_tray_status", return_value="ok"):
|
||||
t = threading.Thread(
|
||||
target=app._icon_updater, daemon=True, name="test-updater"
|
||||
)
|
||||
t.start()
|
||||
time.sleep(0.1)
|
||||
self.assertTrue(t.is_alive(), "updater failed to start")
|
||||
|
||||
# A settings save restarts the watcher — it must NOT kill the updater.
|
||||
app._restart_watcher()
|
||||
time.sleep(0.2)
|
||||
self.assertTrue(
|
||||
t.is_alive(), "updater thread died on watcher restart (the bug)"
|
||||
)
|
||||
|
||||
# Real app exit DOES stop the updater.
|
||||
app._app_stop.set()
|
||||
t.join(timeout=2)
|
||||
self.assertFalse(
|
||||
t.is_alive(), "updater thread did not stop on app exit"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user