From ca5f0fe9c23e4db2d6edc5acf2359e72b1d469a6 Mon Sep 17 00:00:00 2001 From: janik Date: Mon, 6 Jul 2026 16:59:20 +0700 Subject: [PATCH] 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 --- testbench/gui.py | 53 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/testbench/gui.py b/testbench/gui.py index 8b8e041..a16cafc 100644 --- a/testbench/gui.py +++ b/testbench/gui.py @@ -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) - self.worker = None - if self.bench: - try: - self.bench.close() - except Exception: - pass - self.bench = None + worker, bench = self.worker, self.bench + self.worker = 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") - self._console("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)