GUI: non-blocking disconnect, never close VISA sessions under the worker

Pressing Disconnect with the instruments powered off froze the GUI
(apparent crash, 2026-07-06 15:52): worker.join(5) expired while the
worker sat in multi-second VISA timeouts, then bench.close() ran on the
GUI thread -- each driver close() first writes local() to the dead
device (stacked timeouts) and closing a session under an in-flight read
can also take NI-VISA down natively.

_disconnect now detaches the UI immediately and runs all instrument
teardown on a background thread; if the worker is still stuck in I/O
after a 15s join the sessions are deliberately left open (OS reclaims
them at exit) instead of being closed under a live read. Reconnecting
while a teardown is still releasing the instruments is refused with a
console hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-06 16:59:20 +07:00
co-authored by Claude Fable 5
parent 5b168439e8
commit ca5f0fe9c2
+40 -9
View File
@@ -156,6 +156,7 @@ class TestbenchGUI(tk.Tk):
self.bench: MPPTTestbench | None = None
self.worker: InstrumentWorker | None = None
self._teardown_thread: threading.Thread | None = None
self._log_file = None
self._log_writer = None
self._log_count = 0
@@ -1141,6 +1142,10 @@ class TestbenchGUI(tk.Tk):
# ── Connection ────────────────────────────────────────────────────
def _connect(self) -> None:
if self._teardown_thread and self._teardown_thread.is_alive():
self._console("Previous disconnect is still releasing the "
"instruments - try again in a few seconds", "warn")
return
try:
supply_addr = self._supply_addr.get().strip()
if supply_addr == "auto":
@@ -1193,16 +1198,9 @@ class TestbenchGUI(tk.Tk):
def _disconnect(self) -> None:
self._stop_log()
if self.worker:
self.worker.stop()
self.worker.join(timeout=5)
worker, bench = self.worker, self.bench
self.worker = None
if self.bench:
try:
self.bench.close()
except Exception:
pass
self.bench = None
self.bench = None # _poll chain sees both None and ends
self._btn_connect.config(state=tk.NORMAL)
self._btn_setup.config(state=tk.DISABLED)
@@ -1212,7 +1210,40 @@ class TestbenchGUI(tk.Tk):
self._load_baud.config(state=tk.NORMAL)
self._meter_addr.config(state=tk.NORMAL)
self._status_label.config(text="Disconnected")
if not worker and not bench:
self._console("Disconnected")
return
# Instrument teardown runs OFF the GUI thread: with dead/absent
# devices every VISA/serial call blocks in multi-second timeouts,
# which froze the GUI ("not responding" -> apparent crash) when
# this was inline (2026-07-06 15:52). Closing a VISA session
# while the worker is still inside a read can also crash NI-VISA
# natively, so if the worker won't exit the sessions are left
# open -- the OS reclaims them at process exit.
def _teardown():
def say(msg, tag=None):
try:
self._console(msg, tag) if tag else self._console(msg)
except Exception:
pass # window already destroyed
if worker:
worker.stop()
worker.join(timeout=15)
if bench:
if worker and worker.is_alive():
say("Instrument worker stuck in an I/O timeout - "
"connections left open (freed on exit)", "warn")
else:
try:
bench.close()
except Exception:
pass
say("Disconnected")
self._teardown_thread = threading.Thread(target=_teardown, daemon=True)
self._teardown_thread.start()
def _setup_all(self) -> None:
self._send(Cmd.SETUP_ALL)