Skip to content

fix(threads): stop crossing thread boundaries in the RX and TX audio paths - #57

Merged
bucknova merged 2 commits into
mainfrom
fix/thread-affinity
Sep 1, 2026
Merged

fix(threads): stop crossing thread boundaries in the RX and TX audio paths#57
bucknova merged 2 commits into
mainfrom
fix/thread-affinity

Conversation

@bucknova

@bucknova bucknova commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Three thread-affinity defects from the adversarial review.

This area has form — a cross-thread PortAudio abort corrupted the heap, and a
QThread destroyed while running triggered qFatal — so each change here is
narrow, and each carries a test that fails without it.

1. The audio-device swap reset the decoder from the wrong thread

_swap_audio_worker called RxWorker.reset() directly from the GUI
thread
. The audio worker's stopped signal fans out to both
_rx_worker.flush (queued → RX thread) and the swap loop's quit (GUI), so
the GUI could wake and reset the decoder while a flush was still inside
Decoder.feed(), clearing _buffer / _scratch / _total_samples under a
live decode.

Worse, reset() lazily creates the RX watchdog QTimer, which takes the
affinity of whoever calls it. Created from the GUI thread, the watchdog then
ran its checks on the GUI thread while shutdown() — on the worker thread —
could no longer stop it (QObject::killTimer: Timers cannot be stopped from another thread), leaving a live timer firing into a worker being torn down.

The queued signal that does this properly, _request_rx_reset, was already
declared and already wired to that exact slot — ten lines above the direct
call
. The swap now uses it, and orders the capture restart after the reset
rather than racing it on a second worker thread.

_ensure_watchdog_timer now refuses to create the timer when the caller
isn't the owning thread, so this can't regress quietly.

2. The start-capture one-shot ran on the RX decode thread

reset_done → _start_once was a bare closure. A plain callable has no
QObject affinity, so PySide invokes it in the emitting thread — the RX
decode thread — where it called sd.query_devices(), a process-global
PortAudio read that can run concurrently with the audio worker's own
_pa_reset, and wrote _input_device while the GUI thread read it.

Now a real slot on MainWindow, connected queued at construction, with an
armed flag instead of connect/disconnect bookkeeping. That also retires the
H7 workaround — a second click can no longer leave two closures connected and
start capture twice.

3. stop() could abort a stream that was being closed

It read _active_stream under the lock, released it, then called
abort(). The TX thread clears that pointer under the same lock and only
then lets its with sd.OutputStream(...) block run
Pa_StopStream/Pa_CloseStream — so the released window let
Pa_AbortStream land on a stream mid-close. sounddevice passes the pointer
straight to PortAudio with no state check, and close() frees before
NULLing. That is exactly the cross-thread teardown that corrupted the heap in
PortAudio's ALSA backend before v0.6.2.

The abort now happens under the lock the TX thread must hold to tear down.
The module's own stress testing puts abort() well under a millisecond, so
holding it is cheap.

Tests

Two regression tests, both verified to fail without their fix. The swap test
detaches _request_rx_reset from the worker's slot first, so the only way
reset() can be reached is a direct call — otherwise the spy can't tell the
two apart.

137 passed across tests/ui/test_stability_fixes.py, test_rx_worker.py and
tests/audio; ruff clean.

Not addressed here

The review also flagged nested event loops letting closeEvent re-enter, and
_abort_offline_workers blocking up to 20 s then calling terminate() on a
thread likely inside numpy. Both are real, both are bigger than this PR, and
given the history in this file I'd rather they land on their own.

🤖 Generated with Claude Code

bucknova and others added 2 commits August 31, 2026 18:31
…paths

Three thread-affinity defects from the adversarial review. This area has a
history — a cross-thread PortAudio abort corrupted the heap, and a QThread
destroyed while running triggered qFatal — so each change is narrow and
carries a regression test.

1. _swap_audio_worker called RxWorker.reset() directly from the GUI thread.
   The audio worker's `stopped` fans out to both _rx_worker.flush (queued,
   RX thread) and the swap loop's quit (GUI), so the GUI could wake and
   reset the decoder while a flush was still inside Decoder.feed() —
   clearing _buffer/_scratch/_total_samples under a live decode. And
   reset() lazily creates the RX watchdog QTimer, which takes the affinity
   of whatever thread calls it: from the GUI thread that means the watchdog
   then ran its checks on the GUI thread while shutdown(), on the worker
   thread, could not stop it ("Timers cannot be stopped from another
   thread"), leaving a live timer firing into a torn-down worker.

   The swap now emits _request_rx_reset — the queued signal already wired
   to that exact slot, ten lines from the direct call — and orders the
   capture restart after the reset instead of racing it on a second worker
   thread. _ensure_watchdog_timer now refuses to create the timer when the
   caller is not the owning thread, so this cannot regress silently.

2. The reset_done -> start-capture one-shot was a bare closure. A plain
   callable has no QObject affinity, so PySide invoked it directly on the
   RX decode thread, where it called sd.query_devices() — a process-global
   PortAudio read that can run concurrently with the audio worker's own
   _pa_reset — and wrote _input_device while the GUI thread read it.

   It is now a real slot on MainWindow connected queued at construction,
   with an armed flag replacing the connect/disconnect bookkeeping. That
   also retires the H7 workaround: a second click can no longer leave two
   closures connected and start capture twice.

3. output_stream.stop() read _active_stream under the lock, released it,
   then called abort(). The TX thread clears that pointer under the same
   lock and only then lets its `with sd.OutputStream(...)` block run
   Pa_StopStream/Pa_CloseStream, so the released window let Pa_AbortStream
   land on a stream mid-close — sounddevice passes the pointer straight to
   PortAudio with no state check, and close() frees before NULLing. abort()
   now happens under the lock; it measures well under a millisecond.

Regression tests for 1 and 2, both verified to fail without the fix. The
swap test detaches the signal from the worker's slot first, so the only way
reset() can be reached is a direct call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bucknova
bucknova merged commit 5a44269 into main Sep 1, 2026
18 checks passed
@bucknova
bucknova deleted the fix/thread-affinity branch September 1, 2026 03:37
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