Fix #8: scheduler survives link death; timed-out one-offs cancelled

- A transport exception in the poll loop killed the thread silently, leaving the
  GUI on a frozen 'Connected' dashboard and blocking run_oneoff callers for the
  full timeout. _loop now catches it -> stops, fails pending one-offs with the
  real error, and calls an on_error callback. Controller wires on_error to flag
  the connection dead; the GUI detects it in _tick and tears down with a
  'Connection lost' dialog.
- A run_oneoff that timed out left its job queued, so it executed LATER on the
  shared link -- a ghost/duplicate vehicle command. Jobs now carry
  cancelled/started flags under a lock; on timeout a not-yet-started job is
  cancelled (skipped by _drain_oneoffs), and a started one reports 'still
  running -- do NOT retry'. stop() also frees stranded submitters.
- tests/test_scheduler.py: cancel-on-timeout, freed-on-death, loop-survives.

Closes #8

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
This commit is contained in:
2026-07-01 19:33:33 -04:00
parent b5e0c96763
commit 23c92018c1
4 changed files with 154 additions and 13 deletions
+13
View File
@@ -775,6 +775,17 @@ class MainWindow(QtWidgets.QMainWindow):
b.setEnabled(False)
self.status.showMessage("Disconnected.")
def _on_link_lost(self, exc):
"""The polling thread died (transport failure). Tear down and tell the
user instead of leaving a frozen 'Connected' dashboard."""
self.ctl.poll_error = None # one-shot
self._disconnect()
self.status.showMessage(f"Connection lost: {exc}")
QtWidgets.QMessageBox.warning(
self, "Connection lost",
f"The adapter connection failed and polling stopped:\n\n{exc}\n\n"
"Check the adapter/cable and reconnect.")
# ---------- PID selection ----------
def _apply_preset(self, name):
if not self.ctl.connected:
@@ -1001,6 +1012,8 @@ class MainWindow(QtWidgets.QMainWindow):
def _tick(self):
if not self.ctl.connected:
if getattr(self.ctl, "poll_error", None) is not None:
self._on_link_lost(self.ctl.poll_error)
return
self.tree.blockSignals(True)
for key, it in self._items.items():