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)