Skip to content

fix(gui): delete the calculation worker on the GUI thread to stop a freeze - #15

Merged
dinacaran merged 1 commit into
mainfrom
claude/calc-thread-deadlock
Sep 26, 2026
Merged

dinacaran merged 1 commit into
mainfrom
claude/calc-thread-deadlock

Conversation

@dinacaran

Copy link
Copy Markdown
Collaborator

Problem

The app could freeze when a generated signal finished calculating, and the test suite hung intermittently in tests/test_calculated_signal_gui.py (it stalled a pre-commit run for over 10 minutes).

When a calculation finished, its worker deleted itself on its own thread (finished → deleteLater). The worker's destructor holds a Qt signal-slot mutex while it waits for the Python GIL. At the same moment the GUI thread, holding the GIL, refreshes the signal tree and plot, and those Qt calls can wait for the same mutex (Qt shares a small pool of these mutexes by object address). Both threads then wait forever.

Native stack dumps of a frozen process confirmed it: the GUI thread was waiting on a Qt mutex in QTreeView::expandToDepth (or in QObject::disconnect from a plot resize), while the worker thread sat in QObject::~QObject → PyGILState_Ensure.

Changes

  • gui/main_window.py: the worker is no longer deleted on its own thread. _cleanup_calculation drops the last reference after the thread has stopped, so the worker is deleted on the GUI thread, which already holds the GIL.
  • tests/test_calculated_signal_gui.py: new test test_calculation_worker_is_deleted_on_the_gui_thread, for a finished and a failed calculation.

Testing

  • Defect injection: with the two deleteLater connections restored, both new cases fail (the worker is deleted on the worker thread).
  • Stress script (5,000 calculations per run, one process): the old code froze in 4 of 11 runs; the fix completed 20 of 20 runs (100,000 calculations) with no freeze.
  • Full suite: 586 passed.

Protected files

None. Only the generated-signal calculation code in gui/main_window.py changed; the Load + Decode wiring is untouched.

Not changed: the load worker's cleanup uses a similar pattern, but its window for this race is much narrower and it is in the protected load path.

🤖 Generated with Claude Code

…reeze

When a generated signal finished calculating, its worker deleted itself on
its own thread (finished -> deleteLater). The worker's destructor holds a Qt
signal-slot mutex while it waits for the Python GIL, and the GUI thread,
holding the GIL while it refreshed the signal tree and plot, could wait for
that same mutex. Both threads then waited forever: the app froze, and the
test suite hung intermittently in test_calculated_signal_gui.py.

The worker is no longer deleted on its own thread. _cleanup_calculation
drops the last reference once the thread has stopped, so it is deleted on
the GUI thread.

A new test checks the worker is deleted on the GUI thread, for a finished
and a failed calculation; both fail with the old wiring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dinacaran
dinacaran merged commit 29a32bd into main Sep 26, 2026
4 checks passed
@dinacaran
dinacaran deleted the claude/calc-thread-deadlock branch September 26, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant