From 7d01144143b8d73027b0dffa03863effbd8acd32 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 29 May 2026 11:15:22 -0300 Subject: [PATCH] =?UTF-8?q?Fix=20stuck=20"thinking=E2=80=A6"=20=E2=80=94?= =?UTF-8?q?=20background=20task=20GC=20dropped=20the=20done=20signal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The QRunnable was auto-deleted the instant its work finished, destroying its signals object before Qt delivered the queued result to the UI thread, so the "done" callback never fired and the command bar stayed disabled. Now tasks keep a strong reference (autoDelete off) until the result is delivered, then drop it. Also: the WoodShop reply summary is now logged to the transcript on success (previously computed but never shown), and the error path re-enables input. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/woodshop/gui/command_bar.py | 14 ++++++++++---- src/woodshop/gui/workers.py | 30 +++++++++++++++++++++++++----- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/woodshop/gui/command_bar.py b/src/woodshop/gui/command_bar.py index 78f99f5..19ce91d 100644 --- a/src/woodshop/gui/command_bar.py +++ b/src/woodshop/gui/command_bar.py @@ -88,11 +88,17 @@ class CommandBar(QWidget): def done(summary): self._busy(False) - if self.speak.isChecked() and summary: - run_async(self.pool, lambda: subprocess.run( - ["read-aloud", "--strip-md", "true"], input=summary, text=True)) + if summary: + self._log("ws", summary) + if self.speak.isChecked(): + run_async(self.pool, lambda: subprocess.run( + ["read-aloud", "--strip-md", "true"], input=summary, text=True)) - run_async(self.pool, work, on_done=done, on_error=lambda e: (self._busy(False), self._log("sys", e))) + def failed(err): + self._busy(False) + self._log("sys", err) + + run_async(self.pool, work, on_done=done, on_error=failed) # ----- voice ------------------------------------------------------- def _listen(self) -> None: diff --git a/src/woodshop/gui/workers.py b/src/woodshop/gui/workers.py index 048032b..6bf6e79 100644 --- a/src/woodshop/gui/workers.py +++ b/src/woodshop/gui/workers.py @@ -1,5 +1,11 @@ """Run slow work (dictate, the LLM call, read-aloud) off the Qt event loop so -the UI never freezes.""" +the UI never freezes. + +Lifetime note: a QRunnable is auto-deleted by the pool the moment run() returns, +which can destroy its signals object before Qt delivers the queued result to the +UI thread (the "done" callback then never fires). We disable auto-delete and +keep a strong reference until the result is delivered, then drop it. +""" from __future__ import annotations from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal @@ -15,6 +21,7 @@ class _Task(QRunnable): super().__init__() self.fn = fn self.signals = _Signals() + self.setAutoDelete(False) # we manage lifetime (see module docstring) def run(self): try: @@ -23,10 +30,23 @@ class _Task(QRunnable): self.signals.error.emit(str(exc)) +_active: set[_Task] = set() + + def run_async(pool: QThreadPool, fn, on_done=None, on_error=None) -> None: task = _Task(fn) - if on_done: - task.signals.done.connect(on_done) - if on_error: - task.signals.error.connect(on_error) + _active.add(task) # keep alive until a result is delivered on the UI thread + + def finish_done(result): + _active.discard(task) + if on_done: + on_done(result) + + def finish_error(message): + _active.discard(task) + if on_error: + on_error(message) + + task.signals.done.connect(finish_done) + task.signals.error.connect(finish_error) pool.start(task)