DEV Community

Cover image for Four Bugs I Only Found by Driving the Real Window of a PySide6 App
WgeorgeAssistantIA
WgeorgeAssistantIA

Posted on

Four Bugs I Only Found by Driving the Real Window of a PySide6 App

My app is a desktop tool that traces images into SVG. The tracing engine is vtracer, a Rust extension, and the UI is PySide6. The engine had good unit tests. Then I used the app for real, and four bugs showed up that none of those tests could see.

All four come from the same place: the interaction between Qt, threads and a native extension. If you build a PySide6 or PyQt app that runs work in a QThread, you may hit the same ones.

Bug 1: the app died with no exception

While a trace was running, the user could paint another stroke. The handler for the mask-changed signal put the selection back with canvas.set_mask(...). But set_mask emits the same signal again, so the handler called itself.

That is infinite recursion, but not the kind Python complains about. It went through Qt's C++ signal machinery, so Python's recursion limit never triggered. The process simply died with a native stack overflow (0xC00000FD on Windows). No traceback, no message.

The rule I took from it: never react to a Qt signal by modifying the thing that emitted it. A guard flag fixes it:

self._suspend_mask = True
try:
    self.canvas.clear_mask()      # emits mask_changed
finally:
    self._suspend_mask = False

def _on_mask_changed(self):
    if self._suspend_mask:
        return
    ...
Enter fullscreen mode Exit fullscreen mode

Bug 2: the UI froze forever after a Rust panic

The worker looked like this:

class Worker(QThread):
    done = Signal(object)
    failed = Signal(str)

    def run(self):
        try:
            self.done.emit(self._fn())
        except Exception as e:
            self.failed.emit(str(e))
Enter fullscreen mode Exit fullscreen mode

With a corrupt image, vtracer panicked. pyo3 turns a Rust panic into a Python PanicException, and that class derives from BaseException, not Exception. My except never caught it. failed was never emitted, so the code that re-enables the buttons never ran. Every button stayed greyed out, with no message.

The fix is to catch BaseException in the worker (re-raising KeyboardInterrupt and SystemExit), and to add a safety net: finished is emitted even when run() ends on an uncaught exception, so a handler on it can reset the "busy" state.

If you wrap any native extension, check what its panics or errors derive from.

Bug 3: a second worker silently refused to start

After a click, the app ran one worker, and when it finished it should start a second one. I called the next _run() directly from the done handler. Sometimes nothing happened.

_run() starts with a guard:

if self._worker is not None and self._worker.isRunning():
    return False
Enter fullscreen mode Exit fullscreen mode

The problem: done is emitted from inside run(), that is, from the worker's own thread, before the thread has finished stopping. At that exact moment isRunning() can still return True. The new call was rejected by its own guard, depending on timing. It never showed up when I tested one case alone, only inside the full test suite.

The fix is to let the event loop turn once before chaining:

def _on_selection_done(self, mask):
    QTimer.singleShot(0, lambda: self._apply_selection(mask))
Enter fullscreen mode Exit fullscreen mode

My rule now: chaining one worker from another's done always goes through QTimer.singleShot(0, ...), never a direct call.

A related trap sits in the same area. Each worker removes itself from self._worker on finished. A chain started from done had already installed the next worker, and the previous worker's finished then wiped it out. Nothing blocked two jobs from running on the same session. The fix is to forget a worker only if it is still the current one:

def _forget_worker(self, worker):
    if self._worker is worker:
        self._worker = None
Enter fullscreen mode Exit fullscreen mode

Bug 4: a keyboard shortcut bypassed the greyed-out buttons

I disabled the buttons while a trace was running. But a shortcut like Ctrl+O does not go through a button. Pressing it during a computation opened a new image, which closed the session and deleted its temporary folder under the running thread. The thread then died with "Cannot create output file".

The fix is a single predicate used by every entry point, shortcuts included:

def _idle(self) -> bool:
    return self._worker is None or not self._worker.isRunning()

def open_image(self):
    if not self._idle():
        return
    ...
Enter fullscreen mode Exit fullscreen mode

How I test this now

Engine tests cannot catch these. The failures only exist when the real window, the real event loop and the real thread run together. So I keep a separate file of regression tests that drives the actual MainWindow in Qt's offscreen mode, with modal dialogs disabled (otherwise a test blocks silently on a message box).

Two practical details:

  • To test "Ctrl+O during a computation", force a slow job (time.sleep(1.5) in the worker). On a small test image a real trace ends in tens of milliseconds, and the test would check nothing.
  • To test the panic case, raise a custom BaseException subclass in the worker and assert that the buttons are enabled again afterwards.

What I would tell my past self

  • Qt signals cross threads and C++ code. Python's safety nets do not follow them.
  • done is not "the thread has stopped".
  • Every way to start work needs the same guard, not just the buttons.
  • Test the glue, not just the engine.

The app

This is part of VectoFix, a local Windows tool that traces an image into SVG, measures what the tracing lost compared with the source, and lets you fix a zone with a brush. Everything runs on the user's machine.

Have you hit a thread or signal bug that only appeared with the real UI? I am curious what your regression tests look like.

Top comments (0)